From 837ca6e4f65663651d21de9329ca2956a91c67fe Mon Sep 17 00:00:00 2001 From: kmh Date: Thu, 21 May 2026 18:02:39 +0900 Subject: [PATCH] Add inventory transfer API and enhance POP backend controllers - Add inventoryTransferController/routes for POP inventory move (send/receive) - Add transactionPackagingService for transactional packaging operations - Enhance popProduction/popInventoryMove/popInventoryAdjust controllers - Enhance receiving/outbound/packaging/workInstruction controllers Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 2 + .../inventoryTransferController.ts | 1005 +++++++++++++++++ .../src/controllers/outboundController.ts | 255 ++++- .../src/controllers/packagingController.ts | 134 +++ .../popInventoryAdjustController.ts | 127 ++- .../controllers/popInventoryMoveController.ts | 256 +++++ .../controllers/popProductionController.ts | 803 +++++++++++-- .../src/controllers/receivingController.ts | 182 ++- .../controllers/workInstructionController.ts | 197 +++- .../src/routes/inventoryTransferRoutes.ts | 37 + backend-node/src/routes/outboundRoutes.ts | 3 + backend-node/src/routes/packagingRoutes.ts | 5 + backend-node/src/routes/popInventoryRoutes.ts | 8 + .../src/routes/popProductionRoutes.ts | 6 + backend-node/src/routes/receivingRoutes.ts | 3 + .../src/routes/workInstructionRoutes.ts | 3 + .../services/transactionPackagingService.ts | 372 ++++++ 17 files changed, 3264 insertions(+), 134 deletions(-) create mode 100644 backend-node/src/controllers/inventoryTransferController.ts create mode 100644 backend-node/src/routes/inventoryTransferRoutes.ts create mode 100644 backend-node/src/services/transactionPackagingService.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4b2c5d60..817eb1c7 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -137,6 +137,7 @@ import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import popInventoryRoutes from "./routes/popInventoryRoutes"; // POP 재고 조정/이동 +import inventoryTransferRoutes from "./routes/inventoryTransferRoutes"; // 재고이동 요청/승인 워크플로우 import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 @@ -311,6 +312,7 @@ 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/inventory-transfer", inventoryTransferRoutes); // 재고이동 요청/승인 워크플로우 app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); diff --git a/backend-node/src/controllers/inventoryTransferController.ts b/backend-node/src/controllers/inventoryTransferController.ts new file mode 100644 index 00000000..b2363272 --- /dev/null +++ b/backend-node/src/controllers/inventoryTransferController.ts @@ -0,0 +1,1005 @@ +/** + * inventoryTransferController — 2단계 재고이동 (출고측 보내기 → 입고측 확정/거절/부분입고) + * + * 사용처: + * - 출고: frontend/app/(main)/COMPANY_X/pop/outbound/inventory-move/ + * - 입고: frontend/app/(main)/COMPANY_X/pop/inbound/inventory-move/ + * + * 동작: + * - 출고측 "보내기" -> 마스터(inventory_transfer_request)+디테일(inventory_transfer_detail) INSERT + * + 출고측 inventory_stock 즉시 차감 + inventory_history '이동출고' 기록 (A안: 즉시 차감) + * - 입고측 처리 -> 디테일별 confirmed_qty 입력. 입고측 +confirmed, 잔량 출고측 + 복원, + * inventory_history '이동입고'/'이동잔량복원'/'이동거절복원' 기록 + * - 출고측 취소 -> 출고측 + 전량 복원, inventory_history '이동취소복원' + * + * inventory_history 추적: reference_type='inventory_transfer_detail', reference_id=detail.id, + * reference_number=request_no (popProductionController 패턴 재사용) + * + * 박스(transaction_packaging) 처리는 Phase 2. + */ + +import { Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const REF_TYPE = "inventory_transfer_detail"; + +interface RequestItem { + stock_id: string; // 출고측 inventory_stock.id + qty: number; // 보낼 수량 (requested_qty) + note?: string; // 라인 비고 (사용 안 함, 추후 확장) +} + +interface RequestBody { + from_warehouse: string; + to_warehouse: string; + items: RequestItem[]; + note?: string; +} + +interface ProcessItem { + detail_id: string; + confirmed_qty: number; // 0 이면 전체 거절 + reject_reason?: string; +} + +interface ProcessBody { + items: ProcessItem[]; +} + +// ============================================================ +// 헬퍼: 채번 ITR-YYYYMMDD-NNNN +// ============================================================ +async function generateRequestNo( + client: any, + companyCode: string, +): Promise { + const today = new Date(); + const yyyymmdd = today + .toISOString() + .slice(0, 10) + .replace(/-/g, ""); + const prefix = `ITR-${yyyymmdd}-`; + + const result = await client.query( + `SELECT request_no FROM inventory_transfer_request + WHERE company_code = $1 AND request_no LIKE $2 + ORDER BY request_no DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (result.rows.length > 0) { + const lastSeq = parseInt(result.rows[0].request_no.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + return `${prefix}${String(seq).padStart(4, "0")}`; +} + +// ============================================================ +// 헬퍼: 디테일 집계 -> 마스터 status 재산정 +// ============================================================ +function computeMasterStatus( + lines: Array<{ line_status: string }>, +): string { + if (lines.length === 0) return "PENDING"; + const statuses = new Set(lines.map((l) => l.line_status)); + if (statuses.size === 1) { + const s = lines[0].line_status; + if (s === "PENDING") return "PENDING"; + if (s === "CONFIRMED") return "CONFIRMED"; + if (s === "REJECTED") return "REJECTED"; + if (s === "PARTIAL_CONFIRMED") return "PARTIAL_CONFIRMED"; + } + return "PARTIAL_CONFIRMED"; +} + +// ============================================================ +// 헬퍼: inventory_history INSERT +// ============================================================ +async function insertHistory( + client: any, + params: { + companyCode: string; + stockId: string | null; + itemCode: string; + warehouseCode: string; + locationCode: string; + transactionType: string; + quantity: number; + balanceQty: number; + referenceId: string; + referenceNumber: string; + writer: string; + managerName: string; + }, +): Promise { + await client.query( + `INSERT INTO inventory_history ( + id, company_code, stock_id, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, + reference_type, reference_id, reference_number, + remark, writer, manager_id, manager_name, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, $4, $5, + $6, NOW(), $7, $8, + $9, $10, $11, + $6, $12, $12, $13, NOW() + )`, + [ + params.companyCode, + params.stockId, + params.itemCode, + params.warehouseCode, + params.locationCode || "", + params.transactionType, + String(params.quantity), + String(params.balanceQty), + REF_TYPE, + params.referenceId, + params.referenceNumber, + params.writer, + params.managerName, + ], + ); +} + +// ============================================================ +// POST /api/inventory-transfer/request +// 출고측 보내기 (마스터+디테일 INSERT, 출고측 즉시 차감) +// ============================================================ +export const createTransferRequest = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { from_warehouse, to_warehouse, items, note } = + req.body as RequestBody; + + if (!from_warehouse) { + return res + .status(400) + .json({ success: false, message: "출발 창고를 선택하세요" }); + } + if (!to_warehouse) { + return res + .status(400) + .json({ success: false, message: "도착 창고를 선택하세요" }); + } + if (from_warehouse === to_warehouse) { + return res.status(400).json({ + success: false, + message: "출발과 도착 창고가 동일합니다", + }); + } + if (!items || !Array.isArray(items) || items.length === 0) { + return res + .status(400) + .json({ success: false, message: "이동 항목이 없습니다" }); + } + + await client.query("BEGIN"); + + const requestNo = await generateRequestNo(client, companyCode); + + // 1) 마스터 INSERT + const reqInsert = await client.query( + `INSERT INTO inventory_transfer_request ( + company_code, request_no, from_warehouse, to_warehouse, + status, note, requested_by, writer, created_by + ) VALUES ($1, $2, $3, $4, 'PENDING', $5, $6, $6, $6) + RETURNING id`, + [companyCode, requestNo, from_warehouse, to_warehouse, note || null, userId], + ); + const requestId = reqInsert.rows[0].id; + + const lineResults: Array<{ + detail_id: string; + stock_id: string; + item_code: string; + qty: number; + }> = []; + + // 2) 디테일별 처리 + for (const item of items) { + // 2-1) 출발 stock 조회 + 락 + const stockRes = await client.query( + `SELECT id, item_code, warehouse_code, location_code, + COALESCE(CAST(NULLIF(current_qty,'') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE id = $1 AND company_code = $2 + FOR UPDATE`, + [item.stock_id, companyCode], + ); + + if (stockRes.rowCount === 0) { + throw new Error(`재고 row 없음: ${item.stock_id}`); + } + const stock = stockRes.rows[0]; + + if (stock.warehouse_code !== from_warehouse) { + throw new Error( + `재고 창고 불일치: ${stock.warehouse_code} != ${from_warehouse}`, + ); + } + + const currentQty = parseFloat(stock.current_qty) || 0; + const qty = Number(item.qty); + + if (!qty || qty <= 0) { + throw new Error(`수량이 잘못됨: ${item.stock_id}`); + } + if (qty > currentQty) { + throw new Error( + `재고 부족: ${stock.item_code} 보유 ${currentQty} < 요청 ${qty}`, + ); + } + + // 2-2) 디테일 INSERT + const detailInsert = await client.query( + `INSERT INTO inventory_transfer_detail ( + company_code, request_id, item_code, lot_no, + from_location, to_location, + requested_qty, confirmed_qty, rejected_qty, line_status, + writer, created_by + ) VALUES ($1, $2, $3, NULL, $4, NULL, $5, 0, 0, 'PENDING', $6, $6) + RETURNING id`, + [ + companyCode, + requestId, + stock.item_code, + stock.location_code || "", + String(qty), + userId, + ], + ); + const detailId = detailInsert.rows[0].id; + + // 2-3) 출고측 stock 차감 + const newBalance = currentQty - qty; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(newBalance), userId, stock.id, companyCode], + ); + + // 2-4) inventory_history '이동출고' (출고측 -qty) + await insertHistory(client, { + companyCode, + stockId: stock.id, + itemCode: stock.item_code, + warehouseCode: stock.warehouse_code, + locationCode: stock.location_code || "", + transactionType: "이동출고", + quantity: -qty, + balanceQty: newBalance, + referenceId: detailId, + referenceNumber: requestNo, + writer: userId, + managerName: userName, + }); + + lineResults.push({ + detail_id: detailId, + stock_id: stock.id, + item_code: stock.item_code, + qty, + }); + } + + await client.query("COMMIT"); + + logger.info("[inventory-transfer] request 생성", { + companyCode, + requestId, + requestNo, + lines: lineResults.length, + }); + + return res.json({ + success: true, + message: `재고이동 요청 ${lineResults.length}건 생성`, + data: { + request_id: requestId, + request_no: requestNo, + status: "PENDING", + lines: lineResults, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[inventory-transfer] request 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +// ============================================================ +// GET /api/inventory-transfer/pending +// 입고측 대기 목록 (to_warehouse, status IN PENDING/PARTIAL_CONFIRMED) +// Query: warehouse_code (선택, 미지정 시 전체 도착창고) +// ============================================================ +export const getPendingList = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code } = req.query; + // 마스터 status 와 무관하게 "디테일에 PENDING 라인이 남아있는 요청" 만 노출 + // (PARTIAL_CONFIRMED 라도 디테일 모두 처리된 케이스는 이력으로 이동) + const conditions = [ + "r.company_code = $1", + "r.status IN ('PENDING','PARTIAL_CONFIRMED')", + `EXISTS (SELECT 1 FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code + AND d.line_status = 'PENDING')`, + ]; + const params: any[] = [companyCode]; + let idx = 2; + + if (warehouse_code && warehouse_code !== "all") { + conditions.push(`r.to_warehouse = $${idx++}`); + params.push(warehouse_code); + } + + const result = await pool.query( + `SELECT + r.id AS request_id, + r.request_no, + r.from_warehouse, + r.to_warehouse, + COALESCE(wf.warehouse_name, r.from_warehouse) AS from_warehouse_name, + COALESCE(wt.warehouse_name, r.to_warehouse) AS to_warehouse_name, + r.status, + r.note, + r.requested_by, + r.requested_at, + (SELECT COUNT(*) FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code) AS line_count, + (SELECT COUNT(*) FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code + AND d.line_status = 'PENDING') AS pending_count + FROM inventory_transfer_request r + LEFT JOIN warehouse_info wf ON wf.warehouse_code = r.from_warehouse AND wf.company_code = r.company_code + LEFT JOIN warehouse_info wt ON wt.warehouse_code = r.to_warehouse AND wt.company_code = r.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY r.requested_at DESC + LIMIT 200`, + params, + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[inventory-transfer] pending 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// GET /api/inventory-transfer/received-history +// 입고측 처리 완료 이력 (to_warehouse, status IN CONFIRMED/REJECTED/CANCELLED) +// Query: warehouse_code (선택, 미지정 시 전체 도착창고) +// ============================================================ +export const getReceivedHistory = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code } = req.query; + // 처리 완료 이력: CONFIRMED/REJECTED/CANCELLED 전부 + PARTIAL_CONFIRMED 중 디테일 PENDING 라인이 0인 케이스 + const conditions = [ + "r.company_code = $1", + `(r.status IN ('CONFIRMED','REJECTED','CANCELLED') + OR (r.status = 'PARTIAL_CONFIRMED' AND NOT EXISTS ( + SELECT 1 FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code + AND d.line_status = 'PENDING')))`, + ]; + const params: any[] = [companyCode]; + let idx = 2; + + if (warehouse_code && warehouse_code !== "all") { + conditions.push(`r.to_warehouse = $${idx++}`); + params.push(warehouse_code); + } + + const result = await pool.query( + `SELECT + r.id AS request_id, + r.request_no, + r.from_warehouse, + r.to_warehouse, + COALESCE(wf.warehouse_name, r.from_warehouse) AS from_warehouse_name, + COALESCE(wt.warehouse_name, r.to_warehouse) AS to_warehouse_name, + r.status, + r.note, + r.requested_by, + r.requested_at, + r.confirmed_by, + r.confirmed_at, + (SELECT COUNT(*) FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code) AS line_count, + (SELECT COUNT(*) FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code + AND d.line_status = 'PENDING') AS pending_count + FROM inventory_transfer_request r + LEFT JOIN warehouse_info wf ON wf.warehouse_code = r.from_warehouse AND wf.company_code = r.company_code + LEFT JOIN warehouse_info wt ON wt.warehouse_code = r.to_warehouse AND wt.company_code = r.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY r.confirmed_at DESC NULLS LAST, r.requested_at DESC + LIMIT 200`, + params, + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[inventory-transfer] received-history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// GET /api/inventory-transfer/sent +// 출고측 보낸 목록 (from_warehouse, status IN PENDING/PARTIAL_CONFIRMED) +// ============================================================ +export const getSentList = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code } = req.query; + const conditions = [ + "r.company_code = $1", + "r.status IN ('PENDING','PARTIAL_CONFIRMED')", + ]; + const params: any[] = [companyCode]; + let idx = 2; + + if (warehouse_code && warehouse_code !== "all") { + conditions.push(`r.from_warehouse = $${idx++}`); + params.push(warehouse_code); + } + + const result = await pool.query( + `SELECT + r.id AS request_id, + r.request_no, + r.from_warehouse, + r.to_warehouse, + COALESCE(wf.warehouse_name, r.from_warehouse) AS from_warehouse_name, + COALESCE(wt.warehouse_name, r.to_warehouse) AS to_warehouse_name, + r.status, + r.note, + r.requested_by, + r.requested_at, + (SELECT COUNT(*) FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code) AS line_count, + (SELECT COUNT(*) FROM inventory_transfer_detail d + WHERE d.request_id = r.id AND d.company_code = r.company_code + AND d.line_status = 'PENDING') AS pending_count + FROM inventory_transfer_request r + LEFT JOIN warehouse_info wf ON wf.warehouse_code = r.from_warehouse AND wf.company_code = r.company_code + LEFT JOIN warehouse_info wt ON wt.warehouse_code = r.to_warehouse AND wt.company_code = r.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY r.requested_at DESC + LIMIT 200`, + params, + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[inventory-transfer] sent 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// GET /api/inventory-transfer/:requestId +// 단건 상세 (마스터 + 디테일) +// ============================================================ +export const getTransferDetail = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { requestId } = req.params; + + const masterRes = await pool.query( + `SELECT + r.id AS request_id, + r.request_no, + r.from_warehouse, + r.to_warehouse, + COALESCE(wf.warehouse_name, r.from_warehouse) AS from_warehouse_name, + COALESCE(wt.warehouse_name, r.to_warehouse) AS to_warehouse_name, + r.status, r.note, + r.requested_by, r.requested_at, + r.confirmed_by, r.confirmed_at + FROM inventory_transfer_request r + LEFT JOIN warehouse_info wf ON wf.warehouse_code = r.from_warehouse AND wf.company_code = r.company_code + LEFT JOIN warehouse_info wt ON wt.warehouse_code = r.to_warehouse AND wt.company_code = r.company_code + WHERE r.id = $1 AND r.company_code = $2`, + [requestId, companyCode], + ); + + if (masterRes.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "요청을 찾을 수 없습니다" }); + } + + const detailRes = await pool.query( + `SELECT + d.id AS detail_id, + d.item_code, + COALESCE(ii.item_name, d.item_code) AS item_name, + COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit, + d.lot_no, + d.from_location, d.to_location, + d.requested_qty, d.confirmed_qty, d.rejected_qty, + d.line_status, d.reject_reason + FROM inventory_transfer_detail d + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, unit, inventory_unit, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON d.item_code = ii.item_number AND d.company_code = ii.company_code + WHERE d.request_id = $1 AND d.company_code = $2 + ORDER BY d.created_date ASC`, + [requestId, companyCode], + ); + + return res.json({ + success: true, + data: { + master: masterRes.rows[0], + details: detailRes.rows, + }, + }); + } catch (error: any) { + logger.error("[inventory-transfer] detail 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// POST /api/inventory-transfer/:requestId/process +// 입고측 처리 (디테일별 confirmed_qty 입력) +// ============================================================ +export const processTransfer = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { requestId } = req.params; + const { items } = req.body as ProcessBody; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res + .status(400) + .json({ success: false, message: "처리 항목이 없습니다" }); + } + + await client.query("BEGIN"); + + // 1) 마스터 락 + status 검증 + const masterRes = await client.query( + `SELECT id, request_no, from_warehouse, to_warehouse, status + FROM inventory_transfer_request + WHERE id = $1 AND company_code = $2 + FOR UPDATE`, + [requestId, companyCode], + ); + + if (masterRes.rowCount === 0) { + throw new Error("요청을 찾을 수 없습니다"); + } + const master = masterRes.rows[0]; + if (!["PENDING", "PARTIAL_CONFIRMED"].includes(master.status)) { + throw new Error(`처리 불가 상태: ${master.status}`); + } + + // 2) 디테일별 처리 + for (const item of items) { + const detailRes = await client.query( + `SELECT id, item_code, lot_no, from_location, to_location, + COALESCE(CAST(NULLIF(requested_qty::text,'') AS numeric), 0) AS requested_qty, + line_status + FROM inventory_transfer_detail + WHERE id = $1 AND request_id = $2 AND company_code = $3 + FOR UPDATE`, + [item.detail_id, requestId, companyCode], + ); + if (detailRes.rowCount === 0) { + throw new Error(`디테일 row 없음: ${item.detail_id}`); + } + const detail = detailRes.rows[0]; + + if (detail.line_status !== "PENDING") { + throw new Error( + `라인 처리 불가 (${detail.item_code}): ${detail.line_status}`, + ); + } + + const requestedQty = parseFloat(detail.requested_qty) || 0; + const confirmedQty = Math.max(0, Number(item.confirmed_qty) || 0); + + if (confirmedQty > requestedQty) { + throw new Error( + `확정 수량 초과 (${detail.item_code}): ${confirmedQty} > ${requestedQty}`, + ); + } + + const rejectedQty = requestedQty - confirmedQty; + let lineStatus: string; + if (confirmedQty === 0) lineStatus = "REJECTED"; + else if (confirmedQty === requestedQty) lineStatus = "CONFIRMED"; + else lineStatus = "PARTIAL_CONFIRMED"; + + // 2-1) 입고측 stock UPSERT (+confirmedQty) + if (confirmedQty > 0) { + const toLocation = detail.to_location || ""; + const existDest = await client.query( + `SELECT id, COALESCE(CAST(NULLIF(current_qty,'') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code,'') = COALESCE($3,'') + AND COALESCE(location_code,'') = COALESCE($4,'') + FOR UPDATE`, + [companyCode, detail.item_code, master.to_warehouse, toLocation], + ); + + let destStockId: string; + let destBalance: number; + if (existDest.rowCount && existDest.rowCount > 0) { + destStockId = existDest.rows[0].id; + const prev = parseFloat(existDest.rows[0].current_qty) || 0; + destBalance = prev + confirmedQty; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(destBalance), userId, destStockId, companyCode], + ); + } else { + destBalance = confirmedQty; + const insRes = await client.query( + `INSERT INTO inventory_stock ( + id, company_code, item_code, warehouse_code, location_code, + current_qty, created_date, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6) + RETURNING id`, + [ + companyCode, + detail.item_code, + master.to_warehouse, + toLocation, + String(confirmedQty), + userId, + ], + ); + destStockId = insRes.rows[0].id; + } + + await insertHistory(client, { + companyCode, + stockId: destStockId, + itemCode: detail.item_code, + warehouseCode: master.to_warehouse, + locationCode: toLocation, + transactionType: "이동입고", + quantity: confirmedQty, + balanceQty: destBalance, + referenceId: detail.id, + referenceNumber: master.request_no, + writer: userId, + managerName: userName, + }); + } + + // 2-2) 출고측 stock 복원 (잔량 or 전체 거절) + if (rejectedQty > 0) { + const fromLocation = detail.from_location || ""; + const existSrc = await client.query( + `SELECT id, COALESCE(CAST(NULLIF(current_qty,'') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code,'') = COALESCE($3,'') + AND COALESCE(location_code,'') = COALESCE($4,'') + FOR UPDATE`, + [companyCode, detail.item_code, master.from_warehouse, fromLocation], + ); + + let srcStockId: string; + let srcBalance: number; + if (existSrc.rowCount && existSrc.rowCount > 0) { + srcStockId = existSrc.rows[0].id; + const prev = parseFloat(existSrc.rows[0].current_qty) || 0; + srcBalance = prev + rejectedQty; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(srcBalance), userId, srcStockId, companyCode], + ); + } else { + srcBalance = rejectedQty; + const insRes = await client.query( + `INSERT INTO inventory_stock ( + id, company_code, item_code, warehouse_code, location_code, + current_qty, created_date, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6) + RETURNING id`, + [ + companyCode, + detail.item_code, + master.from_warehouse, + fromLocation, + String(rejectedQty), + userId, + ], + ); + srcStockId = insRes.rows[0].id; + } + + const txType = confirmedQty === 0 ? "이동거절복원" : "이동잔량복원"; + await insertHistory(client, { + companyCode, + stockId: srcStockId, + itemCode: detail.item_code, + warehouseCode: master.from_warehouse, + locationCode: fromLocation, + transactionType: txType, + quantity: rejectedQty, + balanceQty: srcBalance, + referenceId: detail.id, + referenceNumber: master.request_no, + writer: userId, + managerName: userName, + }); + } + + // 2-3) 디테일 UPDATE + await client.query( + `UPDATE inventory_transfer_detail + SET confirmed_qty = $1, rejected_qty = $2, + line_status = $3, reject_reason = $4, + updated_date = NOW(), writer = $5 + WHERE id = $6 AND company_code = $7`, + [ + String(confirmedQty), + String(rejectedQty), + lineStatus, + item.reject_reason || null, + userId, + detail.id, + companyCode, + ], + ); + } + + // 3) 마스터 status 재산정 + const allDetailsRes = await client.query( + `SELECT line_status FROM inventory_transfer_detail + WHERE request_id = $1 AND company_code = $2`, + [requestId, companyCode], + ); + const newStatus = computeMasterStatus(allDetailsRes.rows); + + await client.query( + `UPDATE inventory_transfer_request + SET status = $1, confirmed_by = $2, confirmed_at = NOW(), + updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [newStatus, userId, requestId, companyCode], + ); + + await client.query("COMMIT"); + + logger.info("[inventory-transfer] process 완료", { + companyCode, + requestId, + newStatus, + }); + + return res.json({ + success: true, + message: "처리 완료", + data: { request_id: requestId, status: newStatus }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[inventory-transfer] process 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +// ============================================================ +// POST /api/inventory-transfer/:requestId/cancel +// 출고측 취소 (PENDING 상태만, 출고측 전량 복원) +// ============================================================ +export const cancelTransfer = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res + .status(401) + .json({ success: false, message: "인증 정보 없음" }); + } + + const { requestId } = req.params; + + await client.query("BEGIN"); + + const masterRes = await client.query( + `SELECT id, request_no, from_warehouse, status, requested_by + FROM inventory_transfer_request + WHERE id = $1 AND company_code = $2 + FOR UPDATE`, + [requestId, companyCode], + ); + if (masterRes.rowCount === 0) { + throw new Error("요청을 찾을 수 없습니다"); + } + const master = masterRes.rows[0]; + if (master.status !== "PENDING") { + throw new Error( + `취소 불가 상태: ${master.status} (PENDING 만 가능)`, + ); + } + + // 디테일별 출고측 복원 + const detailRes = await client.query( + `SELECT id, item_code, from_location, + COALESCE(CAST(NULLIF(requested_qty::text,'') AS numeric), 0) AS requested_qty + FROM inventory_transfer_detail + WHERE request_id = $1 AND company_code = $2`, + [requestId, companyCode], + ); + + for (const d of detailRes.rows) { + const qty = parseFloat(d.requested_qty) || 0; + if (qty <= 0) continue; + const fromLocation = d.from_location || ""; + + const existSrc = await client.query( + `SELECT id, COALESCE(CAST(NULLIF(current_qty,'') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code,'') = COALESCE($3,'') + AND COALESCE(location_code,'') = COALESCE($4,'') + FOR UPDATE`, + [companyCode, d.item_code, master.from_warehouse, fromLocation], + ); + + let stockId: string; + let balance: number; + if (existSrc.rowCount && existSrc.rowCount > 0) { + stockId = existSrc.rows[0].id; + balance = (parseFloat(existSrc.rows[0].current_qty) || 0) + qty; + await client.query( + `UPDATE inventory_stock + SET current_qty = $1, updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(balance), userId, stockId, companyCode], + ); + } else { + balance = qty; + const ins = await client.query( + `INSERT INTO inventory_stock ( + id, company_code, item_code, warehouse_code, location_code, + current_qty, created_date, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6) + RETURNING id`, + [ + companyCode, + d.item_code, + master.from_warehouse, + fromLocation, + String(qty), + userId, + ], + ); + stockId = ins.rows[0].id; + } + + await insertHistory(client, { + companyCode, + stockId, + itemCode: d.item_code, + warehouseCode: master.from_warehouse, + locationCode: fromLocation, + transactionType: "이동취소복원", + quantity: qty, + balanceQty: balance, + referenceId: d.id, + referenceNumber: master.request_no, + writer: userId, + managerName: userName, + }); + } + + // 마스터 status = CANCELLED + await client.query( + `UPDATE inventory_transfer_request + SET status = 'CANCELLED', updated_date = NOW(), writer = $1 + WHERE id = $2 AND company_code = $3`, + [userId, requestId, companyCode], + ); + + await client.query("COMMIT"); + + logger.info("[inventory-transfer] cancel 완료", { + companyCode, + requestId, + }); + + return res.json({ + success: true, + message: "요청이 취소되었습니다", + data: { request_id: requestId, status: "CANCELLED" }, + }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[inventory-transfer] cancel 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 8ebb0945..76c8de74 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -13,6 +13,12 @@ import type { AuthenticatedRequest } from "../types/auth"; import { resolveCategoryCode } from "../utils/categoryUtils"; import { adjustInventory } from "../utils/inventoryUtils"; import { logger } from "../utils/logger"; +import { + ensureLoadingInstance, + insertPackagingRows, + processBoxOutbound, + type PackageEntryInput, +} from "../services/transactionPackagingService"; // 출고 목록 조회 export async function getList(req: AuthenticatedRequest, res: Response) { @@ -144,7 +150,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { outbound_qty, unit_price, total_amount, lot_number, warehouse_code, location_code, outbound_status, manager_id, memo, - source_type, sales_order_id, shipment_plan_id, item_info_id, + source_table, source_id, sales_order_id, shipment_plan_id, item_info_id, destination_code, delivery_destination, delivery_address, created_date, created_by, writer, status ) VALUES ( @@ -154,9 +160,9 @@ export async function create(req: AuthenticatedRequest, res: Response) { $13, $14, $15, $16, $17, $18, $19, $20, $21, - $22, $23, $24, $25, - $26, $27, $28, - NOW(), $29, $29, '출고' + $22, $23, $24, $25, $26, + $27, $28, $29, + NOW(), $30, $30, '출고' ) RETURNING *`, [ companyCode, @@ -181,6 +187,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { manager_id || item.manager_id || null, memo || item.memo || null, item.source_type || item.source_table || null, + item.source_id || null, item.sales_order_id || null, item.shipment_plan_id || null, item.item_info_id || null, @@ -193,6 +200,54 @@ export async function create(req: AuthenticatedRequest, res: Response) { insertedRows.push(result.rows[0]); + // transaction_loading + transaction_packaging + // - loading_code 가 있으면 적재함 인스턴스 ensure (같은 row + 코드는 reuse) + // - packages 가 있으면 박스 단위로 펼쳐 N row INSERT (라벨 자동 발번) + try { + const detailId = result.rows[0].id as string; + const loadingId = await ensureLoadingInstance(client, { + companyCode, + sourceType: "outbound", + sourceDocId: detailId, + loadingCode: item.loading_code || null, + loadingName: item.loading_name || null, + loadingSeq: item.loading_seq ?? 1, + writer: userId, + }); + + const rawPkgs: any[] = Array.isArray(item.packages) ? item.packages : []; + const packagesInput: PackageEntryInput[] = rawPkgs + .map((p) => ({ + pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""), + count: Number(p?.count ?? 0), + qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0), + })) + .filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0); + + if (packagesInput.length > 0) { + const labels = await insertPackagingRows(client, { + companyCode, + sourceType: "outbound_detail", + sourceId: detailId, + inboundNumber: outbound_number || item.outbound_number, + packages: packagesInput, + loadingId, + warehouseCode: warehouse_code || item.warehouse_code || null, + locationCode: location_code || item.location_code || null, + lotNo: item.lot_number || null, + writer: userId, + }); + insertedRows[insertedRows.length - 1].package_labels = labels; + } + } catch (pkgErr: any) { + logger.error("transaction_packaging/loading 저장 실패", { + error: pkgErr.message, + outbound_number, + item_number: item.item_code || item.item_number, + }); + throw pkgErr; + } + // 재고 업데이트 (inventory_stock): 출고 수량 차감 const itemCode = item.item_code || item.item_number || null; const whCode = warehouse_code || item.warehouse_code || null; @@ -255,6 +310,42 @@ export async function create(req: AuthenticatedRequest, res: Response) { userId, ], ); + + // Phase 2-b: transaction_packaging 박스 단위 출고 처리 + // FIFO 매칭으로 박스 전체 출고(status=SHIPPED) + 마지막 박스 부분 차감(quantity UPDATE) + // 박스 row 가 0건이면 (Phase 1 도입 전 입고 데이터) 자동 스킵 → 기존 흐름 유지 + try { + const boxResult = await processBoxOutbound(client, { + companyCode, + itemCode, + warehouseCode: stockRow.warehouse_code ?? whCode, + locationCode: stockRow.location_code ?? locCode, + qty: outQty, + writer: userId, + }); + if ( + boxResult.shippedLabels.length > 0 || + boxResult.partialLabel || + boxResult.untrackedQty !== outQty + ) { + logger.info("출고 박스 처리", { + companyCode, + item_code: itemCode, + requested_qty: outQty, + shipped_count: boxResult.shippedLabels.length, + partial_label: boxResult.partialLabel, + partial_before: boxResult.partialBefore, + partial_after: boxResult.partialAfter, + untracked_qty: boxResult.untrackedQty, + }); + } + } catch (pkgErr: any) { + logger.error("출고 박스 처리 실패", { + error: pkgErr.message, + item_code: itemCode, + }); + throw pkgErr; + } } // 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 + master status 자동 전환 @@ -615,6 +706,7 @@ export async function getShipmentInstructions( COALESCE(sid.order_qty, 0) AS order_qty, GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty, sid.source_type, + NULLIF(ii.unit, '') AS unit, -- 단가 fallback chain: 수주 원본 → 거래처-품목 매핑 단가 → 품목 표준가 → 0 COALESCE( CAST(NULLIF(sod.unit_price, '') AS numeric), @@ -625,7 +717,7 @@ export async function getShipmentInstructions( CASE WHEN EXISTS ( SELECT 1 FROM item_inspection_info iii WHERE iii.company_code = si.company_code - AND COALESCE(iii.is_active, 'Y') = 'Y' + AND iii.is_active = '사용' AND iii.inspection_type = '출하검사' AND iii.item_code = sid.item_code ) THEN 'self' ELSE NULL END AS inspection_type @@ -855,3 +947,156 @@ export async function getLocations(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } + +// 생산출고용: 마지막 공정까지 진행된 작업지시 실적 조회 +// - work_order_process 의 max(seq_no) = 마지막 공정 +// - 마지막 공정의 work_order_process_result 가 등록되어 있고 (good_qty + concession_qty) > 0 +// - 출고 잔량 = (good_qty + concession_qty) - 이미 출고된 outbound_mng.outbound_qty 합계 +// - processCode 옵션: 해당 WO 의 마지막 공정이 그 공정인 것만 필터 +// - keyword 옵션: work_instruction_no / item_number / item_name ILIKE +export async function getProductionResults( + req: AuthenticatedRequest, + res: Response, +) { + try { + const companyCode = req.user!.companyCode; + const { processCode, keyword, pageSize } = req.query; + + const limit = Math.min(500, Math.max(1, Number(pageSize) || 100)); + const params: any[] = [companyCode]; + let paramIdx = 2; + + let processCondition = ""; + if (processCode) { + processCondition = `AND wop.process_code = $${paramIdx}`; + params.push(processCode); + paramIdx++; + } + + let keywordCondition = ""; + if (keyword) { + keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`; + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + + const dataResult = await pool.query( + `SELECT + wr.id, + wop.id AS wop_id, + wop.wo_id, + wi.work_instruction_no, + wi.start_date AS order_date, + COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) AS instruction_qty, + wop.process_code, + wop.process_name, + wop.seq_no, + COALESCE(ii.item_number, wi.item_id) AS item_code, + COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name, + COALESCE(ii.size, '') AS spec, + COALESCE(ii.material, '') AS material, + NULLIF(ii.unit, '') AS unit, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty, + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS order_qty, + COALESCE(ship.shipped_qty, 0) AS shipped_qty, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + - COALESCE(ship.shipped_qty, 0) AS remain_qty, + 'work_order_process_result' AS source_table, + wr.result_status, + COALESCE(ii.image, NULL) AS image, + CASE WHEN EXISTS ( + SELECT 1 FROM item_inspection_info iii + WHERE iii.company_code = wop.company_code + AND COALESCE(iii.is_active, 'Y') IN ('Y', '사용') + AND iii.item_code = COALESCE(ii.item_number, wi.item_id) + ) THEN 'self' ELSE NULL END AS inspection_type, + tp_agg.packages, + tl.loading_code, + tl.loading_name + FROM work_order_process_result wr + JOIN work_order_process wop + ON wop.id = wr.wop_id + AND wop.company_code = wr.company_code + JOIN work_instruction wi + ON wi.id = wop.wo_id + AND wi.company_code = wop.company_code + LEFT JOIN ( + SELECT DISTINCT ON (id, company_code) + id, item_number, item_name, size, material, unit, image, company_code + FROM item_info + ORDER BY id, company_code, created_date DESC + ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + LEFT JOIN ( + SELECT source_id, company_code, + SUM(COALESCE(CAST(NULLIF(outbound_qty::text, '') AS numeric), 0)) AS shipped_qty + FROM outbound_mng + WHERE source_table = 'work_order_process_result' + AND company_code = $1 + AND source_id IS NOT NULL + GROUP BY source_id, company_code + ) ship ON ship.source_id = wr.id AND ship.company_code = wr.company_code + LEFT JOIN ( + SELECT packed.source_id, packed.company_code, + JSON_AGG(JSON_BUILD_OBJECT( + 'pkg_code', packed.pkg_code, + 'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code), + 'count', packed.cnt, + 'qty_per_unit', packed.qty_per_unit + )) AS packages + FROM ( + SELECT source_id, company_code, pkg_code, + CAST(quantity AS numeric) AS qty_per_unit, + COUNT(*)::int AS cnt + FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + GROUP BY source_id, company_code, pkg_code, quantity + ) packed + LEFT JOIN pkg_unit pu + ON pu.pkg_code = packed.pkg_code + AND pu.company_code = packed.company_code + GROUP BY packed.source_id, packed.company_code + ) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code + LEFT JOIN ( + SELECT DISTINCT ON (source_doc_id, company_code) + source_doc_id, company_code, + loading_code, loading_name + FROM transaction_loading + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC + ) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code + WHERE wr.company_code = $1 + AND CAST(wop.seq_no AS int) = ( + SELECT MAX(CAST(wop2.seq_no AS int)) + FROM work_order_process wop2 + WHERE wop2.wo_id = wop.wo_id + AND wop2.company_code = wop.company_code + ) + AND ( + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + ) > 0 + AND ( + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + - COALESCE(ship.shipped_qty, 0) + ) > 0 + ${processCondition} + ${keywordCondition} + ORDER BY wi.work_instruction_no, wr.created_date NULLS LAST + LIMIT ${limit}`, + params, + ); + + return res.json({ success: true, data: dataResult.rows }); + } catch (error: any) { + logger.error("생산출고 소스 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index 8a98c256..f3609a6c 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -611,6 +611,140 @@ export async function getItemsByDivision( } } +// ────────────────────────────────────────────── +// 거래 단위 포장재·적재함 추적 (transaction_loading / transaction_packaging) +// ────────────────────────────────────────────── + +// 적재함 인스턴스 조회 (작업 헤더 기준) +// GET /packaging/transaction-loadings?source_type=inbound&source_doc_id=xxx +export async function getTransactionLoadings( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { source_type, source_doc_id } = req.query as { + source_type?: string; + source_doc_id?: string; + }; + const pool = getPool(); + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } + if (source_type) { + conditions.push(`source_type = $${idx++}`); + params.push(source_type); + } + if (source_doc_id) { + conditions.push(`source_doc_id = $${idx++}`); + params.push(source_doc_id); + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT * FROM transaction_loading ${where} + ORDER BY source_doc_id, loading_code, COALESCE(loading_seq, 1)`, + params + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("적재함 인스턴스 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// 박스(라벨) 조회 +// GET /packaging/transaction-packagings?source_type=inbound_detail&source_id=xxx +// GET /packaging/transaction-packagings?inbound_number=INB-2026-001 +// GET /packaging/transaction-packagings?loading_id=xxx +// GET /packaging/transaction-packagings?status=STORED +export async function getTransactionPackagings( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { + source_type, + source_id, + inbound_number, + loading_id, + status, + pkg_code, + keyword, + } = req.query as Record; + const pool = getPool(); + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`tp.company_code = $${idx++}`); + params.push(companyCode); + } + if (source_type) { + conditions.push(`tp.source_type = $${idx++}`); + params.push(source_type); + } + if (source_id) { + conditions.push(`tp.source_id = $${idx++}`); + params.push(source_id); + } + if (inbound_number) { + conditions.push(`tp.package_label LIKE $${idx++}`); + params.push(`${inbound_number}-P%`); + } + if (loading_id) { + conditions.push(`tp.loading_id = $${idx++}`); + params.push(loading_id); + } + if (status) { + conditions.push(`tp.status = $${idx++}`); + params.push(status); + } + if (pkg_code) { + conditions.push(`tp.pkg_code = $${idx++}`); + params.push(pkg_code); + } + if (keyword) { + conditions.push(`tp.package_label ILIKE $${idx++}`); + params.push(`%${keyword}%`); + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT + tp.*, + tl.loading_code AS tl_loading_code, + tl.loading_seq AS tl_loading_seq, + tl.loading_name AS tl_loading_name, + pu.pkg_name AS pkg_name + FROM transaction_packaging tp + LEFT JOIN transaction_loading tl ON tl.id = tp.loading_id + LEFT JOIN pkg_unit pu + ON pu.pkg_code = tp.pkg_code + AND pu.company_code = tp.company_code + ${where} + ORDER BY tp.package_label + LIMIT 1000`, + params + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("박스(라벨) 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + // 일반 품목 조회 (포장재/적재함 제외, 매칭용) export async function getGeneralItems( req: AuthenticatedRequest, diff --git a/backend-node/src/controllers/popInventoryAdjustController.ts b/backend-node/src/controllers/popInventoryAdjustController.ts index 2744a299..8bbb95d6 100644 --- a/backend-node/src/controllers/popInventoryAdjustController.ts +++ b/backend-node/src/controllers/popInventoryAdjustController.ts @@ -6,7 +6,8 @@ * 동작: * - inventory_history.reason 컬럼 직접 저장 * - remark = 메모 평문 (미입력 시 NULL) - * - 이상없음(confirm) 케이스는 받지 않음 (UI only, DB 안 찍음) + * - 이상없음(confirm) 케이스는 quantity='0', balance_qty=systemQty, reason='이상없음' 으로 INSERT + * (inventory_stock UPDATE 없음, 실재고 변동 0) */ import { Response } from "express"; @@ -20,6 +21,9 @@ interface CommitItem { actual_qty: number; reason: string; memo?: string; + // 'confirm' = 이상없음(수량 변화 없음, 사유 '이상없음' 으로 INSERT) + // 'adjust' = 일반 조정 (default) + type?: "confirm" | "adjust"; // 위치불일치 케이스만 사용 new_warehouse?: string; new_location?: string; @@ -58,6 +62,19 @@ export const getAdjustStockList = async (req: any, res: Response) => { ist.item_code ILIKE $${idx} OR COALESCE(ii.item_name, '') ILIKE $${idx} OR COALESCE(ii.item_number, '') ILIKE $${idx} + OR EXISTS ( + SELECT 1 FROM transaction_packaging tp + WHERE tp.company_code = ist.company_code + AND COALESCE(tp.warehouse_code, '') = COALESCE(ist.warehouse_code, '') + AND COALESCE(tp.location_code, '') = COALESCE(ist.location_code, '') + AND tp.lot_no ILIKE $${idx} + ) + OR EXISTS ( + SELECT 1 FROM inbound_detail idd + WHERE idd.company_code = ist.company_code + AND idd.item_number = ist.item_code + AND idd.lot_number ILIKE $${idx} + ) )`); params.push(`%${keyword}%`); idx++; @@ -100,11 +117,12 @@ export const getAdjustStockList = async (req: any, res: Response) => { /** * POST /api/pop/inventory/adjust/commit - * 재고조정 일괄 확정 — 조정 건만 처리 (이상없음은 클라이언트가 보내지 않음) + * 재고조정 일괄 확정 — 조정/이상없음 모두 처리 * * Body: { items: CommitItem[] } * * 처리: + * - 이상없음(type='confirm'): inventory_history INSERT 1건만 (quantity='0', balance_qty=systemQty, reason='이상없음'). stock UPDATE 없음 * - 일반 조정: inventory_stock.current_qty UPDATE + inventory_history INSERT * - 위치불일치: 출발 위치 차감 + 새 위치 UPSERT + inventory_history 2건 INSERT (출발 -, 도착 +) * - 모든 INSERT 의 transaction_type='조정', reason 컬럼 직접 저장, remark=memo 평문 또는 NULL @@ -154,6 +172,24 @@ export const commitAdjust = async (req: any, res: Response) => { const diff = actualQty - systemQty; const memo = item.memo?.trim() || null; + // 이상없음(confirm): 수량 변화 없이 inventory_history 에 흔적만 남김 + // - inventory_stock UPDATE 없음 (실재고 변동 0) + // - transaction_type='조정', quantity='0', balance_qty=systemQty(=확인 시점 실재고) + // - reason='이상없음' (옵션설정 카테고리에 등록되어 있으면 라벨 매핑됨) + if (item.type === "confirm") { + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, reason, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, + '조정', NOW(), '0', $5, '이상없음', NULL, $6, $7, NOW())`, + [companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "", + String(systemQty), userId, userName] + ); + adjustCount++; + results.push({ stock_id: item.stock_id, status: "ok" }); + continue; + } + if (item.reason === "위치불일치" && item.new_warehouse) { // 위치불일치: 출발 차감 + 도착 UPSERT + 이력 2건 const newLoc = item.new_location || item.new_warehouse; @@ -353,3 +389,90 @@ export const saveTempAdjust = async (req: any, res: Response) => { client.release(); } }; + +/** + * GET /api/pop/inventory/adjust/history + * 재고조정 이력 조회 (단일 날짜) + * + * Query: date (YYYY-MM-DD, 필수), reason (선택, 사유 valueCode), keyword (선택, 품목명/코드) + * 대상: inventory_history WHERE transaction_type='조정' AND DATE(transaction_date)=$date + */ +export const getAdjustHistory = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { date, reason, keyword } = req.query as { + date?: string; + reason?: string; + keyword?: string; + }; + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return res.status(400).json({ success: false, message: "date 파라미터(YYYY-MM-DD)가 필요합니다" }); + } + + const conditions = [ + "ih.company_code = $1", + "ih.transaction_type = '조정'", + "DATE(ih.transaction_date) = $2", + ]; + const params: any[] = [companyCode, date]; + let idx = 3; + + if (reason && reason !== "all") { + conditions.push(`ih.reason = $${idx++}`); + params.push(reason); + } + + if (keyword) { + conditions.push(`( + ih.item_code ILIKE $${idx} + OR COALESCE(ii.item_name, '') ILIKE $${idx} + )`); + params.push(`%${keyword}%`); + idx++; + } + + const result = await pool.query( + `SELECT + ih.id, + ih.item_code, + COALESCE(ii.item_name, ih.item_code) AS item_name, + COALESCE(ii.item_number, ih.item_code) AS item_number, + COALESCE(ii.size, '') AS spec, + COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit, + ih.warehouse_code, + COALESCE(wi.warehouse_name, ih.warehouse_code) AS warehouse_name, + COALESCE(ih.location_code, '') AS location_code, + COALESCE(ih.quantity, '0') AS quantity, + COALESCE(ih.balance_qty, '0') AS balance_qty, + COALESCE(ih.reason, '') AS reason, + COALESCE(ih.remark, '') AS remark, + COALESCE(ih.manager_name, ih.writer, '') AS manager_name, + ih.transaction_date + FROM inventory_history ih + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, size, unit, inventory_unit, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON ih.item_code = ii.item_number AND ih.company_code = ii.company_code + LEFT JOIN warehouse_info wi ON ih.warehouse_code = wi.warehouse_code + AND ih.company_code = wi.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY ih.transaction_date DESC + LIMIT 500`, + params + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory/adjust] history 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend-node/src/controllers/popInventoryMoveController.ts b/backend-node/src/controllers/popInventoryMoveController.ts index b017f39c..6b3a73bf 100644 --- a/backend-node/src/controllers/popInventoryMoveController.ts +++ b/backend-node/src/controllers/popInventoryMoveController.ts @@ -14,12 +14,17 @@ import { Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { insertSpilloverBox } from "../services/transactionPackagingService"; const CART_TYPE = "inventory-move"; interface CommitItem { stock_id: string; qty: number; + // Phase 2: 박스 매칭 잔여 처리 모드 + // 'none' — 박스 없이 이동 (inventory_stock 만 차감/도착, 잔여 박스 INSERT 안 함) + // 'new-box' — 잔여를 새 박스에 담아 이동 (직전 박스 정보 자동 복사) + spillover_mode?: "none" | "new-box"; } interface CommitBody { @@ -60,6 +65,19 @@ export const getMoveStockList = async (req: any, res: Response) => { ist.item_code ILIKE $${idx} OR COALESCE(ii.item_name, '') ILIKE $${idx} OR COALESCE(ii.item_number, '') ILIKE $${idx} + OR EXISTS ( + SELECT 1 FROM transaction_packaging tp + WHERE tp.company_code = ist.company_code + AND COALESCE(tp.warehouse_code, '') = COALESCE(ist.warehouse_code, '') + AND COALESCE(tp.location_code, '') = COALESCE(ist.location_code, '') + AND tp.lot_no ILIKE $${idx} + ) + OR EXISTS ( + SELECT 1 FROM inbound_detail idd + WHERE idd.company_code = ist.company_code + AND idd.item_number = ist.item_code + AND idd.lot_number ILIKE $${idx} + ) )`); params.push(`%${keyword}%`); idx++; @@ -100,6 +118,160 @@ export const getMoveStockList = async (req: any, res: Response) => { } }; +/** + * GET /api/pop/inventory/move/process-list + * 재공품 보유 공정 distinct 목록 (출발 = 공정 탭 칩용) + * + * 필터 (생산입고 패턴): + * - good_qty > 0 (실적 등록) + * - target_warehouse_id IS NULL (미입고) + * - parent_process_id IS NULL (마스터만) + * - is_rework 제외 + */ +export const getMoveProcessList = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const result = await pool.query( + `SELECT DISTINCT wop.process_code, wop.process_name + FROM work_order_process wop + WHERE wop.company_code = $1 + AND wop.parent_process_id IS NULL + AND wop.target_warehouse_id IS NULL + AND EXISTS ( + SELECT 1 FROM work_order_process_result wr + WHERE wr.wop_id = wop.id + AND wr.company_code = wop.company_code + AND COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) > 0 + AND COALESCE(wr.is_rework, '') NOT IN ('Y', 'true', '1') + ) + ORDER BY wop.process_name`, + [companyCode] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory/move] process-list 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/pop/inventory/move/process-stock-list + * 공정 탭 카드 — 재공품 row (생산입고 후보와 동일 SQL 패턴) + * + * Query: process_code (선택, 'all' 또는 미지정 시 전체), keyword (선택) + * + * SQL 패턴: receivingController.getProductionResults 와 동일하되 + * - process_code 옵셔널 (전체일 땐 모든 공정 합산) + * - 잔여 가능 수량 > 0 (good_qty + concession_qty - received_qty > 0) + */ +export const getMoveProcessStockList = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { process_code, keyword } = req.query; + const params: any[] = [companyCode]; + let idx = 2; + + let processCondition = ""; + if (process_code && process_code !== "all") { + processCondition = `AND wop.process_code = $${idx++}`; + params.push(process_code); + } + + let keywordCondition = ""; + if (keyword) { + keywordCondition = `AND ( + wi.work_instruction_no ILIKE $${idx} + OR COALESCE(ii.item_name, '') ILIKE $${idx} + OR COALESCE(ii.item_number, '') ILIKE $${idx} + )`; + params.push(`%${keyword}%`); + idx++; + } + + const result = await pool.query( + `SELECT + wr.id, + wop.id AS wop_id, + wop.wo_id, + wi.work_instruction_no, + wop.process_code, + wop.process_name, + wop.seq_no, + COALESCE(ii.item_number, wi.item_id) AS item_code, + COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name, + COALESCE(ii.item_number, wi.item_id) AS item_number, + COALESCE(ii.size, '') AS spec, + COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty, + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty, + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + - COALESCE(rcv.received_qty, 0) AS remain_qty, + 'work_order_process_result' AS source_table + FROM work_order_process_result wr + JOIN work_order_process wop + ON wop.id = wr.wop_id + AND wop.company_code = wr.company_code + JOIN work_instruction wi + ON wi.id = wop.wo_id + AND wi.company_code = wop.company_code + LEFT JOIN ( + SELECT DISTINCT ON (id, company_code) + id, item_number, item_name, size, unit, inventory_unit, company_code + FROM item_info + ORDER BY id, company_code, created_date DESC + ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code + LEFT JOIN ( + SELECT im.source_id, + SUM(COALESCE(CAST(NULLIF(id.inbound_qty::text, '') AS numeric), 0)) AS received_qty + FROM inbound_detail id + JOIN inbound_mng im + ON id.inbound_id = im.inbound_number + AND id.company_code = im.company_code + WHERE im.source_table = 'work_order_process_result' + AND im.company_code = $1 + GROUP BY im.source_id + ) rcv ON rcv.source_id = wr.id + WHERE wr.company_code = $1 + AND COALESCE(wr.is_rework, '') NOT IN ('Y', 'true', '1') + AND ( + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + ) > 0 + AND ( + COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) + + COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) + - COALESCE(rcv.received_qty, 0) + ) > 0 + ${processCondition} + ${keywordCondition} + ORDER BY wop.process_name, wi.work_instruction_no, wr.created_date NULLS LAST + LIMIT 500`, + params + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory/move] process-stock-list 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + /** * POST /api/pop/inventory/move/commit * 재고이동 일괄 확정 @@ -267,6 +439,90 @@ export const commitMove = async (req: any, res: Response) => { String(qty), String(destBalance), userId, userName] ); + // 7. transaction_packaging 박스 단위 이동 (Phase 2) + // FIFO 매칭: 출발 위치의 STORED 박스 중 누적 합산이 qty 이하가 되는 박스 N개를 + // 새 위치로 UPDATE. 잔여 = qty - 박스합산 만큼은 spillover_mode 에 따라 처리. + // 박스 row 가 0건이면 (Phase 1 도입 전 입고 데이터) 전체 스킵 → 기존 흐름 유지. + try { + const boxRows = await client.query( + `SELECT id, package_label, pkg_code, quantity, loading_id, source_type, source_id, + lot_no, expire_date + FROM transaction_packaging + WHERE company_code = $1 + AND status = 'STORED' + AND COALESCE(warehouse_code,'') = COALESCE($2,'') + AND COALESCE(location_code,'') = COALESCE($3,'') + AND source_id IN ( + SELECT id FROM inbound_detail + WHERE company_code = $1 AND item_number = $4 + ) + ORDER BY created_date ASC, package_label ASC`, + [companyCode, stock.warehouse_code || "", fromLocation || "", stock.item_code], + ); + + let remaining = qty; + const movedLabels: string[] = []; + let lastBox: any = null; + + for (const box of boxRows.rows) { + const boxQty = parseFloat(box.quantity) || 0; + if (boxQty <= 0) continue; + if (boxQty > remaining) break; // 박스 분할 안 함 + await client.query( + `UPDATE transaction_packaging + SET warehouse_code = $1, + location_code = $2, + updated_date = NOW() + WHERE id = $3 AND company_code = $4`, + [to_warehouse_code, toLocation, box.id, companyCode], + ); + movedLabels.push(box.package_label); + lastBox = box; + remaining -= boxQty; + if (remaining <= 0) break; + } + + let spilloverLabel: string | null = null; + if (remaining > 0 && lastBox && item.spillover_mode === "new-box") { + spilloverLabel = await insertSpilloverBox( + client, + { + companyCode, + itemCode: stock.item_code, + warehouseCode: to_warehouse_code, + locationCode: toLocation, + quantity: remaining, + templateSourceType: lastBox.source_type, + templateSourceId: lastBox.source_id, + templatePkgCode: lastBox.pkg_code, + templateLoadingId: lastBox.loading_id, + templateLotNo: lastBox.lot_no, + templateExpireDate: lastBox.expire_date, + writer: userId, + }, + lastBox.package_label, + ); + } + + if (movedLabels.length > 0 || spilloverLabel) { + logger.info("재고이동 박스 처리", { + companyCode, + item_code: stock.item_code, + requested_qty: qty, + moved_boxes: movedLabels.length, + spillover_qty: remaining > 0 ? remaining : 0, + spillover_mode: item.spillover_mode || "none", + spillover_label: spilloverLabel, + }); + } + } catch (pkgErr: any) { + logger.error("재고이동 박스 처리 실패", { + error: pkgErr.message, + stock_id: item.stock_id, + }); + throw pkgErr; + } + moveCount++; results.push({ stock_id: item.stock_id, status: "ok" }); } diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index f8c4140d..fbb77c04 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3,6 +3,10 @@ import type { Pool, PoolClient } from "pg"; import { getPool } from "../database/db"; import type { AuthenticatedRequest } from "../middleware/authMiddleware"; import logger from "../utils/logger"; +import { + ensureLoadingInstance, + insertPackagingRows, +} from "../services/transactionPackagingService"; // 불량 상세 항목 타입 interface DefectDetailItem { @@ -142,41 +146,46 @@ async function getPrevProcessGoodQty( woId: string, seqNum: number, companyCode: string, + batchId?: string | null, ): Promise { - // 1. 첫 공정 여부 판정 + const batchKey = batchId ?? ""; + // 1. 첫 공정 여부 판정 (batch 단위) const minSeqCheck = await exec.query( `SELECT MIN(CAST(seq_no AS int)) as min_seq FROM work_order_process - WHERE wo_id = $1 AND company_code = $2`, - [woId, companyCode], + WHERE wo_id = $1 AND company_code = $2 + AND COALESCE(batch_id, '') = $3`, + [woId, companyCode, batchKey], ); const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; if (seqNum <= minSeq) { return null; // 첫 공정 } - // 2. 앞 seq 조회 + // 2. 앞 seq 조회 (batch 단위) const prevProcessSeq = await exec.query( `SELECT MAX(CAST(seq_no AS int)) as prev_seq FROM work_order_process WHERE wo_id = $1 AND company_code = $2 - AND CAST(seq_no AS int) < $3`, - [woId, companyCode, seqNum], + AND COALESCE(batch_id, '') = $3 + AND CAST(seq_no AS int) < $4`, + [woId, companyCode, batchKey, seqNum], ); const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; if (actualPrevSeq == null) { return null; } - // 3. 앞공정 양품 SUM + // 3. 앞공정 양품 SUM (batch 단위) const prevAgg = await exec.query( `SELECT COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) + COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) as total_good FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND COALESCE(wop.batch_id, '') = $4 AND wr.result_status IN ('draft','confirmed')`, - [woId, String(actualPrevSeq), companyCode], + [woId, String(actualPrevSeq), companyCode, batchKey], ); return parseInt(prevAgg.rows[0].total_good, 10) || 0; } @@ -197,6 +206,7 @@ async function evaluatePrevProcesses( woId: string, currentSeq: number, companyCode: string, + batchId?: string | null, ): Promise<{ canAccept: boolean; blockedReason: string | null; @@ -207,6 +217,7 @@ async function evaluatePrevProcesses( processName: string; }>; }> { + const batchKey = batchId ?? ""; const sql = ` WITH wop_with_seq AS ( SELECT @@ -217,6 +228,7 @@ async function evaluatePrevProcesses( COALESCE(wop.is_required, '') AS is_req FROM work_order_process wop WHERE wop.wo_id = $1::varchar AND wop.company_code = $2::varchar + AND COALESCE(wop.batch_id, '') = $4::varchar AND CAST(wop.seq_no AS int) < $3::int ), wr_agg AS ( @@ -244,7 +256,7 @@ async function evaluatePrevProcesses( ORDER BY w.seq_int DESC `; - const result = await exec.query(sql, [woId, companyCode, currentSeq]); + const result = await exec.query(sql, [woId, companyCode, currentSeq, batchKey]); const rows = result.rows as Array<{ id: string; seq_int: number; @@ -257,6 +269,15 @@ async function evaluatePrevProcesses( }>; const fetchInstructionQty = async (): Promise => { + // batch detail.qty 우선 (신규 batch 분리 데이터), 없으면 헤더 wi.qty (기존 데이터 호환) + if (batchKey) { + const wid = await exec.query( + `SELECT qty FROM work_instruction_detail WHERE id = $1 AND company_code = $2`, + [batchKey, companyCode], + ); + const widQty = parseInt(wid.rows[0]?.qty, 10); + if (Number.isFinite(widQty) && widQty > 0) return widQty; + } const wi = await exec.query( `SELECT qty FROM work_instruction WHERE id = $1 AND company_code = $2`, [woId, companyCode], @@ -362,8 +383,15 @@ export async function copyChecklistToSplit( userId: string, options?: { workInstructionNo?: string; skipAStrategy?: boolean }, ): Promise { - // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 - // 단, options.skipAStrategy === true 이면 A 전략 전체를 건너뛰고 B 로 진입 + // 검사 detail_type: 신규 테이블(process_work_inspection_result)로 분기. inspection_count 회차만큼 row 생성 + // 비검사 detail_type: 기존 process_work_result 유지 (BOM/자재/생산수량 등) + const INSP_FILTER_NON = `(detail_type IS NULL OR detail_type NOT IN ('inspection','checklist','procedure'))`; + const INSP_FILTER_YES = `detail_type IN ('inspection','checklist','procedure')`; + const COUNT_EXPR = (col: string) => + `CASE WHEN ${col}.inspection_count_apply = 'Y' AND COALESCE(NULLIF(${col}.inspection_count, ''), '1')::int > 0 + THEN COALESCE(NULLIF(${col}.inspection_count, ''), '1')::int + ELSE 1 END`; + if (routingDetailId && options?.skipAStrategy !== true) { const wiNo = options?.workInstructionNo; let wiExists = false; @@ -379,15 +407,15 @@ export async function copyChecklistToSplit( let countA = 0; if (wiNo && wiExists) { - // A-1. wi_* (작업지시 커스텀 템플릿) 에서 복사 - const result = await client.query( + // A-1. wi_* (작업지시 커스텀 템플릿) + // (1) 비검사 row → process_work_result + const nonInsp = await client.query( `INSERT INTO process_work_result ( id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, - inspection_code, inspection_method, unit, lower_limit, upper_limit, - input_type, lookup_target, display_fields, duration_minutes, + unit, input_type, lookup_target, display_fields, duration_minutes, status, writer ) SELECT @@ -395,8 +423,7 @@ export async function copyChecklistToSplit( wi.id, wid.id, wi.work_phase, wi.title, wi.sort_order::text, wid.content, wid.detail_type, wid.sort_order::text, wid.is_required, - wid.inspection_code, wid.inspection_method, wid.unit, wid.lower_limit, wid.upper_limit, - wid.input_type, wid.lookup_target, wid.display_fields, wid.duration_minutes::text, + wid.unit, wid.input_type, wid.lookup_target, wid.display_fields, wid.duration_minutes::text, 'pending', $2 FROM wi_process_work_item wi JOIN wi_process_work_item_detail wid @@ -404,20 +431,56 @@ export async function copyChecklistToSplit( WHERE wi.work_instruction_no = $5 AND wi.routing_detail_id = $3 AND wi.company_code = $4 + AND ${INSP_FILTER_NON.replace(/detail_type/g, "wid.detail_type")} ORDER BY wi.sort_order::int, wid.sort_order::int`, [wopResultId, userId, routingDetailId, companyCode, wiNo], ); - countA = result.rowCount ?? 0; + + // (2) 검사 row → process_work_inspection_result (N회차) + const insp = await client.query( + `INSERT INTO process_work_inspection_result ( + id, company_code, work_order_process_id, + source_work_item_id, source_detail_id, sequence_no, + detail_type, judgment_criteria, + inspection_code, inspection_method, item_title, detail_content, + unit, lower_limit, upper_limit, pass_criteria, + input_type, condition_auto_collect, condition_plc_data, + status, is_required, sort_order, writer, + work_phase, item_sort_order + ) + SELECT + gen_random_uuid()::text, wi.company_code, $1, + wi.id, wid.id, seq.n, + wid.detail_type, ist.judgment_criteria, + wid.inspection_code, wid.inspection_method, wi.title, wid.content, + wid.unit, wid.lower_limit, wid.upper_limit, ist.inspection_criteria, + wid.input_type, NULL, NULL, + 'pending', wid.is_required, wid.sort_order, $2, + wi.work_phase, wi.sort_order::text + FROM wi_process_work_item wi + JOIN wi_process_work_item_detail wid + ON wid.wi_work_item_id = wi.id AND wid.company_code = wi.company_code + LEFT JOIN inspection_standard ist + ON ist.inspection_code = wid.inspection_code AND ist.company_code = wid.company_code + CROSS JOIN LATERAL generate_series(1, ${COUNT_EXPR("wid")}) AS seq(n) + WHERE wi.work_instruction_no = $5 + AND wi.routing_detail_id = $3 + AND wi.company_code = $4 + AND wid.detail_type IN ('inspection','checklist','procedure') + ORDER BY wi.sort_order::int, wid.sort_order::int, seq.n`, + [wopResultId, userId, routingDetailId, companyCode, wiNo], + ); + countA = (nonInsp.rowCount ?? 0) + (insp.rowCount ?? 0); } else { - // A-2. 원본 템플릿(process_work_item) 에서 복사 (기존 동작) - const result = await client.query( + // A-2. 원본 템플릿(process_work_item) + // (1) 비검사 row → process_work_result + const nonInsp = await client.query( `INSERT INTO process_work_result ( id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, - inspection_code, inspection_method, unit, lower_limit, upper_limit, - input_type, lookup_target, display_fields, duration_minutes, + unit, input_type, lookup_target, display_fields, duration_minutes, status, writer ) SELECT @@ -425,32 +488,65 @@ export async function copyChecklistToSplit( pwi.id, pwd.id, pwi.work_phase, pwi.title, pwi.sort_order::text, pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, - pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, - pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + pwd.unit, pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, 'pending', $2 FROM process_work_item pwi JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id AND pwd.company_code = pwi.company_code WHERE pwi.routing_detail_id = $3 AND pwi.company_code = $4 + AND ${INSP_FILTER_NON.replace(/detail_type/g, "pwd.detail_type")} ORDER BY pwi.sort_order, pwd.sort_order`, [wopResultId, userId, routingDetailId, companyCode], ); - countA = result.rowCount ?? 0; + + // (2) 검사 row → process_work_inspection_result (N회차) + const insp = await client.query( + `INSERT INTO process_work_inspection_result ( + id, company_code, work_order_process_id, + source_work_item_id, source_detail_id, sequence_no, + detail_type, judgment_criteria, + inspection_code, inspection_method, item_title, detail_content, + unit, lower_limit, upper_limit, pass_criteria, + input_type, condition_auto_collect, condition_plc_data, + status, is_required, sort_order, writer, + work_phase, item_sort_order + ) + SELECT + gen_random_uuid()::text, pwi.company_code, $1, + pwi.id, pwd.id, seq.n, + pwd.detail_type, ist.judgment_criteria, + pwd.inspection_code, pwd.inspection_method, pwi.title, pwd.content, + pwd.unit, pwd.lower_limit, pwd.upper_limit, ist.inspection_criteria, + pwd.input_type, pwd.condition_auto_collect, pwd.condition_plc_data, + 'pending', pwd.is_required, pwd.sort_order, $2, + pwi.work_phase, pwi.sort_order::text + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + LEFT JOIN inspection_standard ist + ON ist.inspection_code = pwd.inspection_code AND ist.company_code = pwd.company_code + CROSS JOIN LATERAL generate_series(1, ${COUNT_EXPR("pwd")}) AS seq(n) + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + AND pwd.detail_type IN ('inspection','checklist','procedure') + ORDER BY pwi.sort_order, pwd.sort_order, seq.n`, + [wopResultId, userId, routingDetailId, companyCode], + ); + countA = (nonInsp.rowCount ?? 0) + (insp.rowCount ?? 0); } if (countA > 0) return countA; - // A 전략에서 0건이면 B 전략(마스터 wop의 체크리스트 복사)으로 fallthrough } - // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 wop의 process_work_result 구조 복사 - const result = await client.query( + // B. 마스터 wop 복사 + // (1) 비검사 row → process_work_result + const nonInspB = await client.query( `INSERT INTO process_work_result ( id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, - inspection_code, inspection_method, unit, lower_limit, upper_limit, - input_type, lookup_target, display_fields, duration_minutes, + unit, input_type, lookup_target, display_fields, duration_minutes, status, writer ) SELECT @@ -458,16 +554,45 @@ export async function copyChecklistToSplit( source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, - inspection_code, inspection_method, unit, lower_limit, upper_limit, - input_type, lookup_target, display_fields, duration_minutes, + unit, input_type, lookup_target, display_fields, duration_minutes, 'pending', $2 FROM process_work_result WHERE work_order_process_id = $3 AND company_code = $4 + AND ${INSP_FILTER_NON} ORDER BY item_sort_order, detail_sort_order`, [wopResultId, userId, masterProcessId, companyCode], ); - return result.rowCount ?? 0; + + // (2) 검사 row → process_work_inspection_result (마스터의 회차 그대로 복사) + const inspB = await client.query( + `INSERT INTO process_work_inspection_result ( + id, company_code, work_order_process_id, + source_work_item_id, source_detail_id, sequence_no, + detail_type, judgment_criteria, + inspection_code, inspection_method, item_title, detail_content, + unit, lower_limit, upper_limit, pass_criteria, + input_type, condition_auto_collect, condition_plc_data, + status, is_required, sort_order, writer, + work_phase, item_sort_order + ) + SELECT + gen_random_uuid()::text, company_code, $1, + source_work_item_id, source_detail_id, sequence_no, + detail_type, judgment_criteria, + inspection_code, inspection_method, item_title, detail_content, + unit, lower_limit, upper_limit, pass_criteria, + input_type, condition_auto_collect, condition_plc_data, + 'pending', is_required, sort_order, $2, + work_phase, item_sort_order + FROM process_work_inspection_result + WHERE work_order_process_id = $3 + AND company_code = $4 + ORDER BY sort_order, sequence_no`, + [wopResultId, userId, masterProcessId, companyCode], + ); + + return (nonInspB.rowCount ?? 0) + (inspB.rowCount ?? 0); } /** @@ -496,10 +621,15 @@ async function generateWorkProcessesForInstruction( ); if (batchId) { + // batch_id 정확 매칭 또는 기존 데이터 호환 (구버전: batch_id = item_number) + // A1 정책 — 기존 wop는 신규 detail.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 OR batch_id IS NULL)`, + `SELECT COUNT(*) as cnt FROM work_order_process wop + WHERE wop.wo_id = $1 AND wop.company_code = $2 + AND ( + wop.batch_id = $3 + OR wop.batch_id = (SELECT item_number FROM work_instruction_detail WHERE id = $3) + )`, [workInstructionId, companyCode, batchId], ); if (parseInt(existCheck.rows[0].cnt, 10) > 0) { @@ -696,7 +826,7 @@ export const syncWorkInstructions = async ( 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 + AND (wop.batch_id = wid.id OR wop.batch_id = wid.item_number) ) ) )`, @@ -726,7 +856,7 @@ export const syncWorkInstructions = async ( for (const wi of unsynced) { const detailResult = await pool.query( - `SELECT wid.item_number, wid.routing_version_id, wid.qty + `SELECT wid.id, wid.item_number, wid.routing_version_id, wid.qty FROM work_instruction_detail wid WHERE wid.work_instruction_id = $1 AND wid.routing_version_id IS NOT NULL @@ -825,7 +955,7 @@ export const syncWorkInstructions = async ( detail.qty || null, companyCode, userId, - detail.item_number, + detail.id, ); if (!result) { @@ -952,26 +1082,37 @@ export const controlTimer = async ( case "complete": { const { good_qty, defect_qty } = req.body; - const groupSumResult = await pool.query( - `SELECT COALESCE(SUM( - CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN - EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int - - COALESCE(group_total_paused_time::int, 0) - ELSE 0 END - ), 0)::text AS total_work_seconds - FROM process_work_result - WHERE work_order_process_id = $1 AND company_code = $2`, + // 공정 단위 실작업시간 = NOW - started_at - total_paused_time + // paused 상태에서 종료한 경우 현재 진행 중인 paused 구간도 차감 + const elapsedResult = await pool.query( + `SELECT GREATEST(0, + EXTRACT(EPOCH FROM NOW() - started_at::timestamp)::int + - COALESCE(total_paused_time::int, 0) + - CASE WHEN paused_at IS NOT NULL + THEN EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int + ELSE 0 END + )::text AS work_seconds + FROM work_order_process_result + WHERE id = $1 AND company_code = $2 AND started_at IS NOT NULL`, [work_order_process_id, companyCode], ); const calculatedWorkTime = - groupSumResult.rows[0]?.total_work_seconds || "0"; + elapsedResult.rows[0]?.work_seconds || "0"; + // paused 상태에서 종료한 경우 total_paused_time 도 정산 result = await pool.query( `UPDATE work_order_process_result SET status = 'completed', completed_at = NOW()::text, completed_by = $3, actual_work_time = $4, + total_paused_time = CASE + WHEN paused_at IS NOT NULL THEN ( + COALESCE(total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int + )::text + ELSE total_paused_time + END, good_qty = COALESCE($5, good_qty), defect_qty = COALESCE($6, defect_qty), paused_at = NULL, @@ -1079,14 +1220,6 @@ export const controlGroupTimer = async ( RETURNING id, group_started_at`, baseParams, ); - if (work_order_process_id) { - await pool.query( - `UPDATE work_order_process_result - SET started_at = NOW()::text, updated_date = NOW() - WHERE id = $1 AND company_code = $2 AND started_at IS NULL`, - [work_order_process_id, companyCode], - ); - } break; case "pause": @@ -1132,33 +1265,6 @@ export const controlGroupTimer = async ( RETURNING id, group_started_at, group_completed_at, group_total_paused_time`, baseParams, ); - - // 접수 카드(wop_result) 의 actual_work_time 합산 갱신 - // phase 내 모든 row 가 동일값을 가지므로 phase 별 MAX 로 대표값 추출 후 SUM - if (work_order_process_id) { - await pool.query( - `UPDATE work_order_process_result wr - SET actual_work_time = sub.total_work_seconds::text, - updated_date = NOW() - FROM ( - SELECT COALESCE(SUM(phase_seconds), 0) AS total_work_seconds - FROM ( - SELECT work_phase, - MAX( - CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN - EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int - - COALESCE(group_total_paused_time::int, 0) - ELSE 0 END - ) AS phase_seconds - FROM process_work_result - WHERE work_order_process_id = $1 AND company_code = $2 - GROUP BY work_phase - ) p - ) sub - WHERE wr.id = $1 AND wr.company_code = $2`, - [work_order_process_id, companyCode], - ); - } break; } } @@ -1256,6 +1362,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { defect_qty, defect_detail, result_note, + material_inputs, } = req.body; // validation: BEGIN 이전에 처리 @@ -1518,7 +1625,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { await client.query( `UPDATE work_order_process_result SET status = 'completed', result_status = 'confirmed', - completed_at = NOW()::text, completed_by = $3, updated_date = NOW() + completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [work_order_process_id, companyCode, userId], ); @@ -1527,6 +1634,102 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId); } + // 자재 자동 투입 + 재고 차감 (이번 차수 생산수량 × 1개당 소요량) + // material_inputs 가 있으면 process_work_result INSERT + inventory_stock 차감. + // 재고 부족 시 ROLLBACK + 400 응답. + if (Array.isArray(material_inputs) && material_inputs.length > 0) { + for (const mi of material_inputs) { + const childItemId = mi.child_item_id; + const childItemCode = mi.child_item_code; + const childItemName = mi.child_item_name || ""; + const inputQty = parseFloat(String(mi.input_qty)); + const unit = mi.unit || ""; + const bomDetailId = mi.bom_detail_id || null; + const requiredQty = mi.required_qty != null ? String(mi.required_qty) : null; + + if (!childItemId || !childItemCode || isNaN(inputQty) || inputQty <= 0) { + continue; + } + + // 창고/로케이션 자동 선택 (기존 saveMaterialInput 패턴: 가장 최근 입고 우선) + const autoStock = await client.query( + `SELECT warehouse_code, location_code, + COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0 + ORDER BY last_in_date DESC NULLS LAST LIMIT 1`, + [companyCode, childItemCode], + ); + + if (autoStock.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 0)`, + }); + } + + const stockRow = autoStock.rows[0]; + const stockQty = parseFloat(String(stockRow.current_qty)) || 0; + if (stockQty < inputQty) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 ${stockQty}, 필요 ${inputQty})`, + }); + } + + const effectiveWh = stockRow.warehouse_code; + const effectiveLoc = stockRow.location_code || effectiveWh; + + await client.query( + `INSERT INTO process_work_result ( + id, company_code, work_order_process_id, + detail_type, detail_content, item_title, + result_value, unit, is_passed, status, + bom_detail_id, required_qty, warehouse_code, location_code, + recorded_by, recorded_at, writer + ) VALUES ( + gen_random_uuid()::text, $1, $2, + 'material_input', $3, $4, + $5, $6, 'Y', 'completed', + $7, $8, $9, $10, + $11, NOW()::text, $11 + )`, + [ + companyCode, + work_order_process_id, + childItemCode, + childItemName, + String(inputQty), + unit, + bomDetailId, + requiredQty, + effectiveWh, + effectiveLoc, + userId, + ], + ); + + await client.query( + `UPDATE inventory_stock + SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text, + updated_date = NOW(), writer = $5 + WHERE company_code = $1 AND item_code = $2 + AND warehouse_code = $3 AND location_code = $6`, + [ + companyCode, + childItemCode, + effectiveWh, + String(inputQty), + userId, + effectiveLoc, + ], + ); + } + } + const latestData = await client.query( `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty, @@ -1789,7 +1992,6 @@ export const confirmResult = async ( `UPDATE work_order_process_result SET result_status = 'confirmed', status = 'completed', - completed_at = NOW()::text, completed_by = $3, writer = $3, updated_date = NOW() @@ -1832,6 +2034,122 @@ export const confirmResult = async ( } }; +/** + * 작업기준이 비어있는 공정의 자동 완료 처리. + * - 호출 조건: 해당 wopr 의 process_work_result 가 0건 (= 작업기준 미등록) + * - 동작: input_qty 전부를 양품으로 처리하고 confirmed/completed 로 전환 + */ +export const autoCompleteProcess = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + const wopr = await pool.query( + `SELECT wr.id, wr.input_qty, wr.status, wr.result_status, wr.wop_id + FROM work_order_process_result wr + WHERE wr.id = $1 AND wr.company_code = $2`, + [work_order_process_id, companyCode], + ); + + if (wopr.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "접수 카드를 찾을 수 없습니다." }); + } + + const prev = wopr.rows[0]; + if (prev.result_status === "confirmed" || prev.status === "completed") { + return res + .status(403) + .json({ success: false, message: "이미 확정된 실적입니다." }); + } + + const prCheck = await pool.query( + `SELECT COUNT(*)::int AS cnt FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 + AND detail_type = 'production_result'`, + [work_order_process_id, companyCode], + ); + if ((prCheck.rows[0]?.cnt ?? 0) > 0) { + return res.status(400).json({ + success: false, + message: "실적등록 항목이 존재하는 공정은 자동 완료할 수 없습니다.", + }); + } + + const acceptedQty = parseInt(prev.input_qty, 10) || 0; + if (acceptedQty <= 0) { + return res + .status(400) + .json({ success: false, message: "접수 수량이 없습니다." }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const result = await client.query( + `UPDATE work_order_process_result + SET status = 'completed', + result_status = 'confirmed', + total_production_qty = $3, + good_qty = $3, + defect_qty = '0', + concession_qty = '0', + completed_at = NOW()::text, + completed_by = $4, + writer = $4, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`, + [work_order_process_id, companyCode, String(acceptedQty), userId], + ); + + const wopLookup = await client.query( + `SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [result.rows[0].wop_id, companyCode], + ); + + await client.query("COMMIT"); + + if ((wopLookup.rowCount ?? 0) > 0) { + await checkAndCompleteWorkInstruction( + pool, + wopLookup.rows[0].wo_id, + companyCode, + userId, + ); + } + + return res.json({ success: true, data: result.rows[0] }); + } catch (txErr) { + await client.query("ROLLBACK"); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("[pop/production] auto-complete 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "자동 완료 처리 중 오류가 발생했습니다.", + }); + } +}; + /** * 실적 이력 조회 — work_order_process_result 기준 (wop_result.id) */ @@ -1946,7 +2264,7 @@ export const getAvailableQty = async ( } const current = await pool.query( - `SELECT wop.id, wop.seq_no, wop.wo_id, + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.batch_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code @@ -1960,7 +2278,8 @@ export const getAvailableQty = async ( .json({ success: false, message: "공정을 찾을 수 없습니다." }); } - const { seq_no, wo_id, instruction_qty } = current.rows[0]; + const { seq_no, wo_id, instruction_qty, batch_id: currentBatchId } = + current.rows[0]; const instrQty = parseInt(instruction_qty, 10) || 0; const seqNum = parseInt(seq_no, 10); @@ -1979,12 +2298,19 @@ export const getAvailableQty = async ( // accept-process 와 정책 일치: 비필수 0건 자동 skip 흐름을 모달 max 에도 반영 // (getPrevProcessGoodQty 는 단순 직전 seq 만 봐서 비필수 wop 가 끼면 0 으로 잘못 잡힘) - const prevEval = await evaluatePrevProcesses(pool, wo_id, seqNum, companyCode); + const prevEval = await evaluatePrevProcesses( + pool, + wo_id, + seqNum, + companyCode, + currentBatchId, + ); const prevGoodQty = prevEval.prevGoodQty; const availableQty = Math.max(0, prevGoodQty - myInputQty); - // 앞공정 리워크 미소진 + // 앞공정 리워크 미소진 — batch 단위 + const batchKey = currentBatchId ?? ""; let reworkAvailableQty = 0; if (seqNum > 1) { const prevSeq = String(seqNum - 1); @@ -1993,11 +2319,12 @@ export const getAvailableQty = async ( FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND COALESCE(wop.batch_id, '') = $4 AND wr.is_rework IN ('Y','true','1') AND wr.status = 'completed' AND CAST(NULLIF(wr.good_qty, '') AS int) > 0 GROUP BY wr.rework_source_id`, - [wo_id, prevSeq, companyCode], + [wo_id, prevSeq, companyCode, batchKey], ); for (const rs of reworkSplits.rows) { const srcId = rs.rework_source_id; @@ -2007,9 +2334,10 @@ export const getAvailableQty = async ( FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND COALESCE(wop.batch_id, '') = $5 AND wr.is_rework IN ('Y','true','1') AND wr.rework_source_id = $4`, - [wo_id, seq_no, companyCode, srcId], + [wo_id, seq_no, companyCode, srcId, batchKey], ); const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; reworkAvailableQty += Math.max(0, srcGood - consumed); @@ -2085,7 +2413,7 @@ export const acceptProcess = async ( // 마스터 wop FOR UPDATE (동시 접수 race 방지) const current = await client.query( - `SELECT wop.id, wop.seq_no, wop.wo_id, + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.batch_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.routing_detail_id, wi.qty as instruction_qty @@ -2108,6 +2436,25 @@ export const acceptProcess = async ( const instrQty = parseInt(row.instruction_qty, 10) || 0; const seqNum = parseInt(row.seq_no, 10); + // 종료예정일 초과 차단: work_instruction_detail.end_date < 오늘이면 접수 거부 + if (row.batch_id) { + const overdueCheck = await client.query( + `SELECT 1 FROM work_instruction_detail + WHERE id = $1 AND company_code = $2 + AND end_date IS NOT NULL AND end_date <> '' + AND end_date < to_char(CURRENT_DATE, 'YYYY-MM-DD')`, + [row.batch_id, companyCode], + ); + if ((overdueCheck.rowCount ?? 0) > 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ + success: false, + message: + "이 작업은 종료예정일이 지난 작업입니다. 관리자에게 문의하세요.", + }); + } + } + // 완료 판정: confirmed 존재 + SUM(good+concession) >= instrQty → 거부 const completedCheck = await client.query( `SELECT @@ -2144,8 +2491,14 @@ export const acceptProcess = async ( const currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - // 앞공정 평가 (스킵/차단/접수가능량 통합) - const evalResult = await evaluatePrevProcesses(client, row.wo_id, seqNum, companyCode); + // 앞공정 평가 (스킵/차단/접수가능량 통합) — batch 단위 + const evalResult = await evaluatePrevProcesses( + client, + row.wo_id, + seqNum, + companyCode, + row.batch_id, + ); if (!evalResult.canAccept) { await client.query("ROLLBACK"); return res.status(400).json({ @@ -2250,17 +2603,19 @@ export const acceptProcess = async ( splitReworkSourceId = req.body.rework_source_id; } else if (seqNum > 1) { const prevSeq = String(seqNum - 1); + const acceptBatchKey = row.batch_id ?? ""; const prevReworkSplits = await client.query( `SELECT wr.rework_source_id, COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) as rework_good FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND COALESCE(wop.batch_id, '') = $4 AND wr.is_rework = 'Y' AND wr.status = 'completed' AND CAST(NULLIF(wr.good_qty, '') AS int) > 0 GROUP BY wr.rework_source_id`, - [row.wo_id, prevSeq, companyCode], + [row.wo_id, prevSeq, companyCode, acceptBatchKey], ); for (const rs of prevReworkSplits.rows) { const srcId = rs.rework_source_id; @@ -2270,9 +2625,10 @@ export const acceptProcess = async ( FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND COALESCE(wop.batch_id, '') = $5 AND wr.is_rework = 'Y' AND wr.rework_source_id = $4`, - [row.wo_id, row.seq_no, companyCode, srcId], + [row.wo_id, row.seq_no, companyCode, srcId, acceptBatchKey], ); const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; const remaining = srcGood - consumed; @@ -3141,6 +3497,8 @@ export const getBomMaterials = async ( .json({ success: false, message: "processId 필수" }); } + // processId 는 wop.id (master) 또는 wop_result.id (접수 카드) 두 형태 모두 허용. + // wop_result.id 가 들어오면 wop_id 로 매핑해서 동일하게 처리. const procResult = await pool.query( `SELECT wop.wo_id, wop.process_code, wop.plan_qty, wi.item_id, wi.qty as instruction_qty, @@ -3149,7 +3507,12 @@ export const getBomMaterials = async ( WHERE wop_id = wop.id AND company_code = wop.company_code) as total_input FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code - WHERE wop.id = $1 AND wop.company_code = $2`, + WHERE wop.company_code = $2::varchar + AND wop.id IN ( + $1::varchar, + (SELECT wop_id FROM work_order_process_result WHERE id = $1::varchar AND company_code = $2::varchar) + ) + LIMIT 1`, [processId, companyCode], ); if (procResult.rowCount === 0) { @@ -3225,6 +3588,7 @@ export const getBomMaterials = async ( child_item_code: childItemCode, child_item_name: bd.child_item_name || "", bom_qty: bomQty, + base_qty: baseQty, unit: bd.unit || bd.item_unit || "", process_type: bd.process_type || "", loss_rate: lossRate, @@ -3303,12 +3667,14 @@ export const saveMaterialInput = async ( id, company_code, work_order_process_id, detail_type, detail_content, item_title, result_value, unit, is_passed, status, - remark, recorded_by, recorded_at, writer + bom_detail_id, required_qty, warehouse_code, location_code, + recorded_by, recorded_at, writer ) VALUES ( gen_random_uuid()::text, $1, $2, 'material_input', $3, $4, $5, $6, 'Y', 'completed', - $7, $8, NOW()::text, $8 + $7, $8, $9, $10, + $11, NOW()::text, $11 ) RETURNING id`, [ companyCode, @@ -3317,12 +3683,10 @@ export const saveMaterialInput = async ( effectiveItemName, String(parsedQty), unit || "", - JSON.stringify({ - bom_detail_id, - required_qty: required_qty || 0, - warehouse_code, - location_code, - }), + bom_detail_id || null, + required_qty != null ? String(required_qty) : null, + warehouse_code || null, + location_code || null, userId, ], ); @@ -3404,7 +3768,8 @@ export const getMaterialInputs = async ( const result = await pool.query( `SELECT id, detail_content as item_code, item_title as item_name, - result_value as input_qty, unit, remark, recorded_by, recorded_at + result_value as input_qty, unit, remark, recorded_by, recorded_at, + bom_detail_id, required_qty, warehouse_code, location_code FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' @@ -3437,6 +3802,8 @@ export const getChecklistItems = async ( .json({ success: false, message: "processId 필수" }); } + // 검사 데이터는 process_work_inspection_result 로 분리됨 (2026-05-21) + // 이 엔드포인트는 비검사 detail (material_input / production_result / input) 만 반환 const result = await pool.query( `SELECT pwr.id, @@ -3451,14 +3818,8 @@ export const getChecklistItems = async ( pwr.detail_type, pwr.detail_sort_order, pwr.is_required, - pwr.inspection_code, - pwr.inspection_method, pwr.unit, - pwr.lower_limit, - pwr.upper_limit, pwr.input_type, - pwr.lookup_target, - pwr.display_fields, pwr.duration_minutes, pwr.status, pwr.result_value, @@ -3470,12 +3831,8 @@ export const getChecklistItems = async ( pwr.group_started_at, pwr.group_paused_at, pwr.group_total_paused_time, - pwr.group_completed_at, - ist.judgment_criteria + pwr.group_completed_at FROM process_work_result pwr - LEFT JOIN inspection_standard ist - ON pwr.inspection_code = ist.inspection_code - AND pwr.company_code = ist.company_code WHERE pwr.work_order_process_id = $1 AND pwr.company_code = $2 ORDER BY @@ -3552,6 +3909,7 @@ prev_good_raw AS ( wop.id AS wop_id, wop.wo_id, wop.company_code, + wop.batch_id, ( SELECT COALESCE(SUM(CAST(NULLIF(wr2.good_qty, '') AS numeric)), 0) + COALESCE(SUM(CAST(NULLIF(wr2.concession_qty, '') AS numeric)), 0) @@ -3561,6 +3919,7 @@ prev_good_raw AS ( SELECT wop2.id FROM work_order_process wop2 WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND EXISTS ( SELECT 1 FROM work_order_process_result wr3 @@ -3574,6 +3933,7 @@ prev_good_raw AS ( SELECT 1 FROM work_order_process wop2 WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND COALESCE(wop2.is_required, '') = 'Y' ) AS has_prior_required, @@ -3583,6 +3943,7 @@ prev_good_raw AS ( ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) ) AS has_prior_wr, EXISTS ( @@ -3591,6 +3952,7 @@ prev_good_raw AS ( ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND COALESCE(wop2.is_required, '') <> 'Y' AND wr2.result_status NOT IN ('confirmed','skipped') @@ -3600,6 +3962,7 @@ prev_good_raw AS ( SELECT 1 FROM work_order_process wop2 WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND COALESCE(wop2.is_required, '') = 'Y' AND NOT EXISTS ( @@ -3618,11 +3981,18 @@ prev_good AS ( WHEN pgr.has_unfinished_required_prior THEN 0 WHEN pgr.has_prior_wr THEN COALESCE(pgr.prev_good_qty_raw, 0) WHEN pgr.has_prior_required THEN 0 - ELSE COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) + -- 첫 공정 fallback: batch detail.qty 우선, 없으면 헤더 wi.qty (기존 데이터 호환) + ELSE COALESCE( + CAST(NULLIF(wid.qty, '') AS numeric), + CAST(NULLIF(wi.qty, '') AS numeric), + 0 + ) END AS prev_good_qty FROM prev_good_raw pgr LEFT JOIN work_instruction wi ON wi.id = pgr.wo_id AND wi.company_code = pgr.company_code + LEFT JOIN work_instruction_detail wid + ON wid.id = pgr.batch_id AND wid.company_code = pgr.company_code ), accepted_results AS ( SELECT wop_id, @@ -3682,12 +4052,22 @@ SELECT COALESCE(pg.prev_good_qty, 0) AS prev_good_qty, COALESCE(wa.sum_input_norework, 0) AS my_input_qty, GREATEST(0, COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0)) AS available_qty, - COALESCE(ar.accepted_results, '[]'::json) AS accepted_results + COALESCE(ar.accepted_results, '[]'::json) AS accepted_results, + -- batch 분리 메타 (frontend 라벨 조립용) + ROW_NUMBER() OVER ( + PARTITION BY wop.wo_id, wop.process_code + ORDER BY wid.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id + ) AS batch_index, + COUNT(*) OVER (PARTITION BY wop.wo_id, wop.process_code) AS batch_count, + wid.item_number AS batch_item_number, + wid.end_date AS batch_end_date FROM wop LEFT JOIN wr_agg wa ON wa.wop_id = wop.id LEFT JOIN prev_good pg ON pg.wop_id = wop.id LEFT JOIN accepted_results ar ON ar.wop_id = wop.id -ORDER BY wop.wo_id, CAST(wop.seq_no AS int) +LEFT JOIN work_instruction_detail wid + ON wid.id = wop.batch_id AND wid.company_code = wop.company_code +ORDER BY wop.wo_id, CAST(wop.seq_no AS int), wid.created_date NULLS LAST, wop.batch_id NULLS LAST `; const result = await pool.query(sql, [companyCode]); @@ -3745,3 +4125,184 @@ export const getProcessResult = async ( return res.status(500).json({ success: false, message: error.message }); } }; + +/** + * 공정 실적 포장단위/적재함 저장 (INSERT/UPDATE 통합) + * + * - source_type='work_order_process_result', source_doc_id=source_id=work_order_process_result.id + * - 기존 transaction_packaging row 는 모두 DELETE 후 packages 배열로 재 INSERT + * - 같은 적재함(loading_code, seq=1) 이면 transaction_loading row reuse, 변경 시 새 row INSERT + * - 라벨 prefix: 작업지시번호(work_instruction_no) 기반 (예: WI-2026-017-P0001) + */ +export const savePackaging = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + work_order_process_result_id, + packages, + loading_code, + loading_name, + } = req.body; + + if (!work_order_process_result_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_result_id는 필수입니다.", + }); + } + + const procInfo = await pool.query( + `SELECT wr.id, wr.wop_id, wop.wo_id, + wi.work_instruction_no + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + JOIN work_instruction wi ON wi.id = wop.wo_id AND wi.company_code = wop.company_code + WHERE wr.id = $1 AND wr.company_code = $2`, + [work_order_process_result_id, companyCode], + ); + + if (procInfo.rowCount === 0) { + return res + .status(404) + .json({ success: false, message: "실적을 찾을 수 없습니다." }); + } + + const proc = procInfo.rows[0]; + const labelPrefix: string = + proc.work_instruction_no || work_order_process_result_id; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query( + `DELETE FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + AND source_id = $2`, + [companyCode, work_order_process_result_id], + ); + + const loadingId = await ensureLoadingInstance(client, { + companyCode, + sourceType: "work_order_process_result", + sourceDocId: work_order_process_result_id, + loadingCode: loading_code || null, + loadingName: loading_name || null, + loadingSeq: 1, + writer: userId, + }); + + const rawPkgs: any[] = Array.isArray(packages) ? packages : []; + const packagesInput = rawPkgs + .map((p) => ({ + pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""), + count: Number(p?.count ?? 0), + qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0), + })) + .filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0); + + let labels: string[] = []; + if (packagesInput.length > 0) { + labels = await insertPackagingRows(client, { + companyCode, + sourceType: "work_order_process_result", + sourceId: work_order_process_result_id, + inboundNumber: labelPrefix, + packages: packagesInput, + loadingId, + warehouseCode: null, + locationCode: null, + writer: userId, + }); + } + + await client.query("COMMIT"); + + return res.json({ + success: true, + message: "포장/적재함이 저장되었습니다.", + data: { + loading_id: loadingId, + loading_code: loading_code || null, + loading_name: loading_name || null, + packages: packagesInput, + labels, + }, + }); + } catch (txErr: any) { + await client.query("ROLLBACK").catch(() => {}); + throw txErr; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("[pop/production] save-packaging 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message || "포장/적재함 저장 오류" }); + } +}; + +/** + * 공정 실적에 저장된 포장단위/적재함 조회 + */ +export const getProcessPackaging = async ( + req: AuthenticatedRequest, + res: Response, +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { wopResultId } = req.params; + + if (!wopResultId) { + return res.status(400).json({ + success: false, + message: "wopResultId는 필수입니다.", + }); + } + + const pkgResult = await pool.query( + `SELECT id, package_label, pkg_code, quantity, loading_id, seq_no, status + FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + AND source_id = $2 + ORDER BY seq_no ASC`, + [companyCode, wopResultId], + ); + + const loadingResult = await pool.query( + `SELECT tl.id, tl.loading_code, tl.loading_name, tl.loading_seq, + lu.loading_type, lu.max_load_kg, lu.max_stack + FROM transaction_loading tl + LEFT JOIN loading_unit lu ON lu.loading_code = tl.loading_code AND lu.company_code = tl.company_code + WHERE tl.company_code = $1 + AND tl.source_type = 'work_order_process_result' + AND tl.source_doc_id = $2 + ORDER BY tl.loading_seq ASC + LIMIT 1`, + [companyCode, wopResultId], + ); + + return res.json({ + success: true, + data: { + loading: loadingResult.rows[0] || null, + packages: pkgResult.rows, + }, + }); + } catch (error: any) { + logger.error("[pop/production] get-packaging 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message || "포장 조회 오류" }); + } +}; diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 8207714a..c1df8b09 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -13,6 +13,11 @@ import type { AuthenticatedRequest } from "../types/auth"; import { resolveCategoryCode } from "../utils/categoryUtils"; import { adjustInventory } from "../utils/inventoryUtils"; import { logger } from "../utils/logger"; +import { + ensureLoadingInstance, + insertPackagingRows, + type PackageEntryInput, +} from "../services/transactionPackagingService"; // 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환) export async function getList(req: AuthenticatedRequest, res: Response) { @@ -294,6 +299,54 @@ export async function create(req: AuthenticatedRequest, res: Response) { insertedDetails.push(detailResult.rows[0]); + // 2a-bis. transaction_loading + transaction_packaging + // - loading_code 가 있으면 적재함 인스턴스 ensure (같은 헤더 + 코드는 reuse) + // - packages 가 있으면 박스 단위로 펼쳐 N row INSERT (라벨 자동 발번) + try { + const detailId = detailResult.rows[0].id as string; + const loadingId = await ensureLoadingInstance(client, { + companyCode, + sourceType: "inbound", + sourceDocId: headerRow.id as string, + loadingCode: item.loading_code || null, + loadingName: item.loading_name || null, + loadingSeq: item.loading_seq ?? 1, + writer: userId, + }); + + const rawPkgs: any[] = Array.isArray(item.packages) ? item.packages : []; + const packagesInput: PackageEntryInput[] = rawPkgs + .map((p) => ({ + pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""), + count: Number(p?.count ?? 0), + qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0), + })) + .filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0); + + if (packagesInput.length > 0) { + const labels = await insertPackagingRows(client, { + companyCode, + sourceType: "inbound_detail", + sourceId: detailId, + inboundNumber, + packages: packagesInput, + loadingId, + warehouseCode: warehouse_code || item.warehouse_code || null, + locationCode: location_code || item.location_code || null, + lotNo: item.lot_number || null, + writer: userId, + }); + insertedDetails[insertedDetails.length - 1].package_labels = labels; + } + } catch (pkgErr: any) { + logger.error("transaction_packaging/loading 저장 실패", { + error: pkgErr.message, + inbound_number: inboundNumber, + item_number: item.item_number, + }); + throw pkgErr; + } + // 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지 const itemCode = item.item_number || null; const whCode = warehouse_code || item.warehouse_code || null; @@ -1085,6 +1138,7 @@ export async function getPurchaseOrders( COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS received_qty, COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS remain_qty, COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price, + NULLIF(ii.unit, '') AS unit, COALESCE(po.status, '') AS status, COALESCE(pd.due_date, po.due_date) AS due_date, 'purchase_detail' AS source_table, @@ -1125,6 +1179,7 @@ export async function getPurchaseOrders( - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) ) AS remain_qty, COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price, + NULL::text AS unit, po.status, po.due_date, 'purchase_order_mng' AS source_table, @@ -1219,7 +1274,8 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { sid.material, COALESCE(sid.ship_qty, 0) AS ship_qty, COALESCE(sid.order_qty, 0) AS order_qty, - sid.source_type + sid.source_type, + NULLIF(ii.unit, '') AS unit FROM shipment_instruction si JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id @@ -1227,6 +1283,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { LEFT JOIN customer_mng cm ON cm.customer_code = si.partner_id AND cm.company_code = si.company_code + LEFT JOIN item_info ii + ON ii.item_number = sid.item_code + AND ii.company_code = sid.company_code WHERE ${whereClause} ORDER BY si.instruction_date DESC, si.instruction_no LIMIT ${limit} OFFSET ${offset}`, @@ -1240,6 +1299,88 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { } } +// 불량입고용: 실제 출고 완료(outbound_mng) 데이터 조회 +// outbound_status IN ('출고완료', '부분출고') 만 노출 +export async function getOutbounds(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 50)); + const offset = (currentPage - 1) * limit; + + const conditions: string[] = [ + "om.company_code = $1", + "om.outbound_status IN ('출고완료', '부분출고')", + ]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx})`, + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const whereClause = conditions.join(" AND "); + const pool = getPool(); + + const countResult = await pool.query( + `SELECT COUNT(*) AS total + FROM outbound_mng om + WHERE ${whereClause}`, + params, + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( + `SELECT + om.id AS outbound_id, + om.outbound_number, + om.outbound_date, + om.customer_code, + COALESCE(cm.customer_name, om.customer_name, om.customer_code) AS customer_name, + om.outbound_status, + om.item_code, + om.item_name, + om.specification AS spec, + om.material, + COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0) AS outbound_qty, + COALESCE(sid_match.order_qty, 0) AS sales_order_qty, + (COALESCE(sid_match.order_qty, 0) + - COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0)) AS remain_qty, + om.source_type, + NULLIF(om.unit, '') AS unit + FROM outbound_mng om + LEFT JOIN customer_mng cm + ON cm.customer_code = om.customer_code + AND cm.company_code = om.company_code + LEFT JOIN LATERAL ( + SELECT COALESCE(sid.order_qty, 0) AS order_qty + FROM shipment_instruction si + JOIN shipment_instruction_detail sid + ON sid.instruction_id = si.id + AND sid.company_code = si.company_code + WHERE si.company_code = om.company_code + AND si.instruction_no = om.reference_number + AND sid.item_code = om.item_code + LIMIT 1 + ) sid_match ON true + WHERE ${whereClause} + ORDER BY om.outbound_date DESC, om.outbound_number DESC + LIMIT ${limit} OFFSET ${offset}`, + params, + ); + + return res.json({ success: true, data: dataResult.rows, totalCount }); + } catch (error: any) { + logger.error("출고 데이터 조회 실패(불량입고용)", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // 기타입고용: 품목 데이터 조회 export async function getItems(req: AuthenticatedRequest, res: Response) { try { @@ -1342,6 +1483,7 @@ export async function getProductionResults( COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name, COALESCE(ii.size, '') AS spec, COALESCE(ii.material, '') AS material, + NULLIF(ii.unit, '') AS unit, COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty, COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty, COALESCE(wr.is_rework, '') AS is_rework, @@ -1360,7 +1502,10 @@ export async function getProductionResults( WHERE iii.company_code = wop.company_code AND COALESCE(iii.is_active, 'Y') = 'Y' AND iii.item_code = COALESCE(ii.item_number, wi.item_id) - ) THEN 'self' ELSE NULL END AS inspection_type + ) THEN 'self' ELSE NULL END AS inspection_type, + tp_agg.packages, + tl.loading_code, + tl.loading_name FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id @@ -1370,7 +1515,7 @@ export async function getProductionResults( AND wi.company_code = wop.company_code LEFT JOIN ( SELECT DISTINCT ON (id, company_code) - id, item_number, item_name, size, material, image, company_code + id, item_number, item_name, size, material, unit, image, company_code FROM item_info ORDER BY id, company_code, created_date DESC ) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code @@ -1410,6 +1555,37 @@ export async function getProductionResults( AND CAST(wop3.seq_no AS int) < CAST(wop.seq_no AS int) ) ) prev ON true + LEFT JOIN ( + SELECT packed.source_id, packed.company_code, + JSON_AGG(JSON_BUILD_OBJECT( + 'pkg_code', packed.pkg_code, + 'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code), + 'count', packed.cnt, + 'qty_per_unit', packed.qty_per_unit + )) AS packages + FROM ( + SELECT source_id, company_code, pkg_code, + CAST(quantity AS numeric) AS qty_per_unit, + COUNT(*)::int AS cnt + FROM transaction_packaging + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + GROUP BY source_id, company_code, pkg_code, quantity + ) packed + LEFT JOIN pkg_unit pu + ON pu.pkg_code = packed.pkg_code + AND pu.company_code = packed.company_code + GROUP BY packed.source_id, packed.company_code + ) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code + LEFT JOIN ( + SELECT DISTINCT ON (source_doc_id, company_code) + source_doc_id, company_code, + loading_code, loading_name + FROM transaction_loading + WHERE company_code = $1 + AND source_type = 'work_order_process_result' + ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC + ) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code WHERE wr.company_code = $1 AND wop.process_code = $2 AND ( diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 21e094d2..2a43ce6a 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -147,12 +147,19 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, + NULLIF(itm.unit, '') AS item_unit, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq, - COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count, + EXISTS ( + SELECT 1 FROM work_order_process_result wopr + JOIN work_order_process wop ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + WHERE wop.company_code = wi.company_code + AND (wop.batch_id = d.id OR wop.batch_id = d.item_number) + ) AS is_locked FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id @@ -210,12 +217,19 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, + NULLIF(itm.unit, '') AS item_unit, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq, - COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count, + EXISTS ( + SELECT 1 FROM work_order_process_result wopr + JOIN work_order_process wop ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + WHERE wop.company_code = wi.company_code + AND (wop.batch_id = d.id OR wop.batch_id = d.item_number) + ) AS is_locked FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id @@ -281,15 +295,139 @@ export async function save(req: AuthenticatedRequest, res: Response) { let wiNo: string; if (editId) { + // ─── 수정 모드: row별 UPDATE/INSERT/DELETE + 잠금 가드 ─── const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]); if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다"); wiId = editId; wiNo = check.rows[0].work_instruction_no; + + // 1) 기존 detail rows + 잠금 상태 (해당 batch에 wop_result 1건이라도 있으면 잠김) + const existingRes = await client.query( + `SELECT wid.id, wid.item_number, wid.qty, wid.routing_version_id, + EXISTS ( + SELECT 1 FROM work_order_process_result wopr + JOIN work_order_process wop ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + WHERE wop.company_code = $2 + AND (wop.batch_id = wid.id OR wop.batch_id = wid.item_number) + ) AS is_locked + FROM work_instruction_detail wid + WHERE wid.work_instruction_id = $1`, + [wiId, companyCode] + ); + const existingMap = new Map(); + for (const row of existingRes.rows) { + existingMap.set(String(row.id), { + id: String(row.id), + item_number: String(row.item_number || ""), + qty: String(row.qty || ""), + routing_version_id: row.routing_version_id ?? null, + is_locked: row.is_locked === true, + }); + } + + // 2) payload items 분류 (detailId 매칭 → UPDATE, 없음 → INSERT, 누락 → DELETE) + const payloadIds = new Set(); + const updates: Array<{ item: any; detailId: string; locked: boolean }> = []; + const inserts: any[] = []; + for (const item of items) { + const did = item.detailId ? String(item.detailId) : ""; + if (did && existingMap.has(did)) { + payloadIds.add(did); + updates.push({ item, detailId: did, locked: existingMap.get(did)!.is_locked }); + } else { + inserts.push(item); + } + } + const deleteIds: string[] = []; + for (const id of existingMap.keys()) { + if (!payloadIds.has(id)) deleteIds.push(id); + } + + // 3) 잠금 가드 — UPDATE: 잠긴 row의 품목/수량/라우팅 변경 거부 + for (const u of updates) { + if (!u.locked) continue; + const orig = existingMap.get(u.detailId)!; + const reqItem = String(u.item.itemNumber || u.item.itemCode || ""); + const reqQty = String(u.item.qty ?? ""); + const reqRouting = String(u.item.routing ?? ""); + if (reqItem && reqItem !== orig.item_number) { + throw new Error(`이미 생산접수된 row의 품목은 변경할 수 없습니다 (id=${u.detailId}, 기존=${orig.item_number})`); + } + if (reqQty !== "" && reqQty !== String(orig.qty)) { + throw new Error(`이미 생산접수된 row의 수량은 변경할 수 없습니다 (id=${u.detailId})`); + } + if (reqRouting && reqRouting !== String(orig.routing_version_id || "")) { + throw new Error(`이미 생산접수된 row의 라우팅은 변경할 수 없습니다 (id=${u.detailId})`); + } + } + // 잠금 가드 — DELETE: 잠긴 row 삭제 거부 + for (const did of deleteIds) { + if (existingMap.get(did)!.is_locked) { + throw new Error(`이미 생산접수된 row는 삭제할 수 없습니다 (id=${did})`); + } + } + + // 4) 헤더 UPDATE (qty는 마지막에 detail 합계로 재계산) await client.query( `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`, [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode] ); - await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]); + + // 5) DELETE 처리 (process_work_result 마스터 체크리스트 → wop → detail 순) + for (const did of deleteIds) { + const orig = existingMap.get(did)!; + await client.query( + `DELETE FROM process_work_result + WHERE work_order_process_id IN ( + SELECT id FROM work_order_process + WHERE company_code = $1 AND (batch_id = $2 OR batch_id = $3) + )`, + [companyCode, did, orig.item_number] + ); + await client.query( + `DELETE FROM work_order_process + WHERE company_code = $1 AND (batch_id = $2 OR batch_id = $3)`, + [companyCode, did, orig.item_number] + ); + await client.query(`DELETE FROM work_instruction_detail WHERE id = $1`, [did]); + } + + // 6) UPDATE 처리 (잠금: 일정/설비/작업자만, 비잠금: 전체) + for (const u of updates) { + if (u.locked) { + await client.query( + `UPDATE work_instruction_detail SET start_date=$1, end_date=$2, equipment_ids=$3, work_teams=$4, workers=$5, remark=$6, updated_date=NOW(), writer=$7 WHERE id=$8`, + [u.item.startDate||"", u.item.endDate||"", u.item.equipmentIds||"", u.item.workTeams||"", u.item.workers||"", u.item.remark||"", userId, u.detailId] + ); + } else { + await client.query( + `UPDATE work_instruction_detail SET item_number=$1, qty=$2, remark=$3, source_table=$4, source_id=$5, part_code=$6, routing_version_id=$7, start_date=$8, end_date=$9, equipment_ids=$10, work_teams=$11, workers=$12, updated_date=NOW(), writer=$13 WHERE id=$14`, + [u.item.itemNumber||u.item.itemCode||"", u.item.qty||"0", u.item.remark||"", u.item.sourceTable||"", u.item.sourceId||"", u.item.partCode||u.item.itemNumber||u.item.itemCode||"", u.item.routing||null, u.item.startDate||"", u.item.endDate||"", u.item.equipmentIds||"", u.item.workTeams||"", u.item.workers||"", userId, u.detailId] + ); + // 비잠금 row의 wop plan_qty 동기화 + await client.query( + `UPDATE work_order_process SET plan_qty=$1, updated_date=NOW() WHERE company_code=$2 AND batch_id=$3`, + [u.item.qty||"0", companyCode, u.detailId] + ); + } + } + + // 7) INSERT 처리 (신규 detail — wop는 다음 POP 진입 시 sync가 자동 생성) + for (const item of inserts) { + await client.query( + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,clock_timestamp(),$16)`, + [companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", item.routing||null, item.startDate||"", item.endDate||"", item.equipmentIds||"", item.workTeams||"", item.workers||"", userId] + ); + } + + // 8) 헤더 qty 자동 동기화 (detail 합계) + await client.query( + `UPDATE work_instruction SET qty = COALESCE((SELECT SUM(CAST(NULLIF(qty, '') AS numeric))::text FROM work_instruction_detail WHERE work_instruction_id = $1), '0') WHERE id = $1`, + [wiId] + ); + + await client.query("COMMIT"); + return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } }); } else { try { const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); @@ -1038,3 +1176,56 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response) return res.status(500).json({ success: false, message: error.message }); } } + +// 작업지시 공정별 실적 (생산실적 우측 패널용) +// work_order_process LEFT JOIN work_order_process_result, 카드(result) 단위로 flat 반환. +// 공정에 카드가 없으면 wr.* 컬럼들이 모두 NULL 인 한 행을 반환. +export async function getProcessResults(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiId } = req.params; + if (!wiId) return res.status(400).json({ success: false, message: "wiId는 필수입니다." }); + + const pool = getPool(); + const result = await pool.query( + `SELECT + wop.id AS wop_id, + wop.wo_id, + wop.seq_no AS process_seq_no, + wop.process_code, + wop.process_name, + wr.id AS result_id, + wr.seq AS result_seq, + wr.equipment_code, + wr.input_qty, + wr.good_qty, + wr.defect_qty, + wr.concession_qty, + wr.total_production_qty, + wr.started_at, + wr.completed_at, + wr.status, + wr.result_status, + wr.result_note, + wr.defect_detail, + wr.is_rework, + wr.rework_source_id + FROM work_order_process wop + LEFT JOIN work_order_process_result wr + ON wr.wop_id = wop.id + AND wr.company_code = wop.company_code + WHERE wop.wo_id = $1 AND wop.company_code = $2 + ORDER BY + CASE WHEN wop.seq_no::text ~ '^[0-9]+$' THEN wop.seq_no::text::int ELSE NULL END NULLS LAST, + wop.seq_no::text, + CASE WHEN wr.seq::text ~ '^[0-9]+$' THEN wr.seq::text::int ELSE NULL END NULLS LAST, + wr.seq::text`, + [wiId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업지시 공정 실적 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/inventoryTransferRoutes.ts b/backend-node/src/routes/inventoryTransferRoutes.ts new file mode 100644 index 00000000..af61bd77 --- /dev/null +++ b/backend-node/src/routes/inventoryTransferRoutes.ts @@ -0,0 +1,37 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createTransferRequest, + getPendingList, + getReceivedHistory, + getSentList, + getTransferDetail, + processTransfer, + cancelTransfer, +} from "../controllers/inventoryTransferController"; + +const router = Router(); +router.use(authenticateToken); + +// 출고측 보내기 (마스터+디테일 생성, 출고측 즉시 차감) +router.post("/request", createTransferRequest); + +// 입고측 대기 목록 (to_warehouse 기준 PENDING/PARTIAL) +router.get("/pending", getPendingList); + +// 입고측 처리 완료 이력 (to_warehouse 기준 CONFIRMED/REJECTED/CANCELLED) +router.get("/received-history", getReceivedHistory); + +// 출고측 보낸 목록 (from_warehouse 기준 PENDING/PARTIAL) +router.get("/sent", getSentList); + +// 단건 상세 (마스터 + 디테일) +router.get("/:requestId", getTransferDetail); + +// 입고측 처리 (디테일별 확정/거절/부분입고 일괄) +router.post("/:requestId/process", processTransfer); + +// 출고측 취소 (PENDING 만, 전량 복원) +router.post("/:requestId/cancel", cancelTransfer); + +export default router; diff --git a/backend-node/src/routes/outboundRoutes.ts b/backend-node/src/routes/outboundRoutes.ts index 1d9f24cf..b03c87c1 100644 --- a/backend-node/src/routes/outboundRoutes.ts +++ b/backend-node/src/routes/outboundRoutes.ts @@ -31,6 +31,9 @@ router.get("/source/purchase-orders", outboundController.getPurchaseOrders); // 소스 데이터: 품목 (기타출고) router.get("/source/items", outboundController.getItems); +// 소스 데이터: 생산실적 (생산출고) — 마지막 공정까지 진행된 작업지시 +router.get("/source/production-results", outboundController.getProductionResults); + // 출고 등록 router.post("/", outboundController.create); diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index 3ec1b692..4cc47235 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -8,6 +8,7 @@ import { getLoadingUnitsByPkg, getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, getItemsByDivision, getGeneralItems, + getTransactionLoadings, getTransactionPackagings, } from "../controllers/packagingController"; const router = Router(); @@ -46,4 +47,8 @@ router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); router.get("/items/general", getGeneralItems); router.get("/items/:divisionLabel", getItemsByDivision); +// 거래 단위 포장재·적재함 추적 +router.get("/transaction-loadings", getTransactionLoadings); +router.get("/transaction-packagings", getTransactionPackagings); + export default router; diff --git a/backend-node/src/routes/popInventoryRoutes.ts b/backend-node/src/routes/popInventoryRoutes.ts index 676a4d5c..bb646be8 100644 --- a/backend-node/src/routes/popInventoryRoutes.ts +++ b/backend-node/src/routes/popInventoryRoutes.ts @@ -5,9 +5,12 @@ import { commitAdjust, loadTempAdjust, saveTempAdjust, + getAdjustHistory, } from "../controllers/popInventoryAdjustController"; import { getMoveStockList, + getMoveProcessList, + getMoveProcessStockList, commitMove, loadTempMove, saveTempMove, @@ -37,10 +40,15 @@ router.get("/adjust/temp-load", loadTempAdjust); // 임시저장 일괄 덮어쓰기 (자동 임시저장) router.post("/adjust/temp-save", saveTempAdjust); +// 재고조정 이력 조회 (단일 날짜) +router.get("/adjust/history", getAdjustHistory); + // ============================================================ // 신 POP 재고이동 (v2) — popInventoryMoveController // ============================================================ router.get("/move/stock-list", getMoveStockList); +router.get("/move/process-list", getMoveProcessList); +router.get("/move/process-stock-list", getMoveProcessStockList); router.post("/move/commit", commitMove); router.get("/move/temp-load", loadTempMove); router.post("/move/temp-save", saveTempMove); diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index eea9ebad..301198bf 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -8,6 +8,7 @@ import { getDefectTypes, saveResult, confirmResult, + autoCompleteProcess, getResultHistory, getAvailableQty, acceptProcess, @@ -25,6 +26,8 @@ import { getChecklistItems, getProcessList, getProcessResult, + savePackaging, + getProcessPackaging, } from "../controllers/popProductionController"; const router = Router(); @@ -38,6 +41,7 @@ router.post("/group-timer", controlGroupTimer); router.get("/defect-types", getDefectTypes); router.post("/save-result", saveResult); router.post("/confirm-result", confirmResult); +router.post("/auto-complete", autoCompleteProcess); router.get("/result-history", getResultHistory); router.get("/available-qty", getAvailableQty); router.post("/accept-process", acceptProcess); @@ -55,5 +59,7 @@ router.get("/material-inputs/:processId", getMaterialInputs); router.get("/checklist-items/:processId", getChecklistItems); router.get("/processes", getProcessList); router.get("/result/:id", getProcessResult); +router.post("/save-packaging", savePackaging); +router.get("/packaging/:wopResultId", getProcessPackaging); export default router; diff --git a/backend-node/src/routes/receivingRoutes.ts b/backend-node/src/routes/receivingRoutes.ts index 34b96821..035e0c05 100644 --- a/backend-node/src/routes/receivingRoutes.ts +++ b/backend-node/src/routes/receivingRoutes.ts @@ -25,6 +25,9 @@ router.get("/source/purchase-orders", receivingController.getPurchaseOrders); // 소스 데이터: 출하 (반품입고) router.get("/source/shipments", receivingController.getShipments); +// 소스 데이터: 실제 출고 완료 (불량입고) +router.get("/source/outbounds", receivingController.getOutbounds); + // 소스 데이터: 품목 (기타입고) router.get("/source/items", receivingController.getItems); diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index 1bec3b2d..a9a7da35 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -21,6 +21,9 @@ router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk); // BOM 기준수 일괄 조회 (작업지시 등록 모달 기준수/배치수 산출용) router.post("/bom-base-qty", ctrl.getBomBaseQtyMap); +// 작업지시별 공정 실적 집계 (생산실적 화면 우측 패널) +router.get("/:wiId/process-results", ctrl.getProcessResults); + // 라우팅 & 공정작업기준 router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); router.put("/:wiNo/routing", ctrl.updateRouting); diff --git a/backend-node/src/services/transactionPackagingService.ts b/backend-node/src/services/transactionPackagingService.ts new file mode 100644 index 00000000..4871f11c --- /dev/null +++ b/backend-node/src/services/transactionPackagingService.ts @@ -0,0 +1,372 @@ +/** + * 거래 단위 포장재·적재함 추적 서비스 + * + * 박스(라벨) 1개 = transaction_packaging row 1개. + * 입고/출고/재고이동 등에서 라인 단위 packages/loading_code 를 받아 + * transaction_loading + transaction_packaging row 를 생성한다. + * + * 호출 시점: 컨트롤러의 진행 중인 DB 트랜잭션 안에서 사용 (client 전달) + */ + +import type { PoolClient } from "pg"; + +export interface PackageEntryInput { + pkg_code: string; // pkg_unit.pkg_code (UI 의 unit.value) + count: number; // 박스 개수 + qty_per_unit: number; // 박스 1개에 담길 수량 +} + +export interface LoadingInstanceParams { + companyCode: string; + sourceType: string; // 'inbound' / 'outbound' / 'inventory_move' + sourceDocId: string; // 헤더 ID (예: inbound_mng.id) + loadingCode: string | null; // null 이면 적재함 없음 + loadingName?: string | null; + loadingSeq?: number | null; // 같은 종류 동시 사용 시 구분 (기본 1) + writer?: string | null; +} + +export interface PackagingInsertParams { + companyCode: string; + sourceType: string; // 'inbound_detail' 등 (라인 레벨) + sourceId: string; // 디테일 라인 ID + inboundNumber: string; // 라벨 prefix (예: INB-2026-001) + packages: PackageEntryInput[]; + loadingId: string | null; // transaction_loading.id 또는 null + warehouseCode?: string | null; + locationCode?: string | null; + lotNo?: string | null; + expireDate?: string | null; + writer?: string | null; +} + +export interface SpilloverBoxParams { + companyCode: string; + itemCode: string; // 박스 매칭 후 잔여 품목 + warehouseCode: string; // 도착 위치 + locationCode: string | null; + quantity: number; // 잔여 수량 + // 직전 박스 (FIFO 마지막) 정보 자동 복사 + templateSourceType: string; // 'inbound_detail' 등 + templateSourceId: string; + templatePkgCode: string; + templateLoadingId: string | null; + templateLotNo: string | null; + templateExpireDate: string | null; + writer?: string | null; +} + +/** + * 적재함 인스턴스 생성 (loading_code 가 있을 때). + * + * 같은 (companyCode, sourceType, sourceDocId, loadingCode, loadingSeq) 가 + * 이미 있으면 reuse, 없으면 INSERT. + * + * @returns transaction_loading.id (loading_code 가 null 이면 null) + */ +export async function ensureLoadingInstance( + client: PoolClient, + params: LoadingInstanceParams, +): Promise { + if (!params.loadingCode) return null; + + const seq = params.loadingSeq ?? 1; + + const existing = await client.query( + `SELECT id FROM transaction_loading + WHERE company_code = $1 + AND source_type = $2 + AND source_doc_id = $3 + AND loading_code = $4 + AND COALESCE(loading_seq, 1) = $5 + LIMIT 1`, + [ + params.companyCode, + params.sourceType, + params.sourceDocId, + params.loadingCode, + seq, + ], + ); + if (existing.rowCount && existing.rowCount > 0) { + return existing.rows[0].id as string; + } + + const inserted = await client.query( + `INSERT INTO transaction_loading ( + id, company_code, source_type, source_doc_id, + loading_code, loading_seq, loading_name, + writer, created_by, created_date, updated_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, + $4, $5, $6, + $7, $7, NOW(), NOW() + ) RETURNING id`, + [ + params.companyCode, + params.sourceType, + params.sourceDocId, + params.loadingCode, + seq, + params.loadingName ?? null, + params.writer ?? null, + ], + ); + return inserted.rows[0].id as string; +} + +/** + * 입고/디테일 1라인에 대해 packages 배열을 박스 단위로 펼쳐 INSERT. + * + * PackageEntry 1건이 { count: 5, qty_per_unit: 20 } 이면 5 row. + * 라벨 번호는 inboundNumber 내 기존 row MAX + 1 부터 순차 발번. + * + * @returns 발번된 라벨 배열 + */ +export async function insertPackagingRows( + client: PoolClient, + params: PackagingInsertParams, +): Promise { + if (!params.packages || params.packages.length === 0) return []; + + const startSeq = await nextLabelSeq( + client, + params.companyCode, + params.inboundNumber, + ); + + const labels: string[] = []; + let seq = startSeq; + let lineSeqNo = 0; + + for (const entry of params.packages) { + const count = Math.max(0, Math.floor(entry.count)); + const qty = Number(entry.qty_per_unit); + if (!entry.pkg_code || count <= 0 || !(qty > 0)) continue; + + for (let i = 0; i < count; i++) { + lineSeqNo++; + const label = buildLabel(params.inboundNumber, seq); + await client.query( + `INSERT INTO transaction_packaging ( + id, company_code, source_type, source_id, + package_label, pkg_code, quantity, + loading_id, seq_no, status, + lot_no, expire_date, + warehouse_code, location_code, + writer, created_by, created_date, updated_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, + $4, $5, $6, + $7, $8, 'STORED', + $9, $10, + $11, $12, + $13, $13, NOW(), NOW() + )`, + [ + params.companyCode, + params.sourceType, + params.sourceId, + label, + entry.pkg_code, + qty, + params.loadingId, + lineSeqNo, + params.lotNo ?? null, + params.expireDate ?? null, + params.warehouseCode ?? null, + params.locationCode ?? null, + params.writer ?? null, + ], + ); + labels.push(label); + seq++; + } + } + + return labels; +} + +/** + * 재고이동 잔여 처리용 신규 박스 1개 INSERT. + * + * FIFO 매칭 박스 합산이 이동 qty 보다 작을 때, 잔여를 도착 위치에 + * 새 박스 1개로 담아 옮기는 경우 사용. 직전 박스의 pkg_code 등을 자동 복사. + * + * 라벨 prefix 는 직전 박스의 라벨에서 추출 (예: INB-2026-001-P0021 → INB-2026-001). + * + * @returns 발번된 새 라벨 + */ +export async function insertSpilloverBox( + client: PoolClient, + params: SpilloverBoxParams, + templateLabel: string, +): Promise { + // 라벨 형식 `{inboundNumber}-P{4자리}` 에서 마지막 -PNNNN 만 제거해 inboundNumber 복원 + // (inboundNumber 자체에 -P 가 포함된 경우 split("-P") 가 잘못 자르는 버그 회피) + const match = templateLabel.match(/^(.+)-P\d+$/); + const inboundNumber = match ? match[1] : templateLabel; + + const seq = await nextLabelSeq(client, params.companyCode, inboundNumber); + const label = buildLabel(inboundNumber, seq); + + await client.query( + `INSERT INTO transaction_packaging ( + id, company_code, source_type, source_id, + package_label, pkg_code, quantity, + loading_id, seq_no, status, + lot_no, expire_date, + warehouse_code, location_code, + writer, created_by, created_date, updated_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, + $4, $5, $6, + $7, $8, 'STORED', + $9, $10, + $11, $12, + $13, $13, NOW(), NOW() + )`, + [ + params.companyCode, + params.templateSourceType, + params.templateSourceId, + label, + params.templatePkgCode, + params.quantity, + params.templateLoadingId, + seq, + params.templateLotNo, + params.templateExpireDate, + params.warehouseCode, + params.locationCode, + params.writer ?? null, + ], + ); + return label; +} + +/** + * 출고 처리 — 박스 단위 status/quantity 변경 (Phase 2-b) + * + * FIFO 매칭: 출발 위치의 STORED 박스 중 누적 합산이 qty 에 도달할 때까지 + * - 박스 전체 출고 → `status='SHIPPED'` + * - 마지막 박스 부분 출고 → `quantity` 차감 (0 되면 SHIPPED) + * - 잔여 = qty - 박스합산 (박스 미보유분) → 박스 row 영향 없음 (inventory_stock 만 차감) + * + * @returns 처리된 라벨 목록 + 부분 차감 라벨 + 박스 미보유 차감 수량 + */ +export interface OutboundBoxResult { + shippedLabels: string[]; + partialLabel: string | null; // 부분 차감된 박스 라벨 + partialBefore: number | null; // 부분 차감 전 quantity + partialAfter: number | null; // 부분 차감 후 quantity + untrackedQty: number; // 박스 미보유 잔여 (inventory_stock 만 차감되는 양) +} + +export async function processBoxOutbound( + client: PoolClient, + params: { + companyCode: string; + itemCode: string; + warehouseCode: string | null; + locationCode: string | null; + qty: number; + writer?: string | null; + }, +): Promise { + const boxRows = await client.query( + `SELECT id, package_label, quantity + FROM transaction_packaging + WHERE company_code = $1 + AND status = 'STORED' + AND COALESCE(warehouse_code,'') = COALESCE($2,'') + AND COALESCE(location_code,'') = COALESCE($3,'') + AND source_id IN ( + SELECT id FROM inbound_detail + WHERE company_code = $1 AND item_number = $4 + ) + ORDER BY created_date ASC, package_label ASC`, + [ + params.companyCode, + params.warehouseCode || "", + params.locationCode || "", + params.itemCode, + ], + ); + + let remaining = params.qty; + const shippedLabels: string[] = []; + let partialLabel: string | null = null; + let partialBefore: number | null = null; + let partialAfter: number | null = null; + + for (const box of boxRows.rows) { + if (remaining <= 0) break; + const boxQty = parseFloat(box.quantity) || 0; + if (boxQty <= 0) continue; + + if (boxQty <= remaining) { + // 박스 전체 출고 + await client.query( + `UPDATE transaction_packaging + SET status = 'SHIPPED', updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [box.id, params.companyCode], + ); + shippedLabels.push(box.package_label); + remaining -= boxQty; + } else { + // 박스 부분 출고 — quantity 차감 (status STORED 유지) + const after = boxQty - remaining; + await client.query( + `UPDATE transaction_packaging + SET quantity = $1, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [after, box.id, params.companyCode], + ); + partialLabel = box.package_label; + partialBefore = boxQty; + partialAfter = after; + remaining = 0; + break; + } + } + + return { + shippedLabels, + partialLabel, + partialBefore, + partialAfter, + untrackedQty: remaining, // 박스 미보유분 (inventory_stock 만 차감되는 양) + }; +} + +/** + * 입고번호 내 다음 라벨 seq 번호 산출. + * 기존 `{inboundNumber}-P{4자리}` 라벨 중 MAX(P 뒤 4자리) + 1. + */ +async function nextLabelSeq( + client: PoolClient, + companyCode: string, + inboundNumber: string, +): Promise { + const prefix = `${inboundNumber}-P`; + const result = await client.query( + `SELECT package_label FROM transaction_packaging + WHERE company_code = $1 + AND package_label LIKE $2 + ORDER BY package_label DESC + LIMIT 1`, + [companyCode, `${prefix}%`], + ); + if (!result.rowCount) return 1; + const last = result.rows[0].package_label as string; + const tail = last.substring(prefix.length); + const n = parseInt(tail, 10); + return Number.isFinite(n) && n > 0 ? n + 1 : 1; +} + +function buildLabel(inboundNumber: string, seq: number): string { + const padded = String(seq).padStart(4, "0"); + return `${inboundNumber}-P${padded}`; +}