merge: POP 재고관리 전면 구현 (feature→staging)
This commit is contained in:
@@ -132,6 +132,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
||||
import popInventoryRoutes from "./routes/popInventoryRoutes"; // POP 재고 조정/이동
|
||||
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
@@ -297,6 +298,7 @@ app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
||||
app.use("/api/pop/inventory", popInventoryRoutes); // POP 재고 조정/이동
|
||||
app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
|
||||
1112
backend-node/src/controllers/popInventoryController.ts
Normal file
1112
backend-node/src/controllers/popInventoryController.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,7 @@ async function generateWorkProcessesForInstruction(
|
||||
planQty: string | null,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
batchId?: string | null,
|
||||
): Promise<{
|
||||
processes: Array<{
|
||||
id: string;
|
||||
@@ -171,14 +172,27 @@ async function generateWorkProcessesForInstruction(
|
||||
}>;
|
||||
total_checklists: number;
|
||||
} | null> {
|
||||
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
|
||||
const existCheck = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM work_order_process
|
||||
WHERE wo_id = $1 AND company_code = $2`,
|
||||
[workInstructionId, companyCode],
|
||||
);
|
||||
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
|
||||
return null; // 이미 존재
|
||||
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리)
|
||||
if (batchId) {
|
||||
// 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip
|
||||
const existCheck = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM work_order_process
|
||||
WHERE wo_id = $1 AND company_code = $2 AND batch_id = $3`,
|
||||
[workInstructionId, companyCode, batchId],
|
||||
);
|
||||
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
|
||||
return null; // 이미 존재
|
||||
}
|
||||
} else {
|
||||
// 기존 동작: batch_id 없으면 wo_id 전체로 체크
|
||||
const existCheck = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM work_order_process
|
||||
WHERE wo_id = $1 AND company_code = $2`,
|
||||
[workInstructionId, companyCode],
|
||||
);
|
||||
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
|
||||
return null; // 이미 존재
|
||||
}
|
||||
}
|
||||
|
||||
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
|
||||
@@ -207,13 +221,13 @@ async function generateWorkProcessesForInstruction(
|
||||
let totalChecklists = 0;
|
||||
|
||||
for (const rd of routingDetails.rows) {
|
||||
// 2. work_order_process INSERT
|
||||
// 2. work_order_process INSERT (batch_id 포함)
|
||||
const wopResult = await client.query(
|
||||
`INSERT INTO work_order_process (
|
||||
id, company_code, wo_id, seq_no, process_code, process_name,
|
||||
is_required, is_fixed_order, standard_time, plan_qty,
|
||||
status, routing_detail_id, writer
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
status, routing_detail_id, batch_id, writer
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode,
|
||||
@@ -229,6 +243,7 @@ async function generateWorkProcessesForInstruction(
|
||||
? "acceptable"
|
||||
: "waiting",
|
||||
rd.id,
|
||||
batchId || null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
@@ -358,45 +373,42 @@ export const syncWorkInstructions = async (
|
||||
userId,
|
||||
});
|
||||
|
||||
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
|
||||
// header에 routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응)
|
||||
// 미동기화 작업지시 조회 — 다중 품목(detail) 지원
|
||||
// 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환)
|
||||
// 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목
|
||||
const unsyncedResult = await pool.query(
|
||||
`SELECT wi.id, wi.work_instruction_no,
|
||||
COALESCE(wi.routing, wid.routing_version_id) AS routing,
|
||||
COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty,
|
||||
COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id
|
||||
wi.routing AS header_routing,
|
||||
wi.qty AS header_qty,
|
||||
wi.item_id AS header_item_id
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT routing_version_id, qty, item_number
|
||||
FROM work_instruction_detail
|
||||
WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1
|
||||
LIMIT 1
|
||||
) wid ON true
|
||||
WHERE wi.company_code = $1
|
||||
AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM work_order_process wop
|
||||
WHERE wop.wo_id = wi.id AND wop.company_code = $1
|
||||
AND (
|
||||
-- header routing이 있는데 공정이 아예 없는 경우
|
||||
(wi.routing IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM work_order_process wop
|
||||
WHERE wop.wo_id = wi.id AND wop.company_code = $1
|
||||
))
|
||||
OR
|
||||
-- detail에 routing이 있는 경우 (다중 품목 지원)
|
||||
EXISTS (
|
||||
SELECT 1 FROM work_instruction_detail wid
|
||||
WHERE wid.work_instruction_no = wi.work_instruction_no
|
||||
AND wid.company_code = $1
|
||||
AND wid.routing_version_id IS NOT NULL
|
||||
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM work_order_process wop
|
||||
WHERE wop.wo_id = wi.id AND wop.company_code = $1
|
||||
AND wop.batch_id = wid.item_number
|
||||
)
|
||||
)
|
||||
)`,
|
||||
[companyCode],
|
||||
);
|
||||
|
||||
const unsynced = unsyncedResult.rows;
|
||||
|
||||
// header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화)
|
||||
for (const wi of unsynced) {
|
||||
await pool.query(
|
||||
`UPDATE work_instruction SET
|
||||
routing = COALESCE(routing, $2),
|
||||
qty = COALESCE(NULLIF(qty, ''), $3),
|
||||
item_id = COALESCE(item_id, $4),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $5
|
||||
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
|
||||
[wi.id, wi.routing, wi.qty, wi.item_id, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
if (unsynced.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -410,64 +422,178 @@ export const syncWorkInstructions = async (
|
||||
const details: Array<{
|
||||
work_instruction_id: string;
|
||||
work_instruction_no: string;
|
||||
item_number?: string;
|
||||
status: "synced" | "skipped" | "error";
|
||||
process_count?: number;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const wi of unsynced) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// detail 목록 조회: routing_version_id가 있고 qty > 0인 것
|
||||
const detailResult = await pool.query(
|
||||
`SELECT wid.item_number, wid.routing_version_id, wid.qty
|
||||
FROM work_instruction_detail wid
|
||||
WHERE wid.work_instruction_no = $1 AND wid.company_code = $2
|
||||
AND wid.routing_version_id IS NOT NULL
|
||||
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
|
||||
ORDER BY wid.created_date ASC`,
|
||||
[wi.work_instruction_no, companyCode],
|
||||
);
|
||||
|
||||
const result = await generateWorkProcessesForInstruction(
|
||||
client,
|
||||
wi.id,
|
||||
wi.routing,
|
||||
wi.qty || null,
|
||||
companyCode,
|
||||
userId,
|
||||
const detailRows = detailResult.rows;
|
||||
|
||||
if (detailRows.length === 0 && wi.header_routing) {
|
||||
// detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목)
|
||||
// header에 routing/qty/item_id 자동 보정
|
||||
const firstDetail = await pool.query(
|
||||
`SELECT routing_version_id, qty, item_number
|
||||
FROM work_instruction_detail
|
||||
WHERE work_instruction_no = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[wi.work_instruction_no, companyCode],
|
||||
);
|
||||
const wid = firstDetail.rows[0];
|
||||
if (wid) {
|
||||
await pool.query(
|
||||
`UPDATE work_instruction SET
|
||||
routing = COALESCE(routing, $2),
|
||||
qty = COALESCE(NULLIF(qty, ''), $3),
|
||||
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $5
|
||||
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
|
||||
[wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await generateWorkProcessesForInstruction(
|
||||
client,
|
||||
wi.id,
|
||||
wi.header_routing,
|
||||
wi.header_qty || null,
|
||||
companyCode,
|
||||
userId,
|
||||
);
|
||||
if (!result) {
|
||||
await client.query("ROLLBACK");
|
||||
skipped++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
status: "skipped",
|
||||
});
|
||||
} else {
|
||||
await client.query("COMMIT");
|
||||
synced++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
status: "synced",
|
||||
process_count: result.processes.length,
|
||||
});
|
||||
logger.info("[pop/production] sync: 공정 생성 완료 (header routing)", {
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
process_count: result.processes.length,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
await client.query("ROLLBACK");
|
||||
skipped++;
|
||||
errors++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
status: "skipped",
|
||||
status: "error",
|
||||
error: err.message || "알 수 없는 오류",
|
||||
});
|
||||
continue;
|
||||
logger.error("[pop/production] sync: header routing 오류", {
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
error: err.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
synced++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
status: "synced",
|
||||
process_count: result.processes.length,
|
||||
});
|
||||
// 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number)
|
||||
// header routing/item_id도 첫 번째 detail 기준 보정
|
||||
if (detailRows.length > 0) {
|
||||
const first = detailRows[0];
|
||||
await pool.query(
|
||||
`UPDATE work_instruction SET
|
||||
routing = COALESCE(routing, $2),
|
||||
qty = COALESCE(NULLIF(qty, ''), $3),
|
||||
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $5
|
||||
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
|
||||
[wi.id, first.routing_version_id, first.qty, first.item_number, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("[pop/production] sync: 공정 생성 완료", {
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
process_count: result.processes.length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
await client.query("ROLLBACK");
|
||||
errors++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
status: "error",
|
||||
error: err.message || "알 수 없는 오류",
|
||||
});
|
||||
logger.error("[pop/production] sync: 개별 오류", {
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
error: err.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
for (const detail of detailRows) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const result = await generateWorkProcessesForInstruction(
|
||||
client,
|
||||
wi.id,
|
||||
detail.routing_version_id,
|
||||
detail.qty || null,
|
||||
companyCode,
|
||||
userId,
|
||||
detail.item_number, // batch_id = item_number
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
await client.query("ROLLBACK");
|
||||
skipped++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
item_number: detail.item_number,
|
||||
status: "skipped",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
synced++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
item_number: detail.item_number,
|
||||
status: "synced",
|
||||
process_count: result.processes.length,
|
||||
});
|
||||
|
||||
logger.info("[pop/production] sync: 다중품목 공정 생성 완료", {
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
item_number: detail.item_number,
|
||||
process_count: result.processes.length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
await client.query("ROLLBACK");
|
||||
errors++;
|
||||
details.push({
|
||||
work_instruction_id: wi.id,
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
item_number: detail.item_number,
|
||||
status: "error",
|
||||
error: err.message || "알 수 없는 오류",
|
||||
});
|
||||
logger.error("[pop/production] sync: 다중품목 개별 오류", {
|
||||
work_instruction_no: wi.work_instruction_no,
|
||||
item_number: detail.item_number,
|
||||
error: err.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
d.source_id,
|
||||
d.routing_version_id AS detail_routing_version_id,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
@@ -97,7 +98,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name, size FROM item_info
|
||||
SELECT item_name, size, type FROM item_info
|
||||
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||
) itm ON true
|
||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
|
||||
40
backend-node/src/routes/popInventoryRoutes.ts
Normal file
40
backend-node/src/routes/popInventoryRoutes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { adjustBatch, getAdjustHistory, getStockDetail, loadTempCart, clearTempCart, updateCartStatus, getLocations, locationLookup, getProcessStock, getProcessStockV2, getItemHistory, moveBatch } from "../controllers/popInventoryController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 재고 목록 + 품목상세 JOIN 조회
|
||||
router.get("/stock-detail", getStockDetail);
|
||||
|
||||
// 임시저장 불러오기/삭제/상태변경
|
||||
router.get("/temp-load", loadTempCart);
|
||||
router.delete("/temp-clear", clearTempCart);
|
||||
router.post("/temp-status", updateCartStatus);
|
||||
|
||||
// 재고 조정 일괄 확정
|
||||
router.post("/adjust-batch", adjustBatch);
|
||||
|
||||
// 재고 조정 이력 조회
|
||||
router.get("/adjust-history", getAdjustHistory);
|
||||
|
||||
// 창고별 위치 목록 조회
|
||||
router.get("/locations", getLocations);
|
||||
|
||||
// 위치코드로 창고+위치 조회 (QR 스캔)
|
||||
router.get("/location-lookup", locationLookup);
|
||||
|
||||
// 공정 진행 중 수량 조회
|
||||
router.get("/process-stock", getProcessStock);
|
||||
|
||||
// 공정별 대기수량/미입고 조회 (v2)
|
||||
router.get("/process-stock-v2", getProcessStockV2);
|
||||
|
||||
// 품목별 재고 이력 조회
|
||||
router.get("/item-history", getItemHistory);
|
||||
|
||||
// 재고 이동 일괄 실행
|
||||
router.post("/move-batch", moveBatch);
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user