merge: POP 재고관리 전면 구현 (feature→staging)

This commit is contained in:
SeongHyun Kim
2026-04-10 18:48:52 +09:00
17 changed files with 4181 additions and 706 deletions

View File

@@ -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);

File diff suppressed because it is too large Load Diff

View File

@@ -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();
}
}
}

View File

@@ -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

View 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;