From aacbb62ad8af1347bd7e0145e4088b49739307f7 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 19 May 2026 11:25:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=9E=AC=EA=B4=80=EB=A6=AC=202?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=ED=92=80-CRUD=20+=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?(=EC=9E=90=EC=9E=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20+=20=EB=B6=88?= =?UTF-8?q?=EC=B6=9C=EC=9D=98=EB=A2=B0=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 테이블 5종 (운영 11133 → RPS 11134 DDL 1:1): inventory_mgmt / inventory_mgmt_in / inventory_mgmt_out / inventory_mgmt_out_master / inventory_mgmt_history - 백엔드 /api/inventory-mng — 리스트·재고등록·자재이동·삭제·이력 + 불출의뢰 생성·접수·자재불출(재고 차감)·삭제. 채번 Rfw-YYYY-seq. - 프론트 /COMPANY_16/material/{list, issue-request} + StockRegister / MaterialMove / IssueRequestCreate / InventoryHistory / IssueDispatch 다이얼로그 5종. - AdminPageRenderer 등록 + /material/ prefix. --- backend-node/src/app.ts | 2 + .../src/controllers/inventoryMngController.ts | 251 ++++++ backend-node/src/routes/inventoryMngRoutes.ts | 35 + .../src/services/inventoryMngService.ts | 767 ++++++++++++++++++ .../200_create_inventory_mgmt.sql | 31 + .../201_create_inventory_mgmt_in.sql | 33 + .../202_create_inventory_mgmt_out.sql | 28 + .../203_create_inventory_mgmt_out_master.sql | 32 + .../204_create_inventory_mgmt_history.sql | 18 + .../material/issue-request/page.tsx | 252 ++++++ .../(main)/COMPANY_16/material/list/page.tsx | 257 ++++++ .../components/layout/AdminPageRenderer.tsx | 3 + .../material/InventoryHistoryDialog.tsx | 101 +++ .../material/IssueDispatchDialog.tsx | 228 ++++++ .../material/IssueRequestCreateDialog.tsx | 184 +++++ .../material/MaterialMoveDialog.tsx | 163 ++++ .../material/StockRegisterDialog.tsx | 184 +++++ frontend/lib/api/inventoryMng.ts | 169 ++++ 18 files changed, 2738 insertions(+) create mode 100644 backend-node/src/controllers/inventoryMngController.ts create mode 100644 backend-node/src/routes/inventoryMngRoutes.ts create mode 100644 backend-node/src/services/inventoryMngService.ts create mode 100644 docs/migration/inventory/ddl-extracted/200_create_inventory_mgmt.sql create mode 100644 docs/migration/inventory/ddl-extracted/201_create_inventory_mgmt_in.sql create mode 100644 docs/migration/inventory/ddl-extracted/202_create_inventory_mgmt_out.sql create mode 100644 docs/migration/inventory/ddl-extracted/203_create_inventory_mgmt_out_master.sql create mode 100644 docs/migration/inventory/ddl-extracted/204_create_inventory_mgmt_history.sql create mode 100644 frontend/app/(main)/COMPANY_16/material/issue-request/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/material/list/page.tsx create mode 100644 frontend/components/material/InventoryHistoryDialog.tsx create mode 100644 frontend/components/material/IssueDispatchDialog.tsx create mode 100644 frontend/components/material/IssueRequestCreateDialog.tsx create mode 100644 frontend/components/material/MaterialMoveDialog.tsx create mode 100644 frontend/components/material/StockRegisterDialog.tsx create mode 100644 frontend/lib/api/inventoryMng.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b4e393ad..7d32187a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -122,6 +122,7 @@ import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인) import { planResultRouter as productionPlanResultRoutes, mbomReqRouter as productionMbomRequirementRoutes } from "./routes/productionPlanResultRoutes"; // 생산관리 4개 메뉴 (생산계획&실적, 소요량) import purchaseRoutes from "./routes/purchaseRoutes"; // 구매관리 7개 메뉴 (wace_plm 도메인) +import inventoryMngRoutes from "./routes/inventoryMngRoutes"; // 자재관리 — 자재리스트 + 불출의뢰서 (wace_plm inventoryMng 도메인) import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보 import qualityRoutes from "./routes/qualityRoutes"; // 품질관리 — 수입/공정/반제품 검사 (wace_plm 이식 1단계) import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 @@ -389,6 +390,7 @@ app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관 app.use("/api/production/plan-result", productionPlanResultRoutes); // 생산계획&실적관리 (메뉴1+2) app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes); // 반제품/원자재 소요량 (메뉴3+4) app.use("/api/purchase", purchaseRoutes); // 구매관리 7개 메뉴 (wace_plm 도메인) +app.use("/api/inventory-mng", inventoryMngRoutes); // 자재관리 — 자재리스트 + 불출의뢰서 app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징) app.use("/api/quality", qualityRoutes); // 품질관리 — 수입/공정/반제품 검사 app.use("/api/crawl", crawlRoutes); // 웹 크롤링 diff --git a/backend-node/src/controllers/inventoryMngController.ts b/backend-node/src/controllers/inventoryMngController.ts new file mode 100644 index 00000000..a27ddbe3 --- /dev/null +++ b/backend-node/src/controllers/inventoryMngController.ts @@ -0,0 +1,251 @@ +// ============================================================ +// 자재관리 — 자재리스트 + 불출의뢰서 컨트롤러 +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/inventoryMngService"; +import { logger } from "../utils/logger"; + +function userId(req: AuthenticatedRequest): string { + return (req as any)?.user?.user_id ?? (req as any)?.user?.username ?? "system"; +} + +// ── 자재리스트 ────────────────────────────────────────────── +export async function getInventoryList(req: AuthenticatedRequest, res: Response) { + try { + const q = req.query as Record; + const data = await svc.listInventory({ + ...q, + page: q.page ? Number(q.page) : undefined, + page_size: q.page_size ? Number(q.page_size) : undefined, + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자재리스트 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function postInventoryStock(req: AuthenticatedRequest, res: Response) { + try { + const body = req.body ?? {}; + if (!body.project_objid || !body.part_objid || !body.qty || !body.location) { + return res.status(400).json({ success: false, message: "필수 항목 누락" }); + } + const out = await svc.saveInventory({ + project_objid: body.project_objid, + unit: body.unit ?? "", + part_objid: body.part_objid, + qty: Number(body.qty), + price: body.price ?? "", + location: body.location, + sub_location: body.sub_location ?? "", + cls_cd: body.cls_cd ?? "", + cau_cd: body.cau_cd ?? "", + receipt_date: body.receipt_date ?? "", + writer: body.writer ?? userId(req), + order_objid: body.order_objid ?? "", + }); + return res.json({ success: true, data: out }); + } catch (e: any) { + logger.error("재고 등록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function deleteInventoryStock(req: AuthenticatedRequest, res: Response) { + try { + const objids: string[] = Array.isArray(req.body?.objids) + ? req.body.objids + : String(req.body?.checkArr ?? "").split(",").filter(Boolean); + if (!objids.length) return res.status(400).json({ success: false, message: "삭제 대상 없음" }); + const data = await svc.deleteInventory(objids); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("재고 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function postInventoryMove(req: AuthenticatedRequest, res: Response) { + try { + const items = req.body?.items; + if (!Array.isArray(items) || !items.length) { + return res.status(400).json({ success: false, message: "이동 항목 없음" }); + } + const writer = req.body?.writer ?? userId(req); + const data = await svc.moveInventoryBulk(items.map((it: any) => ({ ...it, writer }))); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자재이동 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getInventoryHistory(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? ""); + if (!objid) return res.status(400).json({ success: false, message: "objid 없음" }); + const data = await svc.getInventoryHistory(objid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자재이력 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ── 불출의뢰서 ───────────────────────────────────────────── +export async function getIssueRequestList(req: AuthenticatedRequest, res: Response) { + try { + const q = req.query as Record; + const data = await svc.listIssueRequest({ + ...q, + page: q.page ? Number(q.page) : undefined, + page_size: q.page_size ? Number(q.page_size) : undefined, + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("불출의뢰서 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getIssueRequestDetail(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? ""); + if (!objid) return res.status(400).json({ success: false, message: "objid 없음" }); + const data = await svc.getIssueRequest(objid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("불출의뢰 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getIssueRequestCandidates(req: AuthenticatedRequest, res: Response) { + try { + const ids: string[] = Array.isArray(req.body?.parent_objids) + ? req.body.parent_objids + : String(req.query.parent_objids ?? "").split(",").filter(Boolean); + const data = await svc.getIssueRequestCandidates(ids); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("불출 후보 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function postIssueRequest(req: AuthenticatedRequest, res: Response) { + try { + const body = req.body ?? {}; + if (!Array.isArray(body.lines) || !body.lines.length) { + return res.status(400).json({ success: false, message: "라인이 비어있음" }); + } + const writer = body.writer ?? userId(req); + const data = await svc.saveIssueRequest({ + master_objid: body.master_objid ?? body.INVENTORY_REQUEST_MASTER_OBJID, + contract_mgmt_objid: body.contract_mgmt_objid ?? "", + request_date: body.request_date ?? "", + request_id: body.request_id ?? writer, + remark: body.remark ?? "", + writer, + lines: body.lines.map((l: any) => ({ + parent_objid: l.parent_objid ?? l.OBJID, + request_qty: Number(l.request_qty ?? l.REQUEST_QTY ?? 0), + unit: l.unit ?? l.unit_code ?? "", + })), + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("불출의뢰 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function deleteIssueRequest(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? ""); + if (!objid) return res.status(400).json({ success: false, message: "objid 없음" }); + await svc.deleteIssueRequest(objid); + return res.json({ success: true }); + } catch (e: any) { + logger.error("불출의뢰 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function postIssueRequestReceive(req: AuthenticatedRequest, res: Response) { + try { + const ids: string[] = Array.isArray(req.body?.objids) + ? req.body.objids + : String(req.body?.checkArr ?? "").split(",").filter(Boolean); + if (!ids.length) return res.status(400).json({ success: false, message: "접수 대상 없음" }); + const data = await svc.receiveIssueRequest(ids, userId(req)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("불출의뢰 접수 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function postIssueRequestDispatch(req: AuthenticatedRequest, res: Response) { + try { + const body = req.body ?? {}; + if (!body.master_objid || !Array.isArray(body.lines) || !body.lines.length) { + return res.status(400).json({ success: false, message: "필수 항목 누락" }); + } + const writer = body.writer ?? userId(req); + const data = await svc.dispatchIssueRequest({ + master_objid: body.master_objid, + lines: body.lines.map((l: any) => ({ + objid: l.objid, + out_qty: Number(l.out_qty ?? 0), + out_date: l.out_date, + acq_user: l.acq_user, + sign: l.sign, + writer, + })), + }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자재불출 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ── 옵션 ─────────────────────────────────────────────────── +export async function getProjectOptions(_req: AuthenticatedRequest, res: Response) { + try { + return res.json({ success: true, data: await svc.listProjectOptions() }); + } catch (e: any) { + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getUnitOptions(req: AuthenticatedRequest, res: Response) { + try { + const code = String(req.query.contract_objid ?? req.query.project_objid ?? ""); + return res.json({ success: true, data: await svc.listUnitOptions(code) }); + } catch (e: any) { + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getUserOptions(_req: AuthenticatedRequest, res: Response) { + try { + return res.json({ success: true, data: await svc.listUserOptions() }); + } catch (e: any) { + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getPartOptions(req: AuthenticatedRequest, res: Response) { + try { + const kw = String(req.query.q ?? req.query.keyword ?? ""); + const limit = Math.min(100, Number(req.query.limit ?? 30)); + return res.json({ success: true, data: await svc.listPartOptions(kw, limit) }); + } catch (e: any) { + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/inventoryMngRoutes.ts b/backend-node/src/routes/inventoryMngRoutes.ts new file mode 100644 index 00000000..53ed70bc --- /dev/null +++ b/backend-node/src/routes/inventoryMngRoutes.ts @@ -0,0 +1,35 @@ +// ============================================================ +// 자재관리 라우트 — 자재리스트(/list) + 불출의뢰서(/issue-request) +// app.ts: app.use("/api/inventory-mng", inventoryMngRoutes) +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/inventoryMngController"; + +const router = Router(); +router.use(authenticateToken); + +// 자재리스트 +router.get("/list", ctrl.getInventoryList); +router.post("/stock", ctrl.postInventoryStock); +router.post("/stock/delete", ctrl.deleteInventoryStock); +router.post("/move", ctrl.postInventoryMove); +router.get("/history/:objid", ctrl.getInventoryHistory); + +// 불출의뢰서 +router.get("/issue-request", ctrl.getIssueRequestList); +router.get("/issue-request/:objid", ctrl.getIssueRequestDetail); +router.post("/issue-request/candidates", ctrl.getIssueRequestCandidates); +router.post("/issue-request", ctrl.postIssueRequest); +router.delete("/issue-request/:objid", ctrl.deleteIssueRequest); +router.post("/issue-request/receive", ctrl.postIssueRequestReceive); +router.post("/issue-request/dispatch", ctrl.postIssueRequestDispatch); + +// 공통 옵션 +router.get("/options/projects", ctrl.getProjectOptions); +router.get("/options/units", ctrl.getUnitOptions); +router.get("/options/users", ctrl.getUserOptions); +router.get("/options/parts", ctrl.getPartOptions); + +export default router; diff --git a/backend-node/src/services/inventoryMngService.ts b/backend-node/src/services/inventoryMngService.ts new file mode 100644 index 00000000..90b01df6 --- /dev/null +++ b/backend-node/src/services/inventoryMngService.ts @@ -0,0 +1,767 @@ +// ============================================================ +// 자재관리 — 자재리스트 + 불출의뢰서 서비스 +// wace_plm inventoryMng.xml + InventoryMngService.java 1:1 베이스 +// +// 테이블: +// inventory_mgmt (자재 마스터: contract_objid+unit+part_objid PK, objid는 별도) +// inventory_mgmt_in (입고/이동 라인: parent_objid → inventory_mgmt.objid) +// inventory_mgmt_out (불출 라인: parent_objid → inventory_mgmt.objid, +// inventory_request_master_objid → inventory_mgmt_out_master.objid) +// inventory_mgmt_out_master (불출의뢰 마스터: inventory_out_no = Rfw-YYYY-seq) +// inventory_mgmt_history (자재 투입 이력 — 본 메뉴에서는 조회용) +// +// 보유수량 공식: +// USE_CNT = SUM(receipt_qty - move_qty) FROM inventory_mgmt_in WHERE parent=IM.objid +// - SUM(request_qty) FROM inventory_mgmt_out WHERE parent=IM.objid +// +// 불출 흐름: 의뢰(request_qty) → 접수(reception_status=reception) → 불출(out_qty, outstatus=complete) +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +export interface InventoryListFilter { + project_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + part_type?: string; + location?: string; + cls_cd?: string; + cau_cd?: string; + writer?: string; + page?: number; + page_size?: number; +} + +export interface IssueRequestFilter { + part_no?: string; + part_name?: string; + request_start_date?: string; + request_end_date?: string; + request_user?: string; + reception_status?: string; + reception_user?: string; + reception_start_date?: string; + reception_end_date?: string; + out_status?: string; + page?: number; + page_size?: number; +} + +interface ListResult { + rows: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +function paging(f: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(f.page ?? 1)); + const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50))); + return { page, pageSize, limit: pageSize, offset: (page - 1) * pageSize }; +} + +// ─── 자재리스트 그리드 ───────────────────────────────────────── +// wace `inventoryMngNewGridList` 매퍼 1:1 베이스 +// USE_CNT = inbound − move − out_request (각 부모 행 단위 합산) +// USE_CNT_ALL = 같은 part_objid 전체 가상 합계 (윈도우 함수) +// REQUEST_QTY = 누적 불출의뢰 수량 +// LOCATION_NAME = inventory_mgmt_in.location distinct 목록 (콤마) +export async function listInventory(filter: InventoryListFilter): Promise> { + const pool = getPool(); + const { page, pageSize, limit, offset } = paging(filter); + + const where: string[] = []; + const params: any[] = []; + const addP = (v: any) => { params.push(v); return `$${params.length}`; }; + + if (filter.project_objid) where.push(`IM.contract_objid = ${addP(filter.project_objid)}`); + if (filter.unit_code) where.push(`IM.unit = ${addP(filter.unit_code)}`); + if (filter.part_no) where.push(`P.part_no ILIKE ${addP(`%${filter.part_no}%`)}`); + if (filter.part_name) where.push(`P.part_name ILIKE ${addP(`%${filter.part_name}%`)}`); + if (filter.part_type) where.push(`P.part_type = ${addP(filter.part_type)}`); + if (filter.location) where.push(`IM.location = ${addP(filter.location)}`); + if (filter.cls_cd) where.push(`IM.cls_cd = ${addP(filter.cls_cd)}`); + if (filter.cau_cd) where.push(`IM.cau_cd = ${addP(filter.cau_cd)}`); + if (filter.writer) where.push(`IM.writer = ${addP(filter.writer)}`); + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const dataSql = ` + WITH IN_SUM AS ( + SELECT parent_objid, + SUM(COALESCE(NULLIF(receipt_qty,'')::numeric,0) - COALESCE(NULLIF(move_qty,'')::numeric,0)) AS in_qty + FROM inventory_mgmt_in + GROUP BY parent_objid + ), + OUT_SUM AS ( + SELECT parent_objid, + SUM(COALESCE(NULLIF(request_qty,'')::numeric,0)) AS req_qty, + SUM(COALESCE(NULLIF(out_qty,'')::numeric,0)) AS out_qty + FROM inventory_mgmt_out + GROUP BY parent_objid + ), + LOC_LIST AS ( + SELECT parent_objid, STRING_AGG(DISTINCT NULLIF(location,''), ',') AS loc_names + FROM inventory_mgmt_in + GROUP BY parent_objid + ) + SELECT + IM.objid AS objid, + IM.contract_objid AS contract_objid, + COALESCE(PJ.project_no, CT.contract_no, '') AS project_no, + IM.unit AS unit, + COALESCE(WT.task_name, '') AS unit_name, + IM.part_objid AS part_objid, + P.part_no AS part_no, + P.part_name AS part_name, + P.material AS material, + P.spec AS spec, + P.part_type AS part_type, + P.part_type AS part_type_name, + IM.cls_cd AS cls_cd, + IM.cau_cd AS cau_cd, + IM.location AS location, + IM.sub_location AS sub_location, + COALESCE(LL.loc_names, IM.location, '') AS location_name, + IM.reg_date AS reg_date, + IM.price AS price, + IM.writer AS writer, + COALESCE(user_name(IM.writer), IM.writer, '') AS writer_name, + COALESCE(IS_.in_qty, 0) - COALESCE(OS.req_qty, 0) AS use_cnt, + COALESCE(OS.req_qty, 0) AS request_qty, + COALESCE(OS.out_qty, 0) AS out_qty_total, + SUM(COALESCE(IS_.in_qty, 0) - COALESCE(OS.req_qty, 0)) + OVER (PARTITION BY IM.part_objid) AS use_cnt_all, + '' AS remark + FROM inventory_mgmt IM + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + LEFT JOIN contract_mgmt CT ON CT.objid = IM.contract_objid + LEFT JOIN project_mgmt PJ ON PJ.contract_objid = IM.contract_objid + LEFT JOIN pms_wbs_task WT ON WT.contract_objid = IM.contract_objid AND WT.task_seq = IM.unit + LEFT JOIN IN_SUM IS_ ON IS_.parent_objid = IM.objid + LEFT JOIN OUT_SUM OS ON OS.parent_objid = IM.objid + LEFT JOIN LOC_LIST LL ON LL.parent_objid = IM.objid + ${whereSql} + ORDER BY IM.reg_date DESC NULLS LAST, IM.objid DESC + LIMIT ${addP(limit)} OFFSET ${addP(offset)} + `; + + const countSql = ` + SELECT COUNT(*)::int AS cnt + FROM inventory_mgmt IM + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + ${whereSql} + `; + try { + const [d, c] = await Promise.all([ + pool.query(dataSql, params), + pool.query(countSql, params.slice(0, params.length - 2)), + ]); + return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize }; + } catch (e: any) { + logger.error("listInventory 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } +} + +// ─── 재고 등록 (자재 신규/추가입고) ─────────────────────────── +// 1) inventory_mgmt: (contract_objid+unit+part_objid) UNIQUE → 존재 시 재사용, 없으면 INSERT +// 2) inventory_mgmt_in: 입고 1건 INSERT +export interface SaveInventoryInput { + project_objid: string; // contract_objid + unit?: string; + part_objid: string; + qty: number; + price?: string; + location: string; + sub_location?: string; + cls_cd?: string; + cau_cd?: string; + receipt_date?: string; + writer?: string; + order_objid?: string; +} +export async function saveInventory(input: SaveInventoryInput): Promise<{ objid: string; in_objid: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const probe = await client.query( + `SELECT objid FROM inventory_mgmt + WHERE contract_objid = $1 AND unit = $2 AND part_objid = $3 + LIMIT 1`, + [input.project_objid, input.unit ?? "", input.part_objid], + ); + let objid = probe.rows[0]?.objid as string | undefined; + if (!objid) { + objid = createObjId(); + await client.query( + `INSERT INTO inventory_mgmt + (objid, contract_objid, unit, part_objid, cls_cd, cau_cd, qty, location, sub_location, + reg_date, price, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, TO_CHAR(NOW(),'YYYY-MM-DD'), $10, $11)`, + [objid, input.project_objid, input.unit ?? "", input.part_objid, + input.cls_cd ?? "", input.cau_cd ?? "", String(input.qty), + input.location, input.sub_location ?? "", + input.price ?? "", input.writer ?? ""], + ); + } + const inObjid = createObjId(); + await client.query( + `INSERT INTO inventory_mgmt_in + (objid, parent_objid, receipt_qty, location, sub_location, writer, regdate, + contract_mgmt_objid, purchase_order_master_objid, receipt_date) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, + [inObjid, objid, String(input.qty), input.location, input.sub_location ?? "", + input.writer ?? "", input.project_objid, + input.order_objid ?? "", input.receipt_date ?? new Date().toISOString().slice(0, 10)], + ); + await client.query("COMMIT"); + return { objid, in_objid: inObjid }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ─── 재고 삭제 ───────────────────────────────────────────── +export async function deleteInventory(objidArr: string[]): Promise<{ deleted: number }> { + if (!objidArr.length) return { deleted: 0 }; + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM inventory_mgmt_in WHERE parent_objid = ANY($1::varchar[])`, [objidArr]); + await client.query(`DELETE FROM inventory_mgmt_out WHERE parent_objid = ANY($1::varchar[])`, [objidArr]); + const r = await client.query(`DELETE FROM inventory_mgmt WHERE objid = ANY($1::varchar[])`, [objidArr]); + await client.query("COMMIT"); + return { deleted: r.rowCount ?? 0 }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ─── 자재이동: inventory_mgmt_in 행에 move_qty/move_date/move_user 누적 ── +export interface MoveInventoryInput { + in_objid: string; + move_qty: number; + location?: string; + sub_location?: string; + move_date?: string; + move_user?: string; + writer?: string; +} +export async function moveInventoryBulk(items: MoveInventoryInput[]): Promise<{ updated: number }> { + if (!items.length) return { updated: 0 }; + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const it of items) { + const prev = await client.query( + `SELECT COALESCE(NULLIF(move_qty,''),'0')::numeric AS mq, parent_objid + FROM inventory_mgmt_in WHERE objid = $1`, + [it.in_objid], + ); + const parent = prev.rows[0]?.parent_objid as string | undefined; + if (!parent) continue; + const newMove = Number(prev.rows[0].mq) + Number(it.move_qty); + await client.query( + `UPDATE inventory_mgmt_in + SET move_qty = $1, + move_date = COALESCE($2, move_date), + move_user = COALESCE($3, move_user) + WHERE objid = $4`, + [String(newMove), it.move_date ?? null, it.move_user ?? null, it.in_objid], + ); + // 이동 이력 라인 추가 + await client.query( + `INSERT INTO inventory_mgmt_in + (objid, parent_objid, receipt_qty, location, sub_location, writer, regdate, + move_qty, move_date, move_user) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, + [createObjId(), parent, String(it.move_qty), + it.location ?? "", it.sub_location ?? "", + it.writer ?? it.move_user ?? "", + String(it.move_qty), it.move_date ?? null, it.move_user ?? null], + ); + } + await client.query("COMMIT"); + return { updated: items.length }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ─── 자재 입출고 이력 ───────────────────────────────────────── +export async function getInventoryHistory(parentObjid: string): Promise { + const pool = getPool(); + const sql = ` + -- 입고 + SELECT + II.objid AS objid, + P.part_no AS part_no, + P.part_name AS part_name, + '입고' AS gubun, + COALESCE(II.receipt_qty, '') AS qty, + II.location AS location_name, + II.sub_location AS sub_location_name, + II.regdate AS regdate, + II.writer AS writer, + COALESCE(user_name(II.writer), II.writer,'') AS writer_name + FROM inventory_mgmt_in II + LEFT JOIN inventory_mgmt IM ON IM.objid = II.parent_objid + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE II.parent_objid = $1 AND COALESCE(II.move_date, '') = '' + + UNION ALL + + -- 이동 + SELECT + II.objid, P.part_no, P.part_name, '이동', + COALESCE(II.move_qty, II.receipt_qty, ''), + II.location, II.sub_location, II.regdate, + II.move_user, + COALESCE(user_name(II.move_user), II.move_user, '') + FROM inventory_mgmt_in II + LEFT JOIN inventory_mgmt IM ON IM.objid = II.parent_objid + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE II.parent_objid = $1 AND COALESCE(II.move_date, '') <> '' + + UNION ALL + + -- 출고 + SELECT + IO.objid, P.part_no, P.part_name, '출고', + COALESCE(IO.out_qty, IO.request_qty, ''), + '', '', IO.regdate, + IO.writer, + COALESCE(user_name(IO.writer), IO.writer, '') + FROM inventory_mgmt_out IO + LEFT JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE IO.parent_objid = $1 + + ORDER BY regdate DESC NULLS LAST + `; + const r = await pool.query(sql, [parentObjid]); + return r.rows; +} + +// ─── 불출의뢰서 그리드 ───────────────────────────────────────── +export async function listIssueRequest(filter: IssueRequestFilter): Promise> { + const pool = getPool(); + const { page, pageSize, limit, offset } = paging(filter); + + const where: string[] = []; + const params: any[] = []; + const addP = (v: any) => { params.push(v); return `$${params.length}`; }; + + if (filter.request_user) where.push(`OM.request_id = ${addP(filter.request_user)}`); + if (filter.reception_user) where.push(`OM.reception_id = ${addP(filter.reception_user)}`); + if (filter.reception_status) { + if (filter.reception_status === "reception") where.push(`OM.reception_status = 'reception'`); + else if (filter.reception_status === "AA") where.push(`COALESCE(OM.reception_status,'') <> 'reception'`); + } + if (filter.out_status) { + if (filter.out_status === "complete") where.push(`OM.outstatus = 'complete'`); + else if (filter.out_status === "NG") where.push(`COALESCE(OM.outstatus,'') <> 'complete'`); + } + if (filter.request_start_date) where.push(`OM.request_date >= ${addP(filter.request_start_date)}`); + if (filter.request_end_date) where.push(`OM.request_date <= ${addP(filter.request_end_date)}`); + if (filter.reception_start_date) where.push(`OM.reception_date >= ${addP(filter.reception_start_date)}`); + if (filter.reception_end_date) where.push(`OM.reception_date <= ${addP(filter.reception_end_date)}`); + + if (filter.part_no) { + where.push(`EXISTS ( + SELECT 1 FROM inventory_mgmt_out IO + JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid + JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE IO.inventory_request_master_objid = OM.objid + AND P.part_no ILIKE ${addP(`%${filter.part_no}%`)} + )`); + } + if (filter.part_name) { + where.push(`EXISTS ( + SELECT 1 FROM inventory_mgmt_out IO + JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid + JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE IO.inventory_request_master_objid = OM.objid + AND P.part_name ILIKE ${addP(`%${filter.part_name}%`)} + )`); + } + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const dataSql = ` + SELECT + OM.objid AS objid, + OM.inventory_out_no AS inventory_out_no, + OM.request_date AS request_date, + OM.request_id AS request_id, + COALESCE(user_name(OM.request_id), OM.request_id,'') AS request_user_name, + OM.reception_status AS reception_status, + CASE WHEN OM.reception_status='reception' THEN '접수' ELSE '미접수' END AS reception_status_title, + OM.reception_id AS reception_id, + COALESCE(user_name(OM.reception_id), OM.reception_id,'') AS reception_user_name, + OM.reception_date AS reception_date, + OM.outstatus AS outstatus, + CASE WHEN OM.outstatus='complete' THEN '완료' ELSE '미완료' END AS outstatus_title, + OM.remark AS remark, + OM.writer AS writer, + OM.regdate AS regdate, + COALESCE(( + SELECT STRING_AGG(P.part_no, ',' ORDER BY P.part_no) + FROM inventory_mgmt_out IO + JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid + JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE IO.inventory_request_master_objid = OM.objid + ), '') AS part_no_arr, + COALESCE(( + SELECT STRING_AGG(P.part_name, ',' ORDER BY P.part_no) + FROM inventory_mgmt_out IO + JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid + JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE IO.inventory_request_master_objid = OM.objid + ), '') AS part_name_arr, + COALESCE(( + SELECT SUM(COALESCE(NULLIF(IO.request_qty,'')::numeric,0)) + FROM inventory_mgmt_out IO + WHERE IO.inventory_request_master_objid = OM.objid + ), 0) AS request_qty_total, + COALESCE(( + SELECT SUM(COALESCE(NULLIF(IO.out_qty,'')::numeric,0)) + FROM inventory_mgmt_out IO + WHERE IO.inventory_request_master_objid = OM.objid + ), 0) AS out_qty_total + FROM inventory_mgmt_out_master OM + ${whereSql} + ORDER BY OM.regdate DESC NULLS LAST, OM.objid DESC + LIMIT ${addP(limit)} OFFSET ${addP(offset)} + `; + const countSql = `SELECT COUNT(*)::int AS cnt FROM inventory_mgmt_out_master OM ${whereSql}`; + + try { + const [d, c] = await Promise.all([ + pool.query(dataSql, params), + pool.query(countSql, params.slice(0, params.length - 2)), + ]); + return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize }; + } catch (e: any) { + logger.error("listIssueRequest 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } +} + +// ─── 불출의뢰 상세: 마스터 + 라인 ──────────────────────────── +export async function getIssueRequest(masterObjid: string): Promise<{ master: any; lines: any[] }> { + const pool = getPool(); + const master = await pool.query( + `SELECT OM.*, + COALESCE(user_name(OM.request_id), OM.request_id, '') AS request_user_name, + COALESCE(user_name(OM.reception_id),OM.reception_id,'') AS reception_user_name + FROM inventory_mgmt_out_master OM + WHERE OM.objid = $1`, + [masterObjid], + ); + const lines = await pool.query( + `SELECT + IO.objid AS objid, + IO.parent_objid AS parent_objid, + IO.request_qty AS request_qty, + IO.out_qty AS out_qty, + IO.out_date AS out_date, + IO.writer AS writer, + COALESCE(user_name(IO.writer), IO.writer, '') AS writer_name, + IO.acq_user AS acq_user, + COALESCE(user_name(IO.acq_user), IO.acq_user, '') AS acq_user_name, + IO.sign AS sign, + IO.unit AS unit, + IO.contract_mgmt_objid AS contract_mgmt_objid, + IM.location AS location, + IM.sub_location AS sub_location, + IM.part_objid AS part_objid, + P.part_no AS part_no, + P.part_name AS part_name, + P.material AS material, + P.spec AS spec, + P.part_type AS part_type + FROM inventory_mgmt_out IO + LEFT JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + WHERE IO.inventory_request_master_objid = $1 + ORDER BY P.part_no NULLS LAST, IO.objid`, + [masterObjid], + ); + return { master: master.rows[0] ?? null, lines: lines.rows }; +} + +// ─── 불출 가능 자재 후보 (자재리스트에서 선택한 OBJID들 기반) ──── +export async function getIssueRequestCandidates(parentObjids: string[]): Promise { + if (!parentObjids.length) return []; + const pool = getPool(); + const r = await pool.query( + `SELECT + IM.objid AS objid, + IM.contract_objid AS contract_objid, + COALESCE(PJ.project_no, CT.contract_no, '') AS project_no, + IM.unit AS unit, + COALESCE(WT.task_name, '') AS unit_name, + P.part_no AS part_no, + P.part_name AS part_name, + P.material AS material, + P.spec AS spec, + P.part_type AS part_type_name, + IM.location AS location, + IM.sub_location AS sub_location, + ( + COALESCE((SELECT SUM( + COALESCE(NULLIF(receipt_qty,'')::numeric,0) + - COALESCE(NULLIF(move_qty,'')::numeric,0)) + FROM inventory_mgmt_in WHERE parent_objid = IM.objid), 0) + - COALESCE((SELECT SUM(COALESCE(NULLIF(request_qty,'')::numeric,0)) + FROM inventory_mgmt_out WHERE parent_objid = IM.objid), 0) + ) AS use_cnt + FROM inventory_mgmt IM + LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid + LEFT JOIN contract_mgmt CT ON CT.objid = IM.contract_objid + LEFT JOIN project_mgmt PJ ON PJ.contract_objid = IM.contract_objid + LEFT JOIN pms_wbs_task WT ON WT.contract_objid = IM.contract_objid AND WT.task_seq = IM.unit + WHERE IM.objid = ANY($1::varchar[])`, + [parentObjids], + ); + return r.rows; +} + +// ─── 불출의뢰 생성/수정 ─────────────────────────────────────── +export interface IssueRequestSaveInput { + master_objid?: string; // 수정 시 기존 키 + contract_mgmt_objid?: string; + request_date?: string; + request_id?: string; + remark?: string; + writer: string; + lines: Array<{ + parent_objid: string; + request_qty: number; + unit?: string; + }>; +} +export async function saveIssueRequest(input: IssueRequestSaveInput): Promise<{ master_objid: string; inventory_out_no: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const today = new Date().toISOString().slice(0, 10); + let masterObjid = input.master_objid; + let inventoryOutNo = ""; + + if (masterObjid) { + await client.query( + `UPDATE inventory_mgmt_out_master + SET remark = COALESCE($1, remark), + request_date = COALESCE($2, request_date), + contract_mgmt_objid = COALESCE($3, contract_mgmt_objid) + WHERE objid = $4`, + [input.remark ?? null, input.request_date ?? today, input.contract_mgmt_objid ?? null, masterObjid], + ); + const m = await client.query( + `SELECT inventory_out_no FROM inventory_mgmt_out_master WHERE objid = $1`, + [masterObjid], + ); + inventoryOutNo = m.rows[0]?.inventory_out_no ?? ""; + await client.query( + `DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`, + [masterObjid], + ); + } else { + masterObjid = createObjId(); + const seq = await client.query( + `SELECT 'Rfw-' || TO_CHAR(NOW(),'YYYY') || '-' || + (COALESCE(MAX(NULLIF(SPLIT_PART(inventory_out_no, '-', 3),'')::int), 0) + 1)::text + AS no + FROM inventory_mgmt_out_master + WHERE inventory_out_no LIKE 'Rfw-' || TO_CHAR(NOW(),'YYYY') || '-%'`, + ); + inventoryOutNo = seq.rows[0]?.no ?? `Rfw-${new Date().getFullYear()}-1`; + await client.query( + `INSERT INTO inventory_mgmt_out_master + (objid, inventory_out_no, request_date, request_id, writer, regdate, remark, contract_mgmt_objid) + VALUES ($1, $2, $3, $4, $5, NOW(), $6, $7)`, + [masterObjid, inventoryOutNo, input.request_date ?? today, + input.request_id ?? input.writer, input.writer, input.remark ?? "", + input.contract_mgmt_objid ?? ""], + ); + } + + for (const ln of input.lines) { + if (!ln.parent_objid || !ln.request_qty || ln.request_qty <= 0) continue; + const ioObjid = createObjId(); + await client.query( + `INSERT INTO inventory_mgmt_out + (objid, parent_objid, request_qty, writer, regdate, + inventory_request_master_objid, contract_mgmt_objid, unit) + VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7)`, + [ioObjid, ln.parent_objid, String(ln.request_qty), input.writer, + masterObjid, input.contract_mgmt_objid ?? "", ln.unit ?? ""], + ); + } + await client.query("COMMIT"); + return { master_objid: masterObjid!, inventory_out_no: inventoryOutNo }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ─── 불출의뢰 삭제 ───────────────────────────────────────── +export async function deleteIssueRequest(masterObjid: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`, [masterObjid]); + await client.query(`DELETE FROM inventory_mgmt_out_master WHERE objid = $1`, [masterObjid]); + await client.query("COMMIT"); + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ─── 접수 처리 ──────────────────────────────────────────── +export async function receiveIssueRequest(objids: string[], receptionId: string): Promise<{ updated: number }> { + if (!objids.length) return { updated: 0 }; + const pool = getPool(); + const today = new Date().toISOString().slice(0, 10); + const r = await pool.query( + `UPDATE inventory_mgmt_out_master + SET reception_status = 'reception', + reception_date = $1, + reception_id = $2 + WHERE objid = ANY($3::varchar[]) + AND COALESCE(reception_status,'') <> 'reception'`, + [today, receptionId, objids], + ); + return { updated: r.rowCount ?? 0 }; +} + +// ─── 자재불출 처리 (실제 불출 + 재고 차감) ────────────────── +export interface DispatchInput { + master_objid: string; + lines: Array<{ + objid: string; // inventory_mgmt_out.objid + out_qty: number; + out_date: string; + acq_user: string; + sign?: string; + writer?: string; + }>; +} +export async function dispatchIssueRequest(input: DispatchInput): Promise<{ updated: number }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + for (const ln of input.lines) { + await client.query( + `UPDATE inventory_mgmt_out + SET out_qty = $1, + out_date = $2, + acq_user = $3, + sign = COALESCE($4, sign), + writer = COALESCE($5, writer) + WHERE objid = $6`, + [String(ln.out_qty), ln.out_date, ln.acq_user, ln.sign ?? null, ln.writer ?? null, ln.objid], + ); + } + await client.query( + `UPDATE inventory_mgmt_out_master + SET outstatus = 'complete' + WHERE objid = $1`, + [input.master_objid], + ); + await client.query("COMMIT"); + return { updated: input.lines.length }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ─── 옵션: 프로젝트 / 유닛 / Location / 사용자 ────────────── +export async function listProjectOptions(): Promise> { + const pool = getPool(); + const r = await pool.query( + `SELECT DISTINCT PM.contract_objid AS code, + COALESCE(PM.project_no, CT.contract_no, PM.contract_objid) AS label + FROM project_mgmt PM + LEFT JOIN contract_mgmt CT ON CT.objid = PM.contract_objid + WHERE PM.contract_objid IS NOT NULL + ORDER BY label`, + ); + return r.rows; +} + +export async function listUnitOptions(contractObjid: string): Promise> { + if (!contractObjid) return []; + const pool = getPool(); + const r = await pool.query( + `SELECT task_seq AS code, task_name AS label + FROM pms_wbs_task + WHERE contract_objid = $1 AND COALESCE(task_seq,'') <> '' + ORDER BY task_seq`, + [contractObjid], + ); + return r.rows; +} + +export async function listUserOptions(): Promise> { + const pool = getPool(); + const r = await pool.query( + `SELECT user_id AS code, COALESCE(user_name, user_id) AS label + FROM user_info + WHERE COALESCE(status,'') <> 'inactive' + ORDER BY label`, + ); + return r.rows; +} + +export async function listPartOptions(keyword: string, limit: number = 30): Promise> { + const pool = getPool(); + const kw = keyword?.trim() ?? ""; + const params: any[] = []; + const conds: string[] = []; + if (kw) { + params.push(`%${kw}%`); + conds.push(`(part_no ILIKE $1 OR part_name ILIKE $1)`); + } + params.push(limit); + const r = await pool.query( + `SELECT objid::varchar AS code, part_no AS label, part_name + FROM part_mng + ${conds.length ? `WHERE ${conds.join(" AND ")}` : ""} + ORDER BY part_no + LIMIT $${params.length}`, + params, + ); + return r.rows; +} diff --git a/docs/migration/inventory/ddl-extracted/200_create_inventory_mgmt.sql b/docs/migration/inventory/ddl-extracted/200_create_inventory_mgmt.sql new file mode 100644 index 00000000..4df12cd6 --- /dev/null +++ b/docs/migration/inventory/ddl-extracted/200_create_inventory_mgmt.sql @@ -0,0 +1,31 @@ +-- ==================================================================== +-- inventory_mgmt — 자재 마스터 (품번 + Location 단위 자재 정보) +-- ==================================================================== +-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm (PG 16.8) +-- 추출일: 2026-05-15 +-- 자식: inventory_mgmt_in (입고/이동 히스토리), inventory_mgmt_out (불출 라인) +-- ==================================================================== + +CREATE TABLE IF NOT EXISTS inventory_mgmt ( + objid VARCHAR NOT NULL, + contract_objid VARCHAR NOT NULL, + unit VARCHAR(100) NOT NULL, + part_objid VARCHAR(100) NOT NULL, + cls_cd VARCHAR(100), + cau_cd VARCHAR(100), + qty VARCHAR(20), + location VARCHAR(20) NOT NULL DEFAULT '', + sub_location VARCHAR(20) NOT NULL DEFAULT '', + reg_date VARCHAR(10), + price VARCHAR(20), + writer VARCHAR(20), + input_contract_objid VARCHAR, + input_qty VARCHAR, + input_date VARCHAR, + assumption_user VARCHAR, + successor_user VARCHAR, + CONSTRAINT inventory_mgmt_pkey PRIMARY KEY (contract_objid, unit, part_objid) +); + +CREATE INDEX IF NOT EXISTS inventory_mgmt_objid_idx ON inventory_mgmt (objid); +CREATE INDEX IF NOT EXISTS inventory_mgmt_part_objid_idx ON inventory_mgmt (part_objid); diff --git a/docs/migration/inventory/ddl-extracted/201_create_inventory_mgmt_in.sql b/docs/migration/inventory/ddl-extracted/201_create_inventory_mgmt_in.sql new file mode 100644 index 00000000..f2f8f80a --- /dev/null +++ b/docs/migration/inventory/ddl-extracted/201_create_inventory_mgmt_in.sql @@ -0,0 +1,33 @@ +-- ==================================================================== +-- inventory_mgmt_in — 자재 입고/이동 히스토리 라인 +-- ==================================================================== +-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm +-- 추출일: 2026-05-15 +-- 부모: inventory_mgmt.objid → parent_objid +-- 외부키: out_objid (콤마분리 inventory_mgmt_out.objid 누적) +-- ==================================================================== + +CREATE TABLE IF NOT EXISTS inventory_mgmt_in ( + objid VARCHAR NOT NULL, + parent_objid VARCHAR, + receipt_qty VARCHAR, + location VARCHAR, + sub_location VARCHAR, + writer VARCHAR, + regdate TIMESTAMP, + contract_mgmt_objid VARCHAR, + purchase_order_master_objid VARCHAR, + purchase_order_sub_objid VARCHAR, + out_objid VARCHAR, + out_qty VARCHAR, + move_objid VARCHAR, + move_qty VARCHAR, + move_date VARCHAR, + move_user VARCHAR, + request_qty VARCHAR, + receipt_date VARCHAR, + CONSTRAINT inventory_mgmt_in_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS inventory_mgmt_in_parent_objid_idx ON inventory_mgmt_in (parent_objid); +CREATE INDEX IF NOT EXISTS inventory_mgmt_in_contract_mgmt_objid_idx ON inventory_mgmt_in (contract_mgmt_objid); diff --git a/docs/migration/inventory/ddl-extracted/202_create_inventory_mgmt_out.sql b/docs/migration/inventory/ddl-extracted/202_create_inventory_mgmt_out.sql new file mode 100644 index 00000000..260eabd4 --- /dev/null +++ b/docs/migration/inventory/ddl-extracted/202_create_inventory_mgmt_out.sql @@ -0,0 +1,28 @@ +-- ==================================================================== +-- inventory_mgmt_out — 불출 라인 (의뢰 + 실제 불출) +-- ==================================================================== +-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm +-- 추출일: 2026-05-15 +-- 부모: inventory_mgmt.objid → parent_objid (자재 마스터) +-- 부모2: inventory_mgmt_out_master.objid → inventory_request_master_objid +-- 흐름: REQUEST_QTY 먼저 입력 (의뢰) → OUT_QTY/OUT_DATE/ACQ_USER/SIGN 입력 (불출) +-- ==================================================================== + +CREATE TABLE IF NOT EXISTS inventory_mgmt_out ( + objid VARCHAR NOT NULL, + parent_objid VARCHAR, + request_qty VARCHAR, + out_qty VARCHAR, + out_date VARCHAR, + writer VARCHAR, + acq_user VARCHAR, + regdate TIMESTAMP, + inventory_request_master_objid VARCHAR, + sign VARCHAR, + contract_mgmt_objid VARCHAR, + unit VARCHAR, + CONSTRAINT inventory_mgmt_out_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS inventory_mgmt_out_parent_objid_idx ON inventory_mgmt_out (parent_objid); +CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_objid_idx ON inventory_mgmt_out (inventory_request_master_objid); diff --git a/docs/migration/inventory/ddl-extracted/203_create_inventory_mgmt_out_master.sql b/docs/migration/inventory/ddl-extracted/203_create_inventory_mgmt_out_master.sql new file mode 100644 index 00000000..ee5c6c22 --- /dev/null +++ b/docs/migration/inventory/ddl-extracted/203_create_inventory_mgmt_out_master.sql @@ -0,0 +1,32 @@ +-- ==================================================================== +-- inventory_mgmt_out_master — 불출의뢰 마스터 (Rfw-YYYY-seq) +-- ==================================================================== +-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm +-- 추출일: 2026-05-15 +-- 자식: inventory_mgmt_out (불출 라인) → inventory_request_master_objid +-- 상태: +-- reception_status='reception' / ''=미접수 +-- outstatus='complete' / ''=미완료 +-- 채번: inventory_out_no = 'Rfw-' || YYYY || '-' || seq +-- ==================================================================== + +CREATE TABLE IF NOT EXISTS inventory_mgmt_out_master ( + objid VARCHAR NOT NULL, + parent_objid VARCHAR, + inventory_out_no VARCHAR, + request_date VARCHAR, + request_id VARCHAR, + reception_status VARCHAR, + reception_id VARCHAR, + reception_date VARCHAR, + outstatus VARCHAR, + writer VARCHAR, + regdate TIMESTAMP, + remark VARCHAR, + contract_mgmt_objid VARCHAR, + sign VARCHAR, + CONSTRAINT inventory_mgmt_out_master_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_no_idx ON inventory_mgmt_out_master (inventory_out_no); +CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_status_idx ON inventory_mgmt_out_master (reception_status, outstatus); diff --git a/docs/migration/inventory/ddl-extracted/204_create_inventory_mgmt_history.sql b/docs/migration/inventory/ddl-extracted/204_create_inventory_mgmt_history.sql new file mode 100644 index 00000000..235dd055 --- /dev/null +++ b/docs/migration/inventory/ddl-extracted/204_create_inventory_mgmt_history.sql @@ -0,0 +1,18 @@ +-- ==================================================================== +-- inventory_mgmt_history — 자재 투입(인계/인수) 이력 +-- ==================================================================== +-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm +-- 추출일: 2026-05-15 +-- ==================================================================== + +CREATE TABLE IF NOT EXISTS inventory_mgmt_history ( + objid VARCHAR NOT NULL, + parent_objid VARCHAR, + contract_objid VARCHAR, + reg_date VARCHAR, + input_qty VARCHAR, + input_date VARCHAR, + assumption_user VARCHAR, + successor_user VARCHAR, + CONSTRAINT inventory_mgmt_history_pkey PRIMARY KEY (objid) +); diff --git a/frontend/app/(main)/COMPANY_16/material/issue-request/page.tsx b/frontend/app/(main)/COMPANY_16/material/issue-request/page.tsx new file mode 100644 index 00000000..fd53ffe8 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/material/issue-request/page.tsx @@ -0,0 +1,252 @@ +"use client"; + +// 자재관리 > 불출의뢰서 — wace inventoryMng/materialRequestList.jsp 1:1 +// 그리드: inventory_mgmt_out_master + inventory_mgmt_out 집계 +// 검색: 품번/품명 / 불출의뢰일 / 의뢰자 / 접수상태 / 접수자 / 접수일 / 불출상태 +// 액션: 자재불출 / 접수 / 삭제 / 조회 + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { CheckCircle2, Trash2, PackageMinus } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { SmartSelect } from "@/components/common/SmartSelect"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { PageHeader } from "@/components/common/PageHeader"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { inventoryMngApi, IssueRequestFilter, OptionItem } from "@/lib/api/inventoryMng"; +import { IssueDispatchDialog } from "@/components/material/IssueDispatchDialog"; + +const EMPTY: IssueRequestFilter = { + part_no: "", part_name: "", + request_start_date: "", request_end_date: "", + request_user: "", reception_status: "", + reception_user: "", reception_start_date: "", reception_end_date: "", + out_status: "", + page: 1, page_size: 50, +}; + +const RECEPTION_OPTS = [ + { code: "reception", label: "접수" }, + { code: "AA", label: "미접수" }, +]; + +const OUT_STATUS_OPTS = [ + { code: "complete", label: "완료" }, + { code: "NG", label: "미완료" }, +]; + +export default function MaterialIssueRequestPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY); + const [checkedIds, setCheckedIds] = useState([]); + + const [userOpts, setUserOpts] = useState([]); + + const [dispatchOpen, setDispatchOpen] = useState(false); + const [dispatchTarget, setDispatchTarget] = useState<{ objid: string; readOnly: boolean } | null>(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await inventoryMngApi.listIssue(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { + (async () => { + try { setUserOpts(await inventoryMngApi.users()); } catch { /* skip */ } + })(); + fetchList(EMPTY); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const gridRows = useMemo( + () => rows.map((r) => ({ ...r, id: r.objid })), + [rows], + ); + + const checkedRows = useMemo( + () => gridRows.filter((r: any) => checkedIds.includes(r.id)), + [checkedIds, gridRows], + ); + + const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ + { key: "inventory_out_no", label: "자재불출번호", width: "w-[140px]", align: "center" }, + { key: "part_no_arr", label: "품번", minWidth: "min-w-[200px]" }, + { key: "part_name_arr", label: "품명", minWidth: "min-w-[260px]" }, + { key: "request_date", label: "불출의뢰일", width: "w-[110px]", align: "center" }, + { key: "request_user_name", label: "의뢰자", width: "w-[100px]", align: "center" }, + { key: "reception_status_title",label: "상태", width: "w-[80px]", align: "center" }, + { key: "reception_user_name", label: "접수자", width: "w-[100px]", align: "center" }, + { key: "reception_date", label: "접수일", width: "w-[110px]", align: "center" }, + { key: "outstatus_title", label: "불출상태", width: "w-[90px]", align: "center" }, + { key: "request_qty_total", label: "의뢰수량합", width: "w-[100px]", align: "right", + render: (v: any) => Number(v ?? 0).toLocaleString() }, + { key: "out_qty_total", label: "불출수량합", width: "w-[100px]", align: "right", + render: (v: any) => Number(v ?? 0).toLocaleString() }, + { key: "remark", label: "특이사항", minWidth: "min-w-[160px]" }, + ]), []); + + const summary = useMemo(() => ([ + { label: "전체 건수", value: total.toLocaleString(), suffix: "건" }, + { label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" }, + ]), [total, checkedIds]); + + const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); }; + const handleReset = () => { setFilter(EMPTY); setCheckedIds([]); fetchList(EMPTY); }; + + const handleReceive = async () => { + const targets = checkedRows.filter((r: any) => r.reception_status !== "reception"); + if (!targets.length) return toast.info("미접수 상태인 항목을 선택해주세요."); + try { + const r = await inventoryMngApi.receiveIssue(targets.map((t: any) => t.objid)); + toast.success(`${r.updated}건 접수 처리되었습니다.`); + setCheckedIds([]); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "접수 실패"); + } + }; + + const handleDispatch = () => { + if (checkedRows.length !== 1) return toast.info("자재불출은 1건씩 처리합니다."); + const r = checkedRows[0] as any; + if (r.reception_status !== "reception") return toast.warning("접수된 항목만 불출 가능합니다."); + if (r.outstatus === "complete") return toast.warning("이미 불출완료된 항목입니다."); + setDispatchTarget({ objid: r.objid, readOnly: false }); + setDispatchOpen(true); + }; + + const handleDelete = async () => { + if (checkedRows.length !== 1) return toast.info("1건씩 삭제하세요."); + const r = checkedRows[0] as any; + if (r.outstatus === "complete") return toast.warning("이미 불출완료된 항목은 삭제할 수 없습니다."); + if (!confirm("선택한 불출의뢰서를 삭제하시겠습니까?")) return; + try { + await inventoryMngApi.deleteIssue(r.objid); + toast.success("삭제되었습니다."); + setCheckedIds([]); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + return ( +
+ + + + + } + /> + + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, request_start_date: v })} + to={filter.request_end_date ?? ""} setTo={(v) => setFilter({ ...filter, request_end_date: v })} + /> + + + setFilter({ ...filter, request_user: v })} /> + + + setFilter({ ...filter, reception_status: v })} /> + + + setFilter({ ...filter, reception_user: v })} /> + + + setFilter({ ...filter, reception_start_date: v })} + to={filter.reception_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reception_end_date: v })} + /> + + + setFilter({ ...filter, out_status: v })} /> + + + + { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} + showColumnSettings + summaryStats={summary} + onRefresh={() => fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "불출의뢰서.xlsx", "불출의뢰서"); + }} + onRowDoubleClick={(row: any) => { + setDispatchTarget({ objid: row.objid, readOnly: true }); + setDispatchOpen(true); + }} + showChart + /> + + {dispatchTarget && ( + setDispatchOpen(false)} + onSaved={() => { setCheckedIds([]); fetchList(); }} + masterObjid={dispatchTarget.objid} + readOnly={dispatchTarget.readOnly} + /> + )} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/material/list/page.tsx b/frontend/app/(main)/COMPANY_16/material/list/page.tsx new file mode 100644 index 00000000..37625ee0 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/material/list/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +// 자재관리 > 자재리스트 — wace inventoryMng/inventoryMngNewList.jsp 1:1 +// 그리드: inventory_mgmt + part_mng + 입출고 집계 (USE_CNT / USE_CNT_ALL / REQUEST_QTY) +// 검색: 프로젝트 / 유닛 / 품번 / 품명 / PART구분 / Location +// 액션: 재고등록 / 자재이동 / 불출의뢰 / 삭제 / 이력보기 + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Plus, Repeat, ClipboardEdit, Trash2, History as HistoryIcon } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { SmartSelect } from "@/components/common/SmartSelect"; +import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar"; +import { PageHeader } from "@/components/common/PageHeader"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { inventoryMngApi, InventoryListFilter, OptionItem } from "@/lib/api/inventoryMng"; +import { StockRegisterDialog } from "@/components/material/StockRegisterDialog"; +import { MaterialMoveDialog } from "@/components/material/MaterialMoveDialog"; +import { IssueRequestCreateDialog } from "@/components/material/IssueRequestCreateDialog"; +import { InventoryHistoryDialog } from "@/components/material/InventoryHistoryDialog"; + +const EMPTY: InventoryListFilter = { + project_objid: "", unit_code: "", + part_no: "", part_name: "", + part_type: "", location: "", + page: 1, page_size: 50, +}; + +export default function MaterialListPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY); + const [checkedIds, setCheckedIds] = useState([]); + + const [projectOpts, setProjectOpts] = useState([]); + const [unitOpts, setUnitOpts] = useState([]); + + const [stockOpen, setStockOpen] = useState(false); + const [moveOpen, setMoveOpen] = useState(false); + const [issueOpen, setIssueOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyTarget, setHistoryTarget] = useState<{ objid: string; label: string } | null>(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await inventoryMngApi.list(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { + (async () => { + try { setProjectOpts(await inventoryMngApi.projects()); } catch { /* skip */ } + })(); + fetchList(EMPTY); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!filter.project_objid) { setUnitOpts([]); return; } + (async () => { + try { setUnitOpts(await inventoryMngApi.units(filter.project_objid!)); } catch { /* skip */ } + })(); + }, [filter.project_objid]); + + const gridRows = useMemo( + () => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })), + [rows], + ); + + const checkedObjids = useMemo( + () => gridRows.filter((r: any) => checkedIds.includes(r.id)).map((r: any) => r.objid as string), + [checkedIds, gridRows], + ); + + const checkedRows = useMemo( + () => gridRows.filter((r: any) => checkedIds.includes(r.id)), + [checkedIds, gridRows], + ); + + const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ + { key: "project_no", label: "프로젝트번호", width: "w-[120px]", align: "center" }, + { key: "unit_name", label: "유닛명", width: "w-[150px]" }, + { key: "part_no", label: "품번", width: "w-[160px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[200px]" }, + { key: "material", label: "재질", width: "w-[110px]" }, + { key: "spec", label: "사양(규격)", minWidth: "min-w-[140px]" }, + { key: "part_type_name", label: "PART구분", width: "w-[100px]", align: "center" }, + { key: "use_cnt", label: "보유수량", width: "w-[100px]", align: "right", + render: (v: any) => Number(v ?? 0).toLocaleString() }, + { key: "use_cnt_all", label: "보유수량(전체)", width: "w-[110px]", align: "right", + render: (v: any) => Number(v ?? 0).toLocaleString() }, + { key: "location_name", label: "Location", width: "w-[140px]" }, + { key: "request_qty", label: "불출이력", width: "w-[100px]", align: "right", + render: (v: any) => Number(v ?? 0).toLocaleString() }, + ]), []); + + const summary = useMemo(() => ([ + { label: "전체 건수", value: total.toLocaleString(), suffix: "건" }, + { label: "페이지 건수", value: gridRows.length.toLocaleString(), suffix: "건" }, + { label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" }, + ]), [gridRows, total, checkedIds]); + + const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); }; + const handleReset = () => { setFilter(EMPTY); setCheckedIds([]); fetchList(EMPTY); }; + + const handleDelete = async () => { + if (!checkedObjids.length) return toast.info("삭제할 자재를 선택해주세요."); + if (!confirm(`선택한 ${checkedObjids.length}건의 자재를 삭제하시겠습니까?`)) return; + try { + await inventoryMngApi.deleteStock(checkedObjids); + toast.success("삭제되었습니다."); + setCheckedIds([]); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + const handleMove = () => { + if (!checkedRows.length) return toast.info("이동할 자재를 선택해주세요."); + if (checkedRows.some((r: any) => Number(r.use_cnt) === 0)) { + return toast.warning("보유수량이 0인 자재가 포함되어 있습니다."); + } + setMoveOpen(true); + }; + + const handleIssue = () => { + if (!checkedRows.length) return toast.info("불출의뢰할 자재를 선택해주세요."); + if (checkedRows.some((r: any) => Number(r.use_cnt) === 0)) { + return toast.warning("보유수량이 0인 자재가 포함되어 있습니다."); + } + setIssueOpen(true); + }; + + const handleHistoryOne = () => { + if (checkedRows.length !== 1) return toast.info("이력은 1건씩 조회합니다."); + const r = checkedRows[0] as any; + setHistoryTarget({ objid: r.objid, label: `${r.part_no} ${r.part_name ?? ""}` }); + setHistoryOpen(true); + }; + + return ( +
+ + + + + + + } + /> + + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, project_objid: v, unit_code: "" })} /> + + + setFilter({ ...filter, unit_code: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, part_type: e.target.value })} /> + + + setFilter({ ...filter, location: e.target.value })} /> + + + + { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }} + onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }} + showColumnSettings + summaryStats={summary} + onRefresh={() => fetchList()} + onDownload={() => { + if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = gridRows.map((r: any) => { + const out: Record = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "자재리스트.xlsx", "자재리스트"); + }} + showChart + /> + + setStockOpen(false)} + onSaved={() => fetchList()} /> + + setMoveOpen(false)} + onSaved={() => { setCheckedIds([]); fetchList(); }} + selectedRows={checkedRows} /> + + setIssueOpen(false)} + onSaved={() => { setCheckedIds([]); fetchList(); }} + parentObjids={checkedObjids} /> + + {historyTarget && ( + setHistoryOpen(false)} + parentObjid={historyTarget.objid} partLabel={historyTarget.label} /> + )} +
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 0fd0b1d4..f014f967 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -136,6 +136,8 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/outsourcing/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/outbound/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/material/list": dynamic(() => import("@/app/(main)/COMPANY_16/material/list/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/material/issue-request": dynamic(() => import("@/app/(main)/COMPANY_16/material/issue-request/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }), @@ -355,6 +357,7 @@ const COMPANY_PAGE_PREFIXES = [ "/outsourcing/", "/design/", "/purchase/", + "/material/", "/quality/", "/mold/", "/monitoring/", diff --git a/frontend/components/material/InventoryHistoryDialog.tsx b/frontend/components/material/InventoryHistoryDialog.tsx new file mode 100644 index 00000000..c0083088 --- /dev/null +++ b/frontend/components/material/InventoryHistoryDialog.tsx @@ -0,0 +1,101 @@ +"use client"; + +// 자재관리 > 자재리스트 — 입출고 이력 다이얼로그 +// wace 1:1: inventoryRequestHistoryPopUp.jsp +// 입고/이동/출고 UNION 결과 노출 + +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { inventoryMngApi } from "@/lib/api/inventoryMng"; +import { toast } from "sonner"; + +interface HistoryRow { + objid: string; + part_no: string; + part_name: string; + gubun: string; + qty: string; + location_name: string; + sub_location_name: string; + regdate: string; + writer_name: string; +} + +interface Props { + open: boolean; + onClose: () => void; + parentObjid: string; // inventory_mgmt.objid + partLabel?: string; +} + +export function InventoryHistoryDialog({ open, onClose, parentObjid, partLabel }: Props) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !parentObjid) return; + setLoading(true); + (async () => { + try { + const data = await inventoryMngApi.history(parentObjid); + setRows(data as HistoryRow[]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "이력 조회 실패"); + } finally { + setLoading(false); + } + })(); + }, [open, parentObjid]); + + return ( + !v && onClose()}> + + + 입출고 이력{partLabel ? ` — ${partLabel}` : ""} + 해당 자재의 입고/이동/출고 이력을 표시합니다. + + +
+ + + + + + + + + + + + + {loading && ( + + )} + {!loading && rows.length === 0 && ( + + )} + {rows.map((r, i) => ( + + + + + + + + + ))} + +
구분수량LocationSub담당자일자
조회 중...
이력이 없습니다.
{r.gubun}{r.qty || "0"}{r.location_name}{r.sub_location_name}{r.writer_name}{r.regdate?.toString().slice(0, 19).replace("T", " ")}
+
+ + + + +
+
+ ); +} diff --git a/frontend/components/material/IssueDispatchDialog.tsx b/frontend/components/material/IssueDispatchDialog.tsx new file mode 100644 index 00000000..c01ab718 --- /dev/null +++ b/frontend/components/material/IssueDispatchDialog.tsx @@ -0,0 +1,228 @@ +"use client"; + +// 자재관리 > 불출의뢰서 — 자재불출 처리 다이얼로그 +// wace 1:1: materialRequestDetailPopUp.jsp / acceptInventoryRequestInfo.do +// 라인별 OUT_QTY / OUT_DATE / ACQ_USER 입력 후 OUTSTATUS='complete' 처리. +// 읽기전용(조회) 모드도 지원. + +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Save, X } from "lucide-react"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { NumberInput } from "@/components/common/NumberInput"; +import { DateInput } from "@/components/common/DateInput"; +import { SmartSelect } from "@/components/common/SmartSelect"; +import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng"; + +interface LineRow { + objid: string; + part_no: string; + part_name: string; + material: string; + spec: string; + location: string; + sub_location: string; + request_qty: string; + out_qty: number | ""; + out_date: string; + acq_user: string; + writer_name: string; +} + +interface Props { + open: boolean; + onClose: () => void; + onSaved: () => void; + masterObjid: string; + readOnly?: boolean; // 미접수/완료 상태에서 조회만 +} + +export function IssueDispatchDialog({ open, onClose, onSaved, masterObjid, readOnly }: Props) { + const [master, setMaster] = useState(null); + const [rows, setRows] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [bulkUser, setBulkUser] = useState(""); + const [bulkDate, setBulkDate] = useState(new Date().toISOString().slice(0, 10)); + + useEffect(() => { + if (!open || !masterObjid) return; + setMaster(null); + setRows([]); + setLoading(true); + (async () => { + try { + const [detail, users] = await Promise.all([ + inventoryMngApi.getIssue(masterObjid), + inventoryMngApi.users(), + ]); + setMaster(detail.master); + setRows((detail.lines ?? []).map((l: any) => ({ + objid: l.objid, + part_no: l.part_no ?? "", + part_name: l.part_name ?? "", + material: l.material ?? "", + spec: l.spec ?? "", + location: l.location ?? "", + sub_location: l.sub_location ?? "", + request_qty: l.request_qty ?? "0", + out_qty: l.out_qty ? Number(l.out_qty) : "", + out_date: l.out_date ?? "", + acq_user: l.acq_user ?? "", + writer_name: l.writer_name ?? "", + }))); + setUserOpts(users); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + })(); + }, [open, masterObjid]); + + const handleRow = (idx: number, patch: Partial) => + setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r)); + + const handleApplyBulk = () => { + setRows(rs => rs.map(r => ({ + ...r, + acq_user: bulkUser || r.acq_user, + out_date: bulkDate || r.out_date, + }))); + }; + + const handleSave = async () => { + const lines = rows.filter(r => r.out_qty !== "" && Number(r.out_qty) > 0); + if (!lines.length) return toast.info("불출수량을 입력해주세요."); + for (const r of lines) { + if (Number(r.out_qty) > Number(r.request_qty)) { + return toast.warning(`${r.part_no}: 의뢰수량(${r.request_qty}) 초과는 불가합니다.`); + } + if (!r.out_date) return toast.info(`${r.part_no}: 인계일을 입력해주세요.`); + if (!r.acq_user) return toast.info(`${r.part_no}: 인수자를 선택해주세요.`); + } + setSaving(true); + try { + await inventoryMngApi.dispatchIssue({ + master_objid: masterObjid, + lines: lines.map(r => ({ + objid: r.objid, + out_qty: Number(r.out_qty), + out_date: r.out_date, + acq_user: r.acq_user, + })), + }); + toast.success("자재 불출 처리가 완료되었습니다."); + onSaved(); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "불출 실패"); + } finally { + setSaving(false); + } + }; + + return ( + !v && onClose()}> + + + + {readOnly ? "불출의뢰 상세" : "자재 불출"} + {master?.inventory_out_no ? ` — ${master.inventory_out_no}` : ""} + + + {readOnly + ? "불출의뢰 라인을 조회합니다." + : "라인별 불출수량 / 인계일 / 인수자를 입력하고 저장하세요."} + + + + {master && ( +
+
의뢰일: {master.request_date ?? "-"}
+
의뢰자: {master.request_user_name ?? master.request_id ?? "-"}
+
접수: {master.reception_status === "reception" ? "접수" : "미접수"} + {master.reception_date ? ` / ${master.reception_date}` : ""}
+
특이사항: {master.remark || "-"}
+
+ )} + + {!readOnly && ( +
+ 일괄적용: + 인계일 +
+ 인수자 +
+ +
+ +
+ )} + +
+ + + + + + + + + + + + + + + + {loading && } + {!loading && rows.length === 0 && ( + + )} + {rows.map((r, i) => ( + + + + + + + + + + + + ))} + +
품번품명규격재질Location의뢰수량불출수량인계일인수자
조회 중...
라인이 없습니다.
{r.part_no}{r.part_name}{r.spec}{r.material}{r.location}{r.sub_location ? ` / ${r.sub_location}` : ""}{Number(r.request_qty ?? 0).toLocaleString()} + {readOnly + ? {r.out_qty === "" ? "" : Number(r.out_qty).toLocaleString()} + : handleRow(i, { out_qty: v })} />} + + {readOnly ? {r.out_date} : + handleRow(i, { out_date: v })} />} + + {readOnly ? {r.acq_user} : + handleRow(i, { acq_user: v })} />} +
+
+ + + + {!readOnly && ( + + )} + +
+
+ ); +} diff --git a/frontend/components/material/IssueRequestCreateDialog.tsx b/frontend/components/material/IssueRequestCreateDialog.tsx new file mode 100644 index 00000000..52b0b06d --- /dev/null +++ b/frontend/components/material/IssueRequestCreateDialog.tsx @@ -0,0 +1,184 @@ +"use client"; + +// 자재관리 > 자재리스트 — 불출의뢰 등록 다이얼로그 +// wace 1:1: materialRequestFormPopUp.jsp / saveInventoryRequest.do +// 선택된 자재(inventory_mgmt) 후보를 불러와 request_qty 입력 → 마스터+라인 저장 +// inventory_out_no 채번: Rfw-YYYY-seq + +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Save, X } from "lucide-react"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { NumberInput } from "@/components/common/NumberInput"; +import { DateInput } from "@/components/common/DateInput"; +import { inventoryMngApi } from "@/lib/api/inventoryMng"; + +interface CandidateRow { + objid: string; + project_no: string; + unit: string; + unit_name: string; + part_no: string; + part_name: string; + material: string; + spec: string; + location: string; + sub_location: string; + use_cnt: number; + request_qty: number | ""; +} + +interface Props { + open: boolean; + onClose: () => void; + onSaved: () => void; + parentObjids: string[]; // 자재리스트에서 선택된 inventory_mgmt.objid +} + +export function IssueRequestCreateDialog({ open, onClose, onSaved, parentObjids }: Props) { + const [rows, setRows] = useState([]); + const [remark, setRemark] = useState(""); + const [requestDate, setRequestDate] = useState(new Date().toISOString().slice(0, 10)); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open || !parentObjids.length) return; + setRemark(""); + setRequestDate(new Date().toISOString().slice(0, 10)); + (async () => { + setLoading(true); + try { + const list = await inventoryMngApi.candidates(parentObjids); + setRows(list.map((r) => ({ + objid: r.objid, + project_no: r.project_no ?? "", + unit: r.unit ?? "", + unit_name: r.unit_name ?? "", + part_no: r.part_no ?? "", + part_name: r.part_name ?? "", + material: r.material ?? "", + spec: r.spec ?? "", + location: r.location ?? "", + sub_location: r.sub_location ?? "", + use_cnt: Number(r.use_cnt ?? 0), + request_qty: "", + }))); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "후보 조회 실패"); + } finally { + setLoading(false); + } + })(); + }, [open, parentObjids]); + + const handleRow = (idx: number, patch: Partial) => + setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r)); + + const handleSave = async () => { + const targets = rows.filter((r) => r.request_qty !== "" && Number(r.request_qty) > 0); + if (!targets.length) return toast.info("불출의뢰수량을 입력해주세요."); + for (const r of targets) { + if (Number(r.request_qty) > r.use_cnt) { + return toast.warning(`${r.part_no}: 보유수량(${r.use_cnt}) 초과는 불가합니다.`); + } + } + setSaving(true); + try { + const res = await inventoryMngApi.saveIssue({ + request_date: requestDate, + remark, + lines: targets.map((r) => ({ + parent_objid: r.objid, + request_qty: Number(r.request_qty), + unit: r.unit, + })), + }); + toast.success(`불출의뢰가 생성되었습니다. (${res.inventory_out_no})`); + onSaved(); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + !v && onClose()}> + + + 불출의뢰 등록 + 선택한 자재의 불출의뢰수량을 입력합니다. + + +
+
+ + +
+
+
+ +