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/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index af91a227..6ca10c11 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -5,6 +5,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/purchaseService"; +import * as formSvc from "../services/purchaseOrderFormService"; import { logger } from "../utils/logger"; function parseFilter(q: Record): svc.PurchaseListFilter { @@ -36,6 +37,41 @@ export const getInbound = (req: AuthenticatedRequest, res: Response) export const getInboundByItem = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByItem, req, res, "품목별 입고관리"); export const getInboundByDate = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByDate, req, res, "입고일별 입고관리"); export const getProjectStatus = (req: AuthenticatedRequest, res: Response) => runList(svc.listProjectStatus, req, res, "프로젝트별 발주/입고 현황"); +export const getPurchaseOrderList = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseOrderList, req, res, "발주서관리"); + +// ─── 발주서 폼 (general 양식) ───────────────────────────────── + +/** + * GET /api/purchase/order-form/init?proposal_objid=... + * 품의서에서 발주서 등록 폼 데이터 자동 채움. + */ +export async function getPurchaseOrderFormInit(req: AuthenticatedRequest, res: Response) { + try { + const proposalObjid = String(req.query.proposal_objid ?? "").trim(); + const data = await formSvc.getPurchaseOrderFormInit(proposalObjid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("발주서 폼 초기화 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** + * GET /api/purchase/order-form/:objid + * 발주서 마스터 + 파트 조회 (수정/조회 모드). + */ +export async function getPurchaseOrderForm(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid required" }); + const data = await formSvc.getPurchaseOrderForm(objid); + if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" }); + 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 getSuppliers(_req: AuthenticatedRequest, res: Response) { try { @@ -47,6 +83,16 @@ export async function getSuppliers(_req: AuthenticatedRequest, res: Response) { } } +export async function getVendors(_req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listVendorOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("공급업체(client_mng) 옵션 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + export async function getUsers(_req: AuthenticatedRequest, res: Response) { try { const data = await svc.listUserOptions(); 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/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index 16f6083c..a382029f 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -18,9 +18,15 @@ router.get("/inbound", ctrl.getInbound); // 입고관리 router.get("/inbound-by-item", ctrl.getInboundByItem); // 품목별 입고관리 router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입고관리 router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황 +router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1) + +// 발주서 폼 (general 양식, wace purchaseOrderFormPopup_general.do 1:1) +router.get("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움 +router.get("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 // 공통 옵션 router.get("/options/suppliers", ctrl.getSuppliers); +router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반 router.get("/options/users", ctrl.getUsers); router.get("/options/projects", ctrl.getProjects); 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/backend-node/src/services/purchaseOrderFormService.ts b/backend-node/src/services/purchaseOrderFormService.ts new file mode 100644 index 00000000..444b146c --- /dev/null +++ b/backend-node/src/services/purchaseOrderFormService.ts @@ -0,0 +1,240 @@ +// ============================================================ +// 발주서관리 — 등록/수정 폼 (general 양식) 서비스 +// +// wace_plm 1:1 이식 베이스: +// - controller: purchaseOrder/purchaseOrderFormPopup_general.do +// purchaseOrder/purchaseOrderFormPopup_generalSave.do +// - service: PurchaseOrderService.savePurchaseOrder_new (1472-1817) +// - mapper: purchaseOrder.xml mergePurchaseOrderMaster (530-714) + +// mergePurchaseOrderPartInfo (1205-1325) + +// getPurchaseOrderMasterInfo (1343-1556) + +// getPURCHASE_ORDER_PART +// salesMng.xml getProposalPartList (5012-5125) + +// getProposalInfo (4919-) +// +// 운영 핵심 흐름: 품의서(/purchase/proposal)에서 "발주서생성" → general 다이얼로그 → +// 품의서 품목 자동 채움 → 마스터 입력 → 저장. +// +// 본 모듈은 form-init / form-get 두 GET 엔드포인트를 제공. +// (save / delete 는 다음 단계) +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +export interface OrderFormInitResult { + master: Record; + parts: Record[]; +} + +/** + * GET /api/purchase/order-form/init?proposal_objid=... + * + * 품의서(sales_request_master)에서 발주서 등록 폼을 채울 데이터를 반환. + * wace controller `purchaseOrderFormPopup_general.do` 의 신규 등록 분기와 1:1. + * + * master 기본값: + * - PROPOSAL_OBJID / SALES_REQUEST_OBJID = proposal_objid + * - CONTRACT_MGMT_OBJID = proposal.PROJECT_NO + * - PURCHASE_ORDER_NO = "RPS{YY}-{MMDD}-{NN}" (NN: 당일 발주 카운트+1) + * - PURCHASE_DATE / ORDER_DATE = 오늘 + * - STATUS = "create" / FORM_TYPE = "general" + * parts: salesMng.getProposalPartList SQL 1:1 → 발주서 그리드 형식 변환 + * (ORDER_QTY=QTY, PARTNER_PRICE=UNIT_PRICE, SUPPLY_UNIT_PRICE=QTY*UNIT_PRICE 등) + */ +export async function getPurchaseOrderFormInit(proposalObjid: string): Promise { + const pool = getPool(); + + // 1) 품의서 마스터 정보 (PROJECT_NO 등) + let proposal: Record | null = null; + if (proposalObjid) { + try { + const r = await pool.query( + `SELECT OBJID, PROJECT_NO, MBOM_HEADER_OBJID, TITLE, REQUEST_USER_ID, PURCHASE_TYPE + FROM SALES_REQUEST_MASTER WHERE OBJID = $1`, + [proposalObjid], + ); + proposal = r.rows[0] ?? null; + } catch (e: any) { + logger.warn("getProposalInfo 실패", { error: e.message }); + } + } + + // 2) 발주번호 채번 (wace mergePurchaseOrderMaster INSERT 절 1:1) + let purchaseOrderNo = ""; + try { + const r = await pool.query( + `SELECT 'RPS' || TO_CHAR(NOW(),'YY') || '-' || TO_CHAR(NOW(),'MMDD') || '-' || + LPAD((COALESCE(MAX(CASE WHEN PURCHASE_ORDER_NO LIKE 'RPS' || TO_CHAR(NOW(),'YY-MMDD') || '-%' + THEN SPLIT_PART(PURCHASE_ORDER_NO, '-', 3) ELSE '0' END)::INTEGER, 0) + 1)::TEXT, 2, '0') + AS po_no + FROM PURCHASE_ORDER_MASTER`, + ); + purchaseOrderNo = r.rows[0]?.po_no ?? ""; + } catch (e: any) { + logger.warn("발주번호 채번 실패", { error: e.message }); + } + + const todayIso = new Date().toISOString().slice(0, 10); + const master: Record = { + objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번 + purchase_order_no: purchaseOrderNo, + purchase_date: todayIso, // 발주일 + order_date_kor: formatKorDate(new Date()), // 발주일자 한글 표기 (wace ORDER_DATE) + status: "create", + form_type: "general", + sales_request_objid: proposalObjid || "", + proposal_objid: proposalObjid || "", + contract_mgmt_objid: proposal?.project_no ?? "", + title: proposal?.title ?? "", + // wace controller _general 기본 담당자 (RPS 운영 고정값) + manager_name: "안동윤", + manager_position: "팀장", + manager_phone: "010-2313-2702", + manager_email: "ady1225@rps-korea.com", + manager_name2: "서동민", + manager_position2: "주임", + manager_phone2: "010-9538-9513", + manager_email2: "sdm0927@rps-korea.com", + }; + + // 3) 품의서 품목 → 발주서 파트 변환 (salesMng.getProposalPartList 1:1) + const parts: Record[] = []; + if (proposalObjid) { + try { + const r = await pool.query( + `SELECT + ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum, + SRP.OBJID AS srp_objid, + SRP.PART_OBJID AS part_objid, + PM.PART_NO AS part_no, + PM.PART_NAME AS part_name, + PM.SPEC AS spec, + PM.MATERIAL AS material, + COALESCE(NULLIF(SRP.UNIT, ''), PM.UNIT_DC) AS unit, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = NULLIF(SRP.UNIT, '')), + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = PM.UNIT_DC) + ) AS unit_title, + SRP.QTY AS qty, + COALESCE(SRP.UNIT_PRICE, 0) AS unit_price, + CASE WHEN COALESCE(SRP.TOTAL_PRICE::NUMERIC, 0) > 0 + THEN SRP.TOTAL_PRICE::NUMERIC + ELSE COALESCE(SRP.QTY::NUMERIC, 0) * COALESCE(SRP.UNIT_PRICE::NUMERIC, 0) + END AS total_price, + SRP.VENDOR_PM AS vendor_pm, + (SELECT CLIENT_NM FROM CLIENT_MNG + WHERE OBJID::VARCHAR = SRP.VENDOR_PM) AS vendor_name, + SRP.REMARK AS remark, + SRP.DELIVERY_REQUEST_DATE AS delivery_request_date, + COALESCE(SRP.MATERIAL_YN, 'N') AS material_yn, + (SELECT PJ.PART_NAME FROM PROJECT_MGMT PJ + WHERE PJ.OBJID::VARCHAR = SRM.PROJECT_NO) AS project_product_name, + PM.PART_NAME AS component_part_name, + SRP.CURRENCY AS currency, + (SELECT CC.CODE_NAME FROM COMM_CODE CC + WHERE CC.CODE_ID = NULLIF(SRP.CURRENCY, '')) AS currency_name + FROM SALES_REQUEST_PART SRP + LEFT JOIN PART_MNG PM + ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR + LEFT JOIN SALES_REQUEST_MASTER SRM + ON SRP.SALES_REQUEST_MASTER_OBJID = SRM.OBJID + WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 + ORDER BY SRP.REGDATE`, + [proposalObjid], + ); + + for (const row of r.rows) { + const qty = toNum(row.qty); + const unitPrice = toNum(row.unit_price); + const projectName = (row.project_product_name ?? "").toString().trim(); + const componentName = (row.component_part_name ?? "").toString().trim(); + const remark = projectName && componentName ? `${projectName} / ${componentName}` + : projectName || componentName || (row.remark ?? ""); + + parts.push({ + // 발주서 그리드 형식 (wace controller _general 변환 1:1) + objid: "", // 신규 row + part_objid: row.part_objid ?? "", + row_num: row.rnum, + part_no: row.part_no ?? "", + part_name: row.part_name ?? "", + spec: row.spec ?? "", + material: row.material ?? "", + order_qty: qty, + unit: row.unit || "0001400", // wace 기본값 EA + unit_title: row.unit_title ?? "", + part_delivery_place: "RPS", + partner_price: unitPrice, + supply_unit_price: qty * unitPrice, + remark, + delivery_request_date: row.delivery_request_date ?? "", + currency: row.currency ?? "", + currency_name: row.currency_name ?? "", + // 추적용 (저장 시 신규 row 임을 구분) + _src: "proposal", + _src_objid: row.srp_objid, + }); + } + } catch (e: any) { + logger.error("getProposalPartList 실패", { error: e.message }); + } + } + + return { master, parts }; +} + +/** + * GET /api/purchase/order-form/:objid + * + * 발주서 마스터 + 파트 조회 (수정/조회 모드). + * wace `getPurchaseOrderMasterInfo` (1343-1556) + `getPURCHASE_ORDER_PART` 의 RPS 압축판. + */ +export async function getPurchaseOrderForm(objid: string): Promise { + const pool = getPool(); + try { + const m = await pool.query( + `SELECT POM.*, + (SELECT CLIENT_NM FROM CLIENT_MNG + WHERE OBJID::VARCHAR = POM.PARTNER_OBJID) AS partner_name, + (SELECT USER_NAME FROM USER_INFO + WHERE USER_ID = POM.SALES_MNG_USER_ID) AS sales_mng_user_name, + (SELECT USER_NAME FROM USER_INFO + WHERE USER_ID = POM.WRITER) AS writer_name, + CM.PROJECT_NO AS project_no, + SRM.REQUEST_MNG_NO AS proposal_no + FROM PURCHASE_ORDER_MASTER POM + LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID + LEFT JOIN SALES_REQUEST_MASTER SRM ON POM.SALES_REQUEST_OBJID = SRM.OBJID + WHERE POM.OBJID = $1`, + [objid], + ); + if (m.rows.length === 0) return null; + + const p = await pool.query( + `SELECT POP.*, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.CURRENCY) AS currency_name + FROM PURCHASE_ORDER_PART POP + WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1 + ORDER BY POP.REGDATE`, + [objid], + ); + + return { master: m.rows[0], parts: p.rows }; + } catch (e: any) { + logger.error("getPurchaseOrderForm 실패", { error: e.message }); + return null; + } +} + +function toNum(v: any): number { + if (v == null || v === "") return 0; + const s = String(v).replace(/,/g, ""); + const n = Number(s); + return Number.isFinite(n) ? n : 0; +} + +function formatKorDate(d: Date): string { + return `${d.getFullYear()}년 ${String(d.getMonth() + 1).padStart(2, "0")}월 ${String(d.getDate()).padStart(2, "0")}일`; +} diff --git a/backend-node/src/services/purchaseService.ts b/backend-node/src/services/purchaseService.ts index 3c7be7ab..66b60cec 100644 --- a/backend-node/src/services/purchaseService.ts +++ b/backend-node/src/services/purchaseService.ts @@ -38,6 +38,7 @@ export interface PurchaseListFilter { purchase_type?: string; part_type?: string; product_cd?: string; + category_cd?: string; paid_type?: string; mail_send_yn?: string; delivery_status?: string; @@ -144,8 +145,11 @@ export async function listPurchaseRequest(filter: PurchaseListFilter): Promise> { - const { page, pageSize } = clampPaging(filter); - logger.warn("listQuotationRequest: quotation_request_master 테이블 미존재 — 빈 응답"); - return { rows: [], totalCount: 0, page, pageSize }; + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = []; + const params: any[] = []; + const addParam = (val: any) => { params.push(val); return `$${params.length}`; }; + + if (filter.year) where.push(`EXTRACT(YEAR FROM QRM.REG_DATE) = ${addParam(Number(filter.year))}`); + if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`); + if (filter.proposal_no) where.push(`QRM.QUOTATION_REQUEST_NO ILIKE ${addParam(`%${filter.proposal_no}%`)}`); + if (filter.partner_objid) where.push(`QRM.VENDOR_OBJID = ${addParam(filter.partner_objid)}`); + if (filter.mail_send_yn) where.push(`COALESCE(QRM.MAIL_SEND_YN, 'N') = ${addParam(filter.mail_send_yn)}`); + if (filter.writer) where.push(`QRM.WRITER = ${addParam(filter.writer)}`); + if (filter.product_cd) where.push(`COALESCE(CTM.PRODUCT, SRM.PRODUCT_NAME) = ${addParam(filter.product_cd)}`); + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const dataSql = ` + SELECT + QRM.OBJID AS objid, + QRM.QUOTATION_REQUEST_NO AS quotation_request_no, + QRM.SALES_REQUEST_MASTER_OBJID AS sales_request_master_objid, + QRM.PROJECT_MGMT_OBJID AS project_mgmt_objid, + QRM.VENDOR_OBJID AS vendor_objid, + QRM.VENDOR_TYPE AS vendor_type, + QRM.STATUS AS status, + CASE QRM.STATUS + WHEN 'create' THEN '작성중' + WHEN 'sent' THEN '발송완료' + WHEN 'received' THEN '견적수신' + WHEN 'completed' THEN '완료' + ELSE COALESCE(QRM.STATUS, '') + END AS status_name, + QRM.MAIL_SEND_DATE AS mail_send_date, + TO_CHAR(QRM.MAIL_SEND_DATE, 'YYYY-MM-DD') AS mail_send_date_title, + COALESCE(QRM.MAIL_SEND_YN, 'N') AS mail_send_yn, + QRM.DUE_DATE AS due_date, + TO_CHAR(QRM.DUE_DATE, 'YYYY-MM-DD') AS due_date_title, + QRM.REMARK AS remark, + QRM.WRITER AS writer, + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = QRM.WRITER LIMIT 1), QRM.WRITER, '') AS writer_name, + QRM.REG_DATE AS reg_date, + TO_CHAR(QRM.REG_DATE, 'YYYY-MM-DD') AS reg_date_title, + SRM.REQUEST_MNG_NO AS request_mng_no, + SRM.PURCHASE_TYPE AS purchase_type, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '' + ) AS purchase_type_name, + COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1), '' + ) AS order_type_name, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CTM.PRODUCT LIMIT 1), + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), + '' + ) AS product_name_title, + PM.PROJECT_NO AS project_number, + CM.CLIENT_NM AS vendor_name, + (SELECT QRD.PART_NO FROM QUOTATION_REQUEST_DETAIL QRD + WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID + ORDER BY QRD.OBJID LIMIT 1) AS part_no, + (SELECT QRD.PART_NAME FROM QUOTATION_REQUEST_DETAIL QRD + WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID + ORDER BY QRD.OBJID LIMIT 1) AS part_name, + (SELECT COUNT(*)::int FROM QUOTATION_REQUEST_DETAIL QRD + WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID) AS detail_count, + (SELECT COUNT(*)::int FROM ATTACH_FILE_INFO + WHERE TARGET_OBJID = QRM.OBJID + AND DOC_TYPE = 'QUOTATION_RECEIVED' + AND COALESCE(STATUS, 'Active') = 'Active') AS attach_file_cnt + FROM QUOTATION_REQUEST_MASTER QRM + LEFT JOIN SALES_REQUEST_MASTER SRM ON QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = SRM.PROJECT_NO + LEFT JOIN CONTRACT_MGMT CTM ON CTM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN CLIENT_MNG CM ON QRM.VENDOR_OBJID = CM.OBJID + ${whereSql} + ORDER BY QRM.REG_DATE DESC + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)} + `; + const countSql = ` + SELECT COUNT(*)::int AS cnt + FROM QUOTATION_REQUEST_MASTER QRM + LEFT JOIN SALES_REQUEST_MASTER SRM ON QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = SRM.PROJECT_NO + LEFT JOIN CONTRACT_MGMT CTM ON CTM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN CLIENT_MNG CM ON QRM.VENDOR_OBJID = CM.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("listQuotationRequest 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } } // ─── 3) 품의서관리 (wace salesMng.xml proposalMngList) ── @@ -274,27 +381,336 @@ export async function listProposal(filter: PurchaseListFilter): Promise> { - const { page, pageSize } = clampPaging(filter); - logger.warn("listInbound: purchase_order_part / arrival_plan 미존재 — 빈 응답"); - return { rows: [], totalCount: 0, page, pageSize }; + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = [ + `POM.MAIL_SEND_DATE IS NOT NULL`, + `POM.STATUS = 'create'`, + `(POM.MULTI_MASTER_YN = 'Y' OR COALESCE(POM.MULTI_MASTER_YN, '') <> 'Y' AND COALESCE(POM.MULTI_YN, '') <> 'Y')`, + ]; + const params: any[] = []; + const addParam = (val: any) => { params.push(val); return `$${params.length}`; }; + + if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(String(filter.year))}`); + if (filter.customer_cd) where.push(`CM.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', '')`); + if (filter.project_no) where.push(`CM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`); + if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`); + if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`); + if (filter.sales_mng_user_id) where.push(`POM.WRITER = ${addParam(filter.sales_mng_user_id)}`); + if (filter.delivery_start_date) where.push(`POM.DELIVERY_DATE >= ${addParam(filter.delivery_start_date)}`); + if (filter.delivery_end_date) where.push(`POM.DELIVERY_DATE <= ${addParam(filter.delivery_end_date)}`); + if (filter.reg_start_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') >= ${addParam(filter.reg_start_date)}`); + if (filter.reg_end_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') <= ${addParam(filter.reg_end_date)}`); + if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POPX WHERE POPX.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND POPX.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)})`); + if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POPX WHERE POPX.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND POPX.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)})`); + if (filter.part_spec) where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POPX WHERE POPX.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND POPX.SPEC ILIKE ${addParam(`%${filter.part_spec}%`)})`); + + const whereSql = `WHERE ${where.join(" AND ")}`; + const havingSql = + filter.delivery_status + ? `HAVING (CASE WHEN COALESCE(S1.TOTAL_PO_QTY,0) - COALESCE(S1.TOTAL_DELIVERY_QTY,0) <= 0 THEN '입고완료' + WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연' + ELSE '입고중' END) = ${addParam(filter.delivery_status)}` + : ""; + + const fromSql = ` + FROM PURCHASE_ORDER_MASTER POM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = POM.CONTRACT_MGMT_OBJID + LEFT JOIN ( + SELECT POP.PURCHASE_ORDER_MASTER_OBJID, + SUM(COALESCE(POP.ORDER_QTY::NUMERIC, 0)) AS TOTAL_PO_QTY, + MAX(AP_AGG.MAX_RECEIPT_DATE) AS CUR_DELIVERY_DATE, + SUM(COALESCE(AP_AGG.SUM_RECEIPT_QTY, 0)) AS TOTAL_DELIVERY_QTY, + SUM(COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(POP.ORDER_QTY::NUMERIC, 0)) AS TOTAL_SUPPLY_PRICE, + SUM(COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(AP_AGG.SUM_RECEIPT_QTY, 0)) AS TOTAL_DELIVERY_PRICE, + SUM(COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * + (COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.SUM_RECEIPT_QTY, 0))) AS TOTAL_NOT_DELIVERY_PRICE + FROM PURCHASE_ORDER_PART POP + LEFT JOIN ( + SELECT PARENT_OBJID, PART_OBJID, + SUM(COALESCE(RECEIPT_QTY::NUMERIC, 0)) AS SUM_RECEIPT_QTY, + MAX(RECEIPT_DATE) AS MAX_RECEIPT_DATE + FROM ARRIVAL_PLAN + GROUP BY PARENT_OBJID, PART_OBJID + ) AP_AGG ON AP_AGG.PARENT_OBJID = POP.PURCHASE_ORDER_MASTER_OBJID + AND AP_AGG.PART_OBJID = POP.PART_OBJID + GROUP BY POP.PURCHASE_ORDER_MASTER_OBJID + ) S1 ON POM.OBJID = S1.PURCHASE_ORDER_MASTER_OBJID + ${whereSql} + `; + + const groupBySql = havingSql ? `GROUP BY POM.OBJID, S1.TOTAL_PO_QTY, S1.TOTAL_DELIVERY_QTY, POM.DELIVERY_DATE` : ""; + + const dataSql = ` + SELECT + POM.OBJID AS objid, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + POM.STATUS AS status, + (SELECT REQUEST_MNG_NO FROM SALES_REQUEST_MASTER SRM WHERE SRM.OBJID = POM.SALES_REQUEST_OBJID LIMIT 1) AS proposal_no, + CM.PROJECT_NO AS project_no, + -- 첫 품번/품명 + "외 N건" + (SELECT CASE WHEN COUNT(*) > 1 THEN MIN(PART_NO) || ' 외 ' || (COUNT(*) - 1) || '건' ELSE MIN(PART_NO) END + FROM PURCHASE_ORDER_PART POPN WHERE POPN.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_no, + (SELECT CASE WHEN COUNT(*) > 1 THEN MIN(PART_NAME) || ' 외 ' || (COUNT(*) - 1) || '건' ELSE MIN(PART_NAME) END + FROM PURCHASE_ORDER_PART POPN WHERE POPN.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_name, + POM.PARTNER_OBJID AS partner_objid, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID = POM.PARTNER_OBJID LIMIT 1) AS partner_name, + (SELECT CC.CODE_NAME FROM COMM_CODE CC + WHERE CC.CODE_ID = (SELECT POP2.CURRENCY FROM PURCHASE_ORDER_PART POP2 + WHERE POP2.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID + AND POP2.CURRENCY IS NOT NULL AND POP2.CURRENCY <> '' LIMIT 1) + LIMIT 1) AS currency_name, + POM.WRITER AS writer, + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER LIMIT 1), POM.WRITER, '') AS writer_name, + (SELECT COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = AP.WRITER), AP.WRITER, '') + FROM ARRIVAL_PLAN AP + WHERE AP.PARENT_OBJID = POM.OBJID + AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0 + ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_writer_name, + (SELECT AP.RECEIPT_DATE FROM ARRIVAL_PLAN AP + WHERE AP.PARENT_OBJID = POM.OBJID + AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0 + ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_regdate, + COALESCE(S1.TOTAL_PO_QTY, 0) AS total_po_qty, + COALESCE(S1.TOTAL_DELIVERY_QTY, 0) AS total_delivery_qty, + COALESCE(S1.TOTAL_PO_QTY, 0) - COALESCE(S1.TOTAL_DELIVERY_QTY, 0) AS non_delivery_qty, + COALESCE(S1.TOTAL_SUPPLY_PRICE, 0) AS total_supply_price, + COALESCE(S1.TOTAL_DELIVERY_PRICE, 0) AS total_delivery_price, + COALESCE(S1.TOTAL_NOT_DELIVERY_PRICE, 0) AS total_not_delivery_price, + (SELECT COUNT(1)::int FROM ATTACH_FILE_INFO AF + WHERE AF.TARGET_OBJID = POM.OBJID + AND AF.DOC_TYPE = 'INSPECTION_FILE' + AND UPPER(COALESCE(AF.STATUS, 'Active')) = 'ACTIVE') AS inspection_file_cnt, + CASE WHEN COALESCE(S1.TOTAL_PO_QTY, 0) - COALESCE(S1.TOTAL_DELIVERY_QTY, 0) <= 0 THEN '입고완료' + WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연' + ELSE '입고중' + END AS delivery_status, + POM.PURCHASE_CLOSE_DATE AS purchase_close_date + ${fromSql} + ${groupBySql} + ${havingSql} + ORDER BY POM.REGDATE DESC + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)} + `; + const countSql = `SELECT COUNT(*)::int AS cnt ${fromSql} ${groupBySql} ${havingSql}`; + 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("listInbound 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } } -// ─── 5) 품목별 입고관리 (wace deliveryMngPartList) ── +// ─── 5) 품목별 입고관리 (wace deliveryMngPartList 매퍼 1:1) ── +// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6309-6543 +// PURCHASE_ORDER_PART 행별 1행 + AP_AGG (입고집계) + IID_AGG/DEFECT_AGG (검사 — RPS 미존재로 0). + export async function listInboundByItem(filter: PurchaseListFilter): Promise> { - const { page, pageSize } = clampPaging(filter); - logger.warn("listInboundByItem: purchase_order_part / arrival_plan 미존재 — 빈 응답"); - return { rows: [], totalCount: 0, page, pageSize }; + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = [ + `POM.MAIL_SEND_DATE IS NOT NULL`, + `POM.STATUS = 'create'`, + `(POM.MULTI_MASTER_YN = 'Y' OR COALESCE(POM.MULTI_MASTER_YN, '') <> 'Y' AND COALESCE(POM.MULTI_YN, '') <> 'Y')`, + ]; + const params: any[] = []; + const addParam = (val: any) => { params.push(val); return `$${params.length}`; }; + + if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(String(filter.year))}`); + if (filter.customer_cd) where.push(`CM.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', '')`); + if (filter.project_no) where.push(`CM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`); + if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`); + if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`); + if (filter.sales_mng_user_id) where.push(`POM.WRITER = ${addParam(filter.sales_mng_user_id)}`); + if (filter.delivery_start_date) where.push(`POP.DELIVERY_REQUEST_DATE >= ${addParam(filter.delivery_start_date)}`); + if (filter.delivery_end_date) where.push(`POP.DELIVERY_REQUEST_DATE <= ${addParam(filter.delivery_end_date)}`); + if (filter.reg_start_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') >= ${addParam(filter.reg_start_date)}`); + if (filter.reg_end_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') <= ${addParam(filter.reg_end_date)}`); + if (filter.part_no) where.push(`POP.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`); + if (filter.part_name) where.push(`POP.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`); + if (filter.part_spec) where.push(`POP.SPEC ILIKE ${addParam(`%${filter.part_spec}%`)}`); + if (filter.delivery_status) { + where.push(`(CASE WHEN COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0) <= 0 THEN '입고완료' + WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연' + ELSE '입고중' END) = ${addParam(filter.delivery_status)}`); + } + + const whereSql = `WHERE ${where.join(" AND ")}`; + const fromSql = ` + FROM PURCHASE_ORDER_PART POP + JOIN PURCHASE_ORDER_MASTER POM ON POM.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = POM.CONTRACT_MGMT_OBJID + LEFT JOIN ( + SELECT PARENT_OBJID, PART_OBJID, SUM(COALESCE(RECEIPT_QTY::NUMERIC, 0)) AS DELIVERY_QTY + FROM ARRIVAL_PLAN + GROUP BY PARENT_OBJID, PART_OBJID + ) AP_AGG ON AP_AGG.PARENT_OBJID = POM.OBJID AND AP_AGG.PART_OBJID = POP.PART_OBJID + ${whereSql} + `; + + const dataSql = ` + SELECT + POP.OBJID AS objid, + POP.OBJID AS purchase_order_part_objid, + POM.OBJID AS purchase_order_master_objid, + POM.STATUS AS status, + (SELECT REQUEST_MNG_NO FROM SALES_REQUEST_MASTER SRM WHERE SRM.OBJID = POM.SALES_REQUEST_OBJID LIMIT 1) AS proposal_no, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + CM.PROJECT_NO AS project_no, + -- 부품품번 (sales_request_part 미존재 → POP.PART_NO fallback) + POP.PART_NO AS component_part_no, + POP.PART_NO AS part_no, + POP.PART_NAME AS part_name, + POM.PARTNER_OBJID AS partner_objid, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID = POM.PARTNER_OBJID LIMIT 1) AS partner_name, + (SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = POP.CURRENCY LIMIT 1) AS currency_name, + POP.DELIVERY_REQUEST_DATE AS delivery_request_date, + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER LIMIT 1), POM.WRITER, '') AS writer_name, + (SELECT COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = AP.WRITER), AP.WRITER, '') + FROM ARRIVAL_PLAN AP + WHERE AP.PARENT_OBJID = POM.OBJID + AND AP.PART_OBJID = POP.PART_OBJID + AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0 + ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_writer_name, + (SELECT AP.RECEIPT_DATE FROM ARRIVAL_PLAN AP + WHERE AP.PARENT_OBJID = POM.OBJID + AND AP.PART_OBJID = POP.PART_OBJID + AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0 + ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_regdate, + COALESCE(POP.ORDER_QTY::NUMERIC, 0) AS order_qty, + COALESCE(AP_AGG.DELIVERY_QTY, 0) AS delivery_qty, + COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0) AS non_delivery_qty, + COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(POP.ORDER_QTY::NUMERIC, 0) AS total_supply_price, + COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(AP_AGG.DELIVERY_QTY, 0) AS total_delivery_price, + COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * + (COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0)) AS total_not_delivery_price, + -- 검사현황/폐기/확정수량 (incoming_inspection_* 미존재 → 0) + '' AS inspection_status, + 0 AS defect_qty, + COALESCE(AP_AGG.DELIVERY_QTY, 0) AS confirmed_qty, + CASE WHEN COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0) <= 0 THEN '입고완료' + WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연' + ELSE '입고중' + END AS delivery_status + ${fromSql} + ORDER BY POM.REGDATE DESC, POP.OBJID + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)} + `; + const countSql = `SELECT COUNT(*)::int AS cnt ${fromSql}`; + 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("listInboundByItem 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } } -// ─── 6) 입고일별 입고관리 (wace purchaseCloseList) ── +// ─── 6) 입고일별 입고관리 (wace purchaseCloseList 매퍼 1:1) ── +// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6549-6765 +// ARRIVAL_PLAN 행별 (RECEIPT_QTY > 0) + 매입마감/관세/세금계산서 컬럼. + export async function listInboundByDate(filter: PurchaseListFilter): Promise> { - const { page, pageSize } = clampPaging(filter); - logger.warn("listInboundByDate: arrival_plan / purchase_order_part 미존재 — 빈 응답"); - return { rows: [], totalCount: 0, page, pageSize }; + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = [ + `POM.MAIL_SEND_DATE IS NOT NULL`, + `POM.STATUS = 'create'`, + `COALESCE(AP.RECEIPT_QTY, '0')::NUMERIC > 0`, + ]; + const params: any[] = []; + const addParam = (val: any) => { params.push(val); return `$${params.length}`; }; + + if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(String(filter.year))}`); + if (filter.customer_cd) where.push(`CM.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', '')`); + if (filter.project_no) where.push(`CM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`); + if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`); + if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`); + if (filter.sales_mng_user_id) where.push(`POM.WRITER = ${addParam(filter.sales_mng_user_id)}`); + if (filter.part_no) where.push(`POP.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`); + if (filter.part_name) where.push(`POP.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`); + if (filter.part_spec) where.push(`POP.SPEC ILIKE ${addParam(`%${filter.part_spec}%`)}`); + if (filter.receipt_date_start) where.push(`AP.RECEIPT_DATE >= ${addParam(filter.receipt_date_start)}`); + if (filter.receipt_date_end) where.push(`AP.RECEIPT_DATE <= ${addParam(filter.receipt_date_end)}`); + if (filter.close_status === "Y") where.push(`AP.PURCHASE_CLOSE_DATE IS NOT NULL AND AP.PURCHASE_CLOSE_DATE <> ''`); + if (filter.close_status === "N") where.push(`(AP.PURCHASE_CLOSE_DATE IS NULL OR AP.PURCHASE_CLOSE_DATE = '')`); + + const whereSql = `WHERE ${where.join(" AND ")}`; + const fromSql = ` + FROM ARRIVAL_PLAN AP + JOIN PURCHASE_ORDER_MASTER POM ON POM.OBJID = AP.PARENT_OBJID + LEFT JOIN PURCHASE_ORDER_PART POP + ON POP.PURCHASE_ORDER_MASTER_OBJID = AP.PARENT_OBJID + AND POP.PART_OBJID = AP.PART_OBJID + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = POM.CONTRACT_MGMT_OBJID + ${whereSql} + `; + const dataSql = ` + SELECT + AP.OBJID AS objid, + AP.OBJID AS arrival_plan_objid, + POP.OBJID AS purchase_order_part_objid, + POM.OBJID AS purchase_order_master_objid, + (SELECT REQUEST_MNG_NO FROM SALES_REQUEST_MASTER SRM WHERE SRM.OBJID = POM.SALES_REQUEST_OBJID LIMIT 1) AS proposal_no, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + CM.PROJECT_NO AS project_no, + POP.PART_NO AS component_part_no, + POP.PART_NO AS part_no, + POP.PART_NAME AS part_name, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID = POM.PARTNER_OBJID LIMIT 1) AS partner_name, + (SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = POP.CURRENCY LIMIT 1) AS currency_name, + AP.RECEIPT_DATE AS receipt_date, + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER LIMIT 1), POM.WRITER, '') AS writer_name, + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = AP.WRITER LIMIT 1), AP.WRITER, '') AS delivery_writer_name, + COALESCE(AP.RECEIPT_QTY::NUMERIC, 0) AS receipt_qty, + COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(AP.RECEIPT_QTY::NUMERIC, 0) AS total_delivery_price, + '' AS inspection_status, + 0 AS defect_qty, + COALESCE(AP.RECEIPT_QTY::NUMERIC, 0) AS confirmed_qty, + AP.SUB_LOCATION AS sub_location_name, + (SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = AP.FOREIGN_TYPE LIMIT 1) AS foreign_type_name, + AP.EXCHANGE_RATE AS exchange_rate, + (SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = AP.TAX_TYPE LIMIT 1) AS tax_type_name, + AP.TAX_INVOICE_DATE AS tax_invoice_date, + AP.EXPORT_DECL_NO AS export_decl_no, + AP.LOADING_DATE AS loading_date, + AP.DUTY AS duty, + AP.IMPORT_VAT AS import_vat, + AP.PURCHASE_CLOSE_DATE AS purchase_close_date + ${fromSql} + ORDER BY AP.RECEIPT_DATE DESC NULLS LAST, AP.OBJID + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)} + `; + const countSql = `SELECT COUNT(*)::int AS cnt ${fromSql}`; + 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("listInboundByDate 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } } // ─── 7) 프로젝트별 발주/입고 현황 (wace projectPurchaseDeliveryStatus) ── @@ -340,28 +756,95 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise 0) + COALESCE((SELECT COUNT(DISTINCT AP.PART_OBJID)::int + FROM ARRIVAL_PLAN AP + JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID + WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID + AND POM3.MAIL_SEND_DATE IS NOT NULL + AND POM3.STATUS = 'create' + AND COALESCE(AP.RECEIPT_QTY::numeric, 0) > 0), 0) AS dlv_item_cnt, + COALESCE((SELECT SUM(COALESCE(AP.RECEIPT_QTY::numeric, 0)) + FROM ARRIVAL_PLAN AP + JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID + WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID + AND POM3.MAIL_SEND_DATE IS NOT NULL + AND POM3.STATUS = 'create'), 0) AS dlv_qty, + -- 미입고 = 발주 - 입고 (음수 방지) + GREATEST( + COALESCE((SELECT COUNT(DISTINCT POP.PART_OBJID)::int + FROM PURCHASE_ORDER_PART POP + JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID + WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID + AND POM2.MAIL_SEND_DATE IS NOT NULL + AND POM2.STATUS = 'create'), 0) + - COALESCE((SELECT COUNT(DISTINCT AP.PART_OBJID)::int + FROM ARRIVAL_PLAN AP + JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID + WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID + AND POM3.MAIL_SEND_DATE IS NOT NULL + AND POM3.STATUS = 'create' + AND COALESCE(AP.RECEIPT_QTY::numeric, 0) > 0), 0) + , 0) AS non_dlv_item_cnt, + GREATEST( + COALESCE((SELECT SUM(COALESCE(POP.ORDER_QTY::numeric, 0)) + FROM PURCHASE_ORDER_PART POP + JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID + WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID + AND POM2.MAIL_SEND_DATE IS NOT NULL + AND POM2.STATUS = 'create'), 0) + - COALESCE((SELECT SUM(COALESCE(AP.RECEIPT_QTY::numeric, 0)) + FROM ARRIVAL_PLAN AP + JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID + WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID + AND POM3.MAIL_SEND_DATE IS NOT NULL + AND POM3.STATUS = 'create'), 0) + , 0) AS non_dlv_qty FROM PROJECT_MGMT PM LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID ${whereSql} @@ -386,8 +869,216 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise> { + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = [ + // wace 운영판 동일: 동시발주 마스터 또는 비-동시발주만 노출 + `(POM.MULTI_MASTER_YN = 'Y' OR COALESCE(POM.MULTI_MASTER_YN, '') <> 'Y' AND COALESCE(POM.MULTI_YN, '') <> 'Y')`, + ]; + const params: any[] = []; + const addParam = (val: any) => { params.push(val); return `$${params.length}`; }; + + if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(filter.year)}`); + if (filter.customer_cd) { + where.push(`EXISTS (SELECT 1 FROM PROJECT_MGMT S_P WHERE POM.CONTRACT_MGMT_OBJID = S_P.OBJID AND S_P.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', ''))`); + } + if (filter.project_no) { + // CSV 다중 선택 지원 (wace project_nos 와 동일) + const ids = String(filter.project_no).split(",").map((s) => s.trim()).filter(Boolean); + if (ids.length > 0) { + const ph = ids.map((v) => addParam(v)).join(","); + where.push(`POM.CONTRACT_MGMT_OBJID IN (${ph})`); + } + } + if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`); + if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`); + if (filter.delivery_start_date) where.push(`POM.DELIVERY_DATE::DATE >= ${addParam(filter.delivery_start_date)}::DATE`); + if (filter.delivery_end_date) where.push(`POM.DELIVERY_DATE::DATE <= ${addParam(filter.delivery_end_date)}::DATE`); + if (filter.reg_start_date) where.push(`POM.MAIL_SEND_DATE::DATE >= ${addParam(filter.reg_start_date)}::DATE`); + if (filter.reg_end_date) where.push(`POM.MAIL_SEND_DATE::DATE <= ${addParam(filter.reg_end_date)}::DATE`); + if (filter.category_cd) where.push(`CM.CATEGORY_CD = ${addParam(filter.category_cd)}`); + if (filter.product_cd) where.push(`CM.PRODUCT = ${addParam(filter.product_cd)}`); + if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${addParam(filter.purchase_type)}`); + if (filter.writer) where.push(`POM.WRITER = ${addParam(filter.writer)}`); + if (filter.part_no) { + where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POP WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND TRIM(UPPER(POP.PART_NO)) LIKE '%' || TRIM(UPPER(${addParam(filter.part_no)})) || '%')`); + } + if (filter.part_name) { + where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POP WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND TRIM(UPPER(POP.PART_NAME)) LIKE '%' || TRIM(UPPER(${addParam(filter.part_name)})) || '%')`); + } + if (filter.mail_send_yn === "Y") { + where.push(`POM.MAIL_SEND_YN = 'Y' AND (POM.STATUS IS NULL OR POM.STATUS <> 'orderCancel')`); + } else if (filter.mail_send_yn === "orderCancel") { + where.push(`POM.STATUS = 'orderCancel'`); + } else if (filter.mail_send_yn === "N") { + where.push(`(POM.MAIL_SEND_YN IS NULL OR POM.MAIL_SEND_YN = '' OR POM.MAIL_SEND_YN = 'N') AND (POM.STATUS IS NULL OR POM.STATUS <> 'orderCancel')`); + } + + const whereSql = `WHERE ${where.join(" AND ")}`; + + const fromJoinSql = ` + FROM PURCHASE_ORDER_MASTER POM + LEFT JOIN ( + SELECT B.OBJID AS ROUTE_OBJID, + B.STATUS AS APPR_STATUS, + CASE B.STATUS + WHEN 'inProcess' THEN '결재중' + WHEN 'complete' THEN '결재완료' + WHEN 'reject' THEN '반려' + WHEN 'cancel' THEN '취소' + ELSE '' END AS APPR_STATUS_NAME, + A.OBJID AS APPROVAL_OBJID, + A.TARGET_OBJID, + B.ROUTE_SEQ, + TO_CHAR(B.REGDATE, 'YYYY-MM-DD') AS APPR_DATE + FROM APPROVAL A, + (SELECT T1.* + FROM (SELECT TARGET_OBJID, MAX(T.ROUTE_SEQ) AS ROUTE_SEQ + FROM ROUTE T GROUP BY T.TARGET_OBJID) T, + ROUTE T1 + WHERE T.TARGET_OBJID = T1.TARGET_OBJID + AND T.ROUTE_SEQ = T1.ROUTE_SEQ + ) B + WHERE A.OBJID = B.APPROVAL_OBJID + AND A.TARGET_TYPE = 'PURCHASE_ORDER' + ) A ON POM.OBJID::VARCHAR = A.TARGET_OBJID::VARCHAR + LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID + LEFT JOIN SALES_REQUEST_MASTER SRM + ON POM.SALES_REQUEST_OBJID = SRM.OBJID + AND SRM.DOC_TYPE IN ('PROPOSAL', 'PURCHASE_REG_PROPOSAL') + `; + + const dataSql = ` + SELECT + POM.OBJID AS objid, + (SELECT ARRAY_TO_STRING(ARRAY_AGG(OBJID), ',') FROM PURCHASE_ORDER_MASTER S + WHERE POM.OBJID = S.MULTI_MASTER_OBJID) AS multi_objids, + TO_CHAR(POM.REGDATE, 'YYYY') AS po_year, + (SELECT SUPPLY_NAME FROM SUPPLY_MNG O + WHERE O.OBJID::VARCHAR = CM.CUSTOMER_OBJID) AS customer_name, + CM.CUSTOMER_PROJECT_NAME AS customer_project_name, + CM.PROJECT_NO AS project_no, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + (CASE WHEN POM.MULTI_YN = 'Y' AND POM.MULTI_MASTER_YN <> 'Y' THEN 'ㅡ' ELSE '' END) + || COALESCE(POM.TITLE, '') AS title, + POM.DELIVERY_PLACE AS delivery_place, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.DELIVERY_PLACE) AS delivery_place_name, + POM.INSPECT_METHOD AS inspect_method, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.INSPECT_METHOD) AS inspect_method_name, + POM.PAYMENT_TERMS AS payment_terms, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.PAYMENT_TERMS) AS payment_terms_name, + POM.DELIVERY_DATE AS delivery_date, + POM.TYPE AS type, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.TYPE) AS type_name, + POM.PARTNER_OBJID AS partner_objid, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = POM.PARTNER_OBJID) + AS partner_name, + POM.SALES_MNG_USER_ID AS sales_mng_user_id, + (SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.SALES_MNG_USER_ID) AS sales_mng_user_name, + TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') AS regdate, + POM.TOTAL_PRICE AS total_price, + POM.TOTAL_PRICE_ALL AS total_price_all, + POM.DISCOUNT_PRICE AS discount_price, + POM.DISCOUNT_PRICE_ALL AS discount_price_all, + POM.TOTAL_SUPPLY_PRICE AS total_supply_price, + POM.TOTAL_SUPPLY_UNIT_PRICE AS total_supply_unit_price, + POM.TOTAL_REAL_SUPPLY_PRICE AS total_real_supply_price, + POM.NEGO_RATE AS nego_rate, + POM.WRITER AS writer, + (SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER) AS writer_name, + POM.ORDER_TYPE_CD AS order_type_cd, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.ORDER_TYPE_CD) AS order_type_cd_name, + POM.UNIT_CODE AS unit_code, + (SELECT O.UNIT_NO || '-' || O.TASK_NAME FROM PMS_WBS_TASK O WHERE O.OBJID = POM.UNIT_CODE) + AS unit_name, + POM.MULTI_YN AS multi_yn, + POM.MULTI_MASTER_YN AS multi_master_yn, + POM.MULTI_MASTER_OBJID AS multi_master_objid, + CASE WHEN POM.MULTI_MASTER_YN = 'Y' THEN '' ELSE POM.MULTI_YN END AS multi_yn_maked, + POM.MAIL_SEND_YN AS mail_send_yn, + POM.MAIL_SEND_DATE AS mail_send_date, + POM.STATUS AS status, + COALESCE(POM.FORM_TYPE, 'general') AS form_type, + A.APPR_STATUS AS appr_status, + CASE WHEN POM.STATUS = 'cancel' THEN '취소' + ELSE COALESCE(A.APPR_STATUS_NAME, '작성중') + END AS appr_status_name, + A.ROUTE_OBJID AS route_objid, + A.APPROVAL_OBJID AS approval_objid, + A.APPR_DATE AS appr_date, + POM.SALES_REQUEST_OBJID AS sales_request_objid, + SRM.REQUEST_MNG_NO AS proposal_no, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE) AS purchase_type_name, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT) AS product_name, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD) AS category_name, + (SELECT CASE WHEN COUNT(*) > 1 + THEN MIN(POP.PART_NO) || ' 외 ' || (COUNT(*) - 1) || '건' + ELSE MIN(POP.PART_NO) + END + FROM PURCHASE_ORDER_PART POP + WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_no, + (SELECT CASE WHEN COUNT(*) > 1 + THEN MIN(POP.PART_NAME) || ' 외 ' || (COUNT(*) - 1) || '건' + ELSE MIN(POP.PART_NAME) + END + FROM PURCHASE_ORDER_PART POP + WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_name, + (SELECT CC.CODE_NAME FROM COMM_CODE CC + WHERE CC.CODE_ID = (SELECT POP.CURRENCY FROM PURCHASE_ORDER_PART POP + WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID::VARCHAR + AND POP.CURRENCY IS NOT NULL AND POP.CURRENCY <> '' + LIMIT 1)) AS currency_name + ${fromJoinSql} + ${whereSql} + ORDER BY POM.REGDATE DESC NULLS LAST, + COALESCE(NULLIF(SPLIT_PART(POM.PURCHASE_ORDER_NO, '-', 3), ''), '0')::NUMERIC DESC + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)} + `; + const countSql = `SELECT COUNT(*)::int AS cnt ${fromJoinSql} ${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("listPurchaseOrderList 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } +} + // ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ────────────────── +// 견적요청서 / 발주서 vendor — wace 매퍼는 client_mng 와 매칭 (supply_mng 아님) +export async function listVendorOptions(): Promise<{ code: string; label: string }[]> { + const pool = getPool(); + try { + const r = await pool.query( + `SELECT OBJID AS code, CLIENT_NM AS label + FROM CLIENT_MNG + WHERE COALESCE(STATUS, 'Y') IN ('Y', 'active', 'ACTIVE', '활성') + AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> '' + ORDER BY CLIENT_NM + LIMIT 2000`, + ); + return r.rows; + } catch { + return []; + } +} + export async function listSupplierOptions(): Promise<{ code: string; label: string }[]> { const pool = getPool(); try { 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/docs/migration/purchase/data-sync/01_quotation_request_sync.sql b/docs/migration/purchase/data-sync/01_quotation_request_sync.sql new file mode 100644 index 00000000..d9c82242 --- /dev/null +++ b/docs/migration/purchase/data-sync/01_quotation_request_sync.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- 견적요청서 운영 sample 데이터 → RPS 이관 +-- 운영: 211.115.91.141:11133/waceplm +-- quotation_request_master 4건 / quotation_request_detail 4건 +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 함정: +-- 1) objid / sales_request_master_objid / project_mgmt_objid : numeric → varchar +-- 2) detail.part_objid : numeric → bigint (RPS part_mng.objid bigint 호환) +-- 3) FK 미매칭 sales_request_part_objid 는 NULL 처리 +-- +-- 멱등성: ON CONFLICT DO NOTHING. +-- ============================================================ + +-- ── master ──────────────────────────────────────────────────── +INSERT INTO quotation_request_master + (objid, quotation_request_no, sales_request_master_objid, project_mgmt_objid, + vendor_objid, vendor_type, status, mail_send_date, mail_send_yn, due_date, + remark, writer, reg_date, edit_date) +VALUES + ('-1554146727','Q20260401-115','-722096187','-1752090174','0000000007','SUPPLY','received','2026-04-03 04:36:11.666917','Y','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.812611','2026-04-01 07:16:21.513931'), + ('-1629785580','Q20260401-116','-722096187','-1752090174','0000000012','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.814099',NULL), + ('185180465','Q20260401-118','-722096187','-1752090174','0000008379','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.836506',NULL), + ('211976545','Q20260401-119','-722096187','-1752090174','0000012062','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.841764',NULL) +ON CONFLICT (objid) DO NOTHING; + +-- ── detail ──────────────────────────────────────────────────── +INSERT INTO quotation_request_detail + (objid, quotation_request_master_objid, sales_request_part_objid, part_objid, + part_no, part_name, raw_material, size, qty, unit_price, total_price, + remark, delivery_request_date, reg_date, edit_date) +VALUES + ('-1266428262','-1554146727','-1279349416',1868255637,'C3P50L22','Ti(GR5)','Ti(GR5)','Ø50*22',0,10000,0,NULL,'2026-04-03','2026-04-01 07:11:30.812611','2026-04-01 07:16:21.513931'), + ('-2130546975','-1629785580','1187291883',1868255516,'10024-0066','SHEET',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.814099',NULL), + ('-392083183','185180465','-1279349416',1868255637,'10026-0031','HOLDER',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.836506',NULL), + ('-563828077','211976545','-1291084031',1868257572,'30004-0098','NUT',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.841764',NULL) +ON CONFLICT (objid) DO NOTHING; + +-- FK 미매칭 sales_request_part_objid 는 NULL 처리 (현재 RPS sales_request_part 0건) +UPDATE quotation_request_detail + SET sales_request_part_objid = NULL + WHERE sales_request_part_objid IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sales_request_part WHERE objid = quotation_request_detail.sales_request_part_objid + ); + +-- FK 미매칭 part_objid 는 NULL 처리 (RPS part_mng 와 매칭 안 되면) +UPDATE quotation_request_detail + SET part_objid = NULL + WHERE part_objid IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM part_mng WHERE objid = quotation_request_detail.part_objid); diff --git a/docs/migration/purchase/data-sync/02_purchase_inbound_sync.sql b/docs/migration/purchase/data-sync/02_purchase_inbound_sync.sql new file mode 100644 index 00000000..671511ce --- /dev/null +++ b/docs/migration/purchase/data-sync/02_purchase_inbound_sync.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 발주/입고 운영 sample 데이터 → RPS 이관 +-- 운영: 211.115.91.141:11133/waceplm +-- purchase_order_master 1건 / purchase_order_part 1건 / arrival_plan 1건 +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- FK 매칭 (확인): +-- sales_request_objid='-233034270' → RPS sales_request_master.objid (있음) +-- contract_mgmt_objid='-1752090174' → 운영DB project_mgmt.objid (RPS contract_mgmt 미매칭, project_mgmt 매칭) +-- part_objid=1868260552 → RPS part_mng (있음) +-- partner_objid='0000000007' → RPS client_mng 서울반도체(주) (있음) +-- +-- 멱등성: ON CONFLICT DO NOTHING +-- ============================================================ + +-- ── purchase_order_master (RPS 이미 존재하면 mail_send_* 만 보강) ── +-- PK constraint 없어 ON CONFLICT 사용 불가 → WHERE NOT EXISTS 패턴 +INSERT INTO purchase_order_master + (objid, purchase_order_no, partner_objid, contract_mgmt_objid, sales_request_objid, + regdate, writer, status, mail_send_yn, mail_send_date, + sales_mng_user_id, payment_terms) +SELECT + '-2135417309','RPS26-0401-01','0000000007','-1752090174','-233034270', + '2026-04-01 07:20:58.687075','ady1225','create','Y','2026-04-03', + 'ish0312','0001069' +WHERE NOT EXISTS (SELECT 1 FROM purchase_order_master WHERE objid='-2135417309'); + +-- 이미 있던 행에는 매퍼 필수 필드(mail_send_*) 보강 +UPDATE purchase_order_master + SET mail_send_yn='Y', mail_send_date='2026-04-03' + WHERE objid='-2135417309' + AND COALESCE(mail_send_yn,'') = ''; + +-- ── purchase_order_part ─────────────────────────────────────── +INSERT INTO purchase_order_part + (objid, purchase_order_master_objid, part_objid, order_qty, partner_price, + remark, writer, regdate, part_name, spec, supply_unit_price, unit, + part_no, qty, part_delivery_place, delivery_request_date) +VALUES + ('-192149597','-2135417309',1868260552,'1','10000', + 'W/M ASSY (RWMR1070-NO07 LH) / HOLDER','ady1225','2026-04-01 07:20:58.687075', + 'Ti(GR5)','Ø50*22','10000','0001400','C3P50L22','1','RPS','2026-04-03') +ON CONFLICT (objid) DO NOTHING; + +-- ── arrival_plan ────────────────────────────────────────────── +INSERT INTO arrival_plan + (objid, parent_objid, order_part_objid, part_objid, + arrival_qty, receipt_qty, receipt_date, location, + writer, group_seq, seq, inventory_status, sub_location, receiver_id) +VALUES + ('1030275443','-2135417309','-192149597',1868260552, + '1','1','2026-04-01','L101', + 'ady1225','1','1','Y','1490000','ady1225') +ON CONFLICT (objid) DO NOTHING; + +-- 검증: 매퍼 WHERE (mail_send_date IS NOT NULL AND status='create') 통과 여부 +-- SELECT pom.purchase_order_no, pop.part_no, ap.receipt_date +-- FROM purchase_order_master pom +-- JOIN purchase_order_part pop ON pop.purchase_order_master_objid = pom.objid +-- LEFT JOIN arrival_plan ap ON ap.parent_objid = pom.objid AND ap.part_objid = pop.part_objid +-- WHERE pom.mail_send_date IS NOT NULL AND pom.status = 'create'; diff --git a/docs/migration/purchase/ddl-extracted/500_quotation_request.sql b/docs/migration/purchase/ddl-extracted/500_quotation_request.sql new file mode 100644 index 00000000..ab35980b --- /dev/null +++ b/docs/migration/purchase/ddl-extracted/500_quotation_request.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- 견적요청서 (Quotation Request) — 구매관리 단독 +-- 원본: 운영DB 211.115.91.141:11133/waceplm (quotation_request_master 4건, quotation_request_detail 4건) +-- 추출일: 2026-05-15 +-- 적용대상: vexplor_rps (11134) +-- +-- 운영 ↔ RPS 타입 차이 (feedback_createobjid_pattern.md): +-- 운영: quotation_request_master.objid numeric → RPS varchar(64) +-- 운영: sales_request_master_objid / project_mgmt_objid numeric → RPS varchar(64) (FK 호환) +-- 운영: detail.part_objid numeric → RPS bigint (part_mng.objid bigint 호환) +-- 운영: detail.sales_request_part_objid numeric → RPS varchar(64) +-- +-- 비즈니스 흐름: +-- 구매리스트(sales_request_master) → 견적요청서(quotation_request_master + detail) +-- → 품의서 → 발주서(purchase_order_master + part) → 입고(arrival_plan + inventory_*) +-- +-- 매퍼 본문(getQuotationRequestList): wace_plm/src/com/pms/mapper/salesMng.xml:5248-5349 +-- ============================================================ + +-- ── 1. quotation_request_master ────────────────────────────── +CREATE TABLE IF NOT EXISTS quotation_request_master ( + objid varchar(64) NOT NULL, + quotation_request_no varchar(50), + sales_request_master_objid varchar(64), + project_mgmt_objid varchar(64), + vendor_objid varchar(64), + vendor_type varchar(20), + status varchar(50) DEFAULT 'create', + mail_send_date timestamp, + mail_send_yn varchar(1) DEFAULT 'N', + due_date date, + remark text, + writer varchar(50), + reg_date timestamp DEFAULT now(), + edit_date timestamp, + CONSTRAINT quotation_request_master_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_qrm_sales_request ON quotation_request_master (sales_request_master_objid); +CREATE INDEX IF NOT EXISTS idx_qrm_project ON quotation_request_master (project_mgmt_objid); +CREATE INDEX IF NOT EXISTS idx_qrm_vendor ON quotation_request_master (vendor_objid); +CREATE INDEX IF NOT EXISTS idx_qrm_status ON quotation_request_master (status); + +-- ── 2. quotation_request_detail ────────────────────────────── +CREATE TABLE IF NOT EXISTS quotation_request_detail ( + objid varchar(64) NOT NULL, + quotation_request_master_objid varchar(64), + sales_request_part_objid varchar(64), + part_objid bigint, + part_no varchar(100), + part_name varchar(200), + raw_material varchar(100), + size varchar(100), + qty numeric DEFAULT 0, + unit_price numeric DEFAULT 0, + total_price numeric DEFAULT 0, + remark text, + delivery_request_date varchar(10), + reg_date timestamp DEFAULT now(), + edit_date timestamp, + CONSTRAINT quotation_request_detail_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_qrd_master ON quotation_request_detail (quotation_request_master_objid); +CREATE INDEX IF NOT EXISTS idx_qrd_part ON quotation_request_detail (sales_request_part_objid); + +ALTER TABLE quotation_request_detail + DROP CONSTRAINT IF EXISTS fk_qrd_master; +ALTER TABLE quotation_request_detail + ADD CONSTRAINT fk_qrd_master + FOREIGN KEY (quotation_request_master_objid) + REFERENCES quotation_request_master (objid) + ON DELETE CASCADE; diff --git a/docs/migration/purchase/ddl-extracted/501_purchase_inbound.sql b/docs/migration/purchase/ddl-extracted/501_purchase_inbound.sql new file mode 100644 index 00000000..2b11facb --- /dev/null +++ b/docs/migration/purchase/ddl-extracted/501_purchase_inbound.sql @@ -0,0 +1,131 @@ +-- ============================================================ +-- 발주서 + 입고관리 — 구매관리 입고 3메뉴 + 발주서관리 의존 테이블 +-- 원본: 운영DB 211.115.91.141:11133/waceplm +-- purchase_order_master 1건 (mail_send_yn='Y', status='create') +-- purchase_order_part 1건 (RPS26-0401-01 / C3P50L22) +-- arrival_plan 1건 (receipt_qty=1, receipt_date=2026-04-01) +-- 추출일: 2026-05-15 +-- 적용대상: vexplor_rps (11134) +-- +-- 운영 ↔ RPS 타입 차이: +-- part_objid: 운영 varchar(64) → RPS bigint (part_mng.objid bigint 호환) +-- +-- 매퍼: +-- deliveryMngPartList: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6309-6543 +-- purchaseCloseList: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6549-6765 +-- projectPurchaseStat: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6768-6951 +-- +-- 함정: +-- 1) wace 매퍼는 PROJECT_MGMT.OBJID = POM.CONTRACT_MGMT_OBJID 로 LEFT JOIN +-- (즉 contract_mgmt_objid 컬럼명이 실제로는 project_mgmt 키를 저장) +-- 2) WHERE: POM.MAIL_SEND_DATE IS NOT NULL AND POM.STATUS='create' +-- ============================================================ + +-- ── 1. purchase_order_master 보충 컬럼 (10개) ─────────────────── +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS mail_send_yn varchar; +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS mail_send_date varchar; +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS form_type varchar(20); +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS sales_mng_user_id2 varchar(50); +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS request_content text; +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS purchase_close_date varchar(10); +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS shipment varchar; +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS packing varchar; +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS validity varchar; +ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS attn_to varchar; + +-- ── 2. purchase_order_part (운영 43 cols 1:1, part_objid 만 bigint) ─ +CREATE TABLE IF NOT EXISTS purchase_order_part ( + objid varchar(64) NOT NULL, + purchase_order_master_objid varchar(64), + part_objid bigint, + order_qty varchar, + partner_price varchar, + remark varchar, + writer varchar, + regdate timestamp, + status varchar, + part_name varchar, + do_no varchar, + thickness varchar, + width varchar, + height varchar, + out_diameter varchar, + length varchar, + in_diameter varchar, + inven_total_qty varchar, + ld_part_objid varchar, + spec varchar, + maker varchar, + supply_unit_price varchar, + unit varchar, + price1 varchar, + price2 varchar, + price3 varchar, + part_no varchar, + supply_unit_vat_price varchar, + price4 varchar, + supply_unit_vat_sum_price varchar, + total_order_qty varchar, + stock_qty varchar, + real_order_qty varchar, + update_date timestamp, + modifier varchar, + real_supply_price varchar, + bom_qty varchar, + qty varchar, + part_delivery_place varchar(50), + product_name varchar(200), + work_order_no varchar(50), + delivery_request_date varchar(20), + currency varchar, + CONSTRAINT purchase_order_part_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_pop_master ON purchase_order_part (purchase_order_master_objid); +CREATE INDEX IF NOT EXISTS idx_pop_part ON purchase_order_part (part_objid); + +-- ── 3. arrival_plan (운영 37 cols 1:1, part_objid bigint) ─────── +CREATE TABLE IF NOT EXISTS arrival_plan ( + objid varchar(64) NOT NULL, + parent_objid varchar(64), + order_part_objid varchar(64), + part_objid bigint, + arrival_plan_date varchar, + re_arrival_plan_date varchar, + arrival_qty varchar, + receipt_qty varchar, + genuine_qty varchar, + receipt_date varchar, + inspection_date varchar, + location varchar, + error_qty varchar, + error_reason varchar, + attribution varchar, + status varchar, + assembly_status varchar, + writer varchar, + group_seq varchar, + seq varchar, + defect_content varchar, + defect_action varchar, + defect_note varchar, + defect_action_date varchar, + defect_action_title varchar, + inventory_status varchar, + sub_location varchar, + receiver_id varchar, + purchase_close_date varchar, + foreign_type varchar(10), + exchange_rate numeric(15,2), + duty numeric(15,2), + import_vat numeric(15,2), + tax_invoice_date varchar(10), + export_decl_no varchar(100), + loading_date varchar(10), + tax_type varchar(20), + CONSTRAINT arrival_plan_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_arrival_parent ON arrival_plan (parent_objid); +CREATE INDEX IF NOT EXISTS idx_arrival_order_part ON arrival_plan (order_part_objid); +CREATE INDEX IF NOT EXISTS idx_arrival_part ON arrival_plan (part_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/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx index 38703282..96edf1ee 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx @@ -83,7 +83,7 @@ export default function InboundByDatePage() { { key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" }, { key: "component_part_no", label: "부품품번", width: "w-[135px]" }, { key: "part_no", label: "품번", width: "w-[135px]" }, - { key: "part_name", label: "품명", minWidth: "min-w-[180px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[280px]" }, { key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" }, { key: "currency_name", label: "환종", width: "w-[80px]", align: "center" }, { key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx index 3e9953d9..fdeb28c7 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx @@ -81,7 +81,7 @@ export default function InboundByItemPage() { { key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" }, { key: "component_part_no", label: "부품품번", width: "w-[140px]" }, { key: "part_no", label: "품번", width: "w-[140px]" }, - { key: "part_name", label: "품명", minWidth: "min-w-[180px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[280px]" }, { key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" }, { key: "currency_name", label: "환종", width: "w-[80px]", align: "center" }, { key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx index d83db373..e828a19a 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx @@ -88,7 +88,7 @@ export default function InboundPage() { { key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" }, { key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" }, { key: "part_no", label: "품번", width: "w-[140px]" }, - { key: "part_name", label: "품명", minWidth: "min-w-[180px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[280px]" }, { key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" }, { key: "currency_name", label: "환종", width: "w-[80px]", align: "center" }, { key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx index 930f0724..2d3cd49a 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx @@ -85,7 +85,7 @@ export default function PurchaseListPage() { { key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" }, { key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" }, { key: "part_display", label: "품번", width: "w-[160px]" }, - { key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" }, + { key: "part_name_display", label: "품명", minWidth: "min-w-[280px]" }, { key: "has_quotation_request", label: "견적요청서", width: "w-[115px]", align: "center" }, { key: "request_user_name", label: "작성자", width: "w-[115px]", align: "center" }, { key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index a599267a..e1f75167 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -1,1331 +1,239 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Button } from "@/components/ui/button"; +// 구매관리 > 발주서관리 — wace_plm purchaseOrder/purchaseOrderList_new.jsp 1:1 +// 매퍼: wace_plm/src/com/pms/mapper/purchaseOrder.xml purchaseOrderMasterList_new +// 검색 2행: 년도/고객사/프로젝트번호/발주No/공급업체/품번/품명 + +// 입고요청일/발주일/주문유형/제품구분/구매유형/구매담당자/메일발송 +// 그리드 15컬럼: 품의서No/발주서No/프로젝트번호/구매유형/주문유형/제품구분/ +// 품번/품명/공급업체/환종/총액/메일발송/발주일/구매담당자/작성일 + +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, -} from "@/components/ui/dialog"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Badge } from "@/components/ui/badge"; -import { Label } from "@/components/ui/label"; -import { - Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, - ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, - Settings2, GripVertical, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { apiClient } from "@/lib/api/client"; -import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; -import { exportToExcel } from "@/lib/utils/excelExport"; -import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; -import { useTableSettings } from "@/hooks/useTableSettings"; -import { TableSettingsModal } from "@/components/common/TableSettingsModal"; -import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { SmartSelect } from "@/components/common/SmartSelect"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { CustomerSelect } from "@/components/common/CustomerSelect"; +import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar"; +import { PageHeader } from "@/components/common/PageHeader"; +import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase"; +import { apiClient } from "@/lib/api/client"; +import { exportToExcel } from "@/lib/utils/excelExport"; -const MASTER_TABLE = "purchase_order_mng"; -const DETAIL_TABLE = "purchase_detail"; - -const formatNumber = (val: string) => { - const num = val.replace(/[^\d.-]/g, ""); - if (!num) return ""; - const parts = num.split("."); - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); - return parts.join("."); -}; -const parseNumber = (val: string) => val.replace(/,/g, ""); - -const STATUS_OPTIONS = [ - { code: "작성중", label: "작성중" }, - { code: "발주확정", label: "발주확정" }, - { code: "입고완료", label: "입고완료" }, - { code: "취소", label: "취소" }, +const MAIL_SEND_OPTS: SmartSelectOption[] = [ + { code: "Y", label: "발송완료" }, + { code: "N", label: "미발송" }, + { code: "orderCancel", label: "발주취소" }, ]; -const STATUS_BADGE_CLASS: Record = { - "작성중": "bg-warning/10 text-warning border-warning/20", - "발주확정": "bg-primary/10 text-primary border-primary/20", - "입고완료": "bg-success/10 text-success border-success/20", - "취소": "bg-destructive/10 text-destructive border-destructive/20", +const EMPTY_FILTER: PurchaseListFilter = { + year: String(new Date().getFullYear()), + customer_cd: "", project_no: "", purchase_order_no: "", + partner_objid: "", part_no: "", part_name: "", + delivery_start_date: "", delivery_end_date: "", + reg_start_date: "", reg_end_date: "", + category_cd: "", product_cd: "", purchase_type: "", writer: "", + mail_send_yn: "", + page: 1, page_size: 50, }; -const EXCEL_COLUMNS = [ - { key: "purchase_no", label: "발주번호" }, - { key: "order_date", label: "발주일" }, - { key: "supplier_name", label: "공급업체명" }, - { key: "item_code", label: "품번" }, - { key: "item_name", label: "품명" }, - { key: "order_qty", label: "발주수량" }, - { key: "unit_price", label: "단가" }, - { key: "amount", label: "금액" }, - { key: "due_date", label: "납기일" }, -]; +// wace 코드그룹 ID (PurchaseOrderController.matermgmtList 기준) +const CODE_GROUP = { + CATEGORY: "0000167", // 주문유형 + PRODUCT: "0000001", // 제품구분 + PURCHASE: "0001814", // 구매유형 +} as const; -const GRID_COLUMNS_CONFIG = [ - { key: "purchase_no", label: "발주번호" }, - { key: "order_date", label: "발주일" }, - { key: "supplier_name", label: "공급업체" }, - { key: "item_code", label: "품번" }, - { key: "item_name", label: "품명" }, - { key: "spec", label: "규격" }, - { key: "order_qty", label: "발주수량" }, - { key: "received_qty", label: "입고수량" }, - { key: "remain_qty", label: "잔량" }, - { key: "unit_price", label: "단가" }, - { key: "amount", label: "금액" }, - { key: "due_date", label: "납기일" }, - { key: "status", label: "상태" }, - { key: "memo", label: "메모" }, -]; - -const MODAL_DETAIL_COLUMNS = [ - { key: "item_code", label: "품번", width: "min-w-[120px]" }, - { key: "item_name", label: "품명", width: "min-w-[150px]" }, - { key: "supplier", label: "공급업체", width: "min-w-[150px]" }, - { key: "spec", label: "규격", width: "min-w-[80px]" }, - { key: "unit", label: "단위", width: "min-w-[90px]" }, - { key: "order_qty", label: "발주수량", width: "min-w-[90px]" }, - { key: "received_qty", label: "입고수량", width: "min-w-[90px]" }, - { key: "remain_qty", label: "잔량", width: "min-w-[80px]" }, - { key: "unit_price", label: "단가", width: "min-w-[100px]" }, - { key: "amount", label: "금액", width: "min-w-[100px]" }, - { key: "due_date", label: "납기일", width: "min-w-[160px]" }, - { key: "memo", label: "메모", width: "min-w-[120px]" }, -]; - -const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16"; - -function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - return ( - -
- - {col.label} -
-
- ); +async function loadCodes(groupId: string): Promise { + try { + const r = await apiClient.get(`/sales/codes/${groupId}`); + return (r.data?.data ?? []) as OptionItem[]; + } catch { + return []; + } } -export default function PurchaseOrderPage() { - const { user } = useAuth(); - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - const [orders, setOrders] = useState([]); +export default function PurchaseOrderWacePage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); - const [totalCount, setTotalCount] = useState(0); - - // 검색 필터 (DynamicSearchFilter) - const [searchFilters, setSearchFilters] = useState([]); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [isEditMode, setIsEditMode] = useState(false); - const [saving, setSaving] = useState(false); - const [masterForm, setMasterForm] = useState>({}); - const [detailRows, setDetailRows] = useState([]); - - // 품목 선택 모달 - const [itemSelectOpen, setItemSelectOpen] = useState(false); - const [itemSearchKeyword, setItemSearchKeyword] = useState(""); - const [itemSearchResults, setItemSearchResults] = useState([]); - const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); - const [itemSearchDivision, setItemSearchDivision] = useState("all"); - const [itemPage, setItemPage] = useState(1); - const [itemPageSize, setItemPageSize] = useState(20); - const [itemTotalPages, setItemTotalPages] = useState(0); - const [itemTotal, setItemTotal] = useState(0); - const [itemPageInput, setItemPageInput] = useState("1"); - - const [excelUploadOpen, setExcelUploadOpen] = useState(false); - const [categoryOptions, setCategoryOptions] = useState>({}); + const [filter, setFilter] = useState(EMPTY_FILTER); const [checkedIds, setCheckedIds] = useState([]); - // 테이블 설정 - const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const [categoryOpts, setCategoryOpts] = useState([]); + const [productOpts, setProductOpts] = useState([]); + const [purchaseOpts, setPurchaseOpts] = useState([]); - // 발주 목록 컬럼 정의 — 영업관리 4메뉴와 일관성 (DataGrid + logicstudio props) - // 날짜/숫자는 데이터 매핑 단계에서 pre-format. status 는 plain text (영업메뉴 동일). - const orderTableColumns = useMemo(() => { - const numCols = new Set(["order_qty", "received_qty", "remain_qty"]); - const moneyCols = new Set(["unit_price", "amount"]); - return ts.visibleColumns.map((col) => { - const base: DataGridColumn = { - key: col.key, - label: col.label, - align: numCols.has(col.key) || moneyCols.has(col.key) - ? "right" - : col.key === "status" ? "center" : undefined, - }; - if (numCols.has(col.key)) base.formatNumber = true; - if (moneyCols.has(col.key)) base.formatMoney = true; - return base; - }); - }, [ts.visibleColumns]); + const yearOpts = useMemo(() => getYearOptions(), []); - // 모달 품목 테이블 컬럼 순서 (드래그 재정렬) - const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS); - const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); - - useEffect(() => { - const saved = localStorage.getItem(MODAL_COL_ORDER_KEY); - if (saved) { - try { - const order = JSON.parse(saved) as string[]; - const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS; - const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key)); - setModalColumns([...reordered, ...remaining]); - } catch { /* skip */ } - } - }, []); - - const handleModalDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - setModalColumns((prev) => { - const oldIndex = prev.findIndex((c) => c.key === active.id); - const newIndex = prev.findIndex((c) => c.key === over.id); - const next = arrayMove(prev, oldIndex, newIndex); - localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key))); - return next; - }); - }; - - const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소"; - - const visibleModalColumns = useMemo(() => { - return modalColumns.filter((col) => { - if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false; - return true; - }); - }, [modalColumns, masterForm.input_mode]); - - // 카테고리 로드 - useEffect(() => { - const loadCategories = async () => { - const catColumns = ["input_mode", "price_mode"]; - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - const dedup = (items: { code: string; label: string }[]) => { - const seen = new Set(); - return items.filter((item) => { - const key = item.label.replace(/\s/g, ""); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }; - await Promise.all( - catColumns.map(async (col) => { - try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); - } - } catch { /* skip */ } - }) - ); - try { - const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, { - page: 1, size: 0, autoFilter: true, - }); - const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || []; - optMap["supplier_code"] = supps.map((s: any) => ({ - code: s.supplier_code, - label: `${s.supplier_name} (${s.supplier_code})`, - })); - } catch { /* skip */ } - try { - const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, size: 0, autoFilter: true, - }); - const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; - optMap["manager"] = users.map((u: any) => ({ - code: u.user_id || u.id, - label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - for (const col of ["inventory_unit", "material", "division"]) { - try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) { - optMap[`item_${col}`] = flatten(res.data.data); - } - } catch { /* skip */ } - } - setCategoryOptions(optMap); - const divs = optMap["item_division"] || []; - const purchaseDiv = divs.find((o) => o.label === "구매관리") - || divs.find((o) => o.label === "원자재") - || divs.find((o) => o.label === "부자재"); - if (purchaseDiv) setItemSearchDivision(purchaseDiv.code); - }; - loadCategories(); - }, []); - - // 마스터 테이블 컬럼 (supplier_name, order_date 등) - const MASTER_COLUMNS = new Set(["supplier_name", "supplier_code", "order_date", "status"]); - - // 데이터 조회 - const fetchOrders = useCallback(async () => { + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { - // searchFilters를 detail / master로 분리 - const detailFilters: any[] = []; - const masterExtraFilters: any[] = []; - for (const f of searchFilters) { - const filter = { columnName: f.columnName, operator: f.operator, value: f.value }; - if (MASTER_COLUMNS.has(f.columnName)) { - masterExtraFilters.push(filter); - } else { - detailFilters.push(filter); - } - } - - const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 0, - dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined, - autoFilter: true, - sort: { columnName: "purchase_no", order: "desc" }, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - - const purchaseNos = [...new Set(rows.map((r: any) => r.purchase_no).filter(Boolean))]; - let masterMap: Record = {}; - if (purchaseNos.length > 0) { - try { - const masterFilters: any[] = [{ columnName: "purchase_no", operator: "in", value: purchaseNos }, ...masterExtraFilters]; - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: purchaseNos.length + 10, - dataFilter: { enabled: true, filters: masterFilters }, - autoFilter: true, - }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - for (const m of masters) masterMap[m.purchase_no] = m; - } catch { /* skip */ } - } - - const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))]; - let itemMap: Record = {}; - if (itemCodes.length > 0) { - try { - const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: itemCodes.length + 10, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, - autoFilter: true, - }); - const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; - for (const item of items) itemMap[item.item_number] = item; - } catch { /* skip */ } - } - - const resolveLabel = (key: string, code: string) => { - if (!code) return ""; - const opts = categoryOptions[key]; - if (!opts) return code; - return opts.find((o) => o.code === code)?.label || code; - }; - - const hasMasterFilters = masterExtraFilters.length > 0; - const data = rows - .filter((row: any) => { - const master = masterMap[row.purchase_no]; - if (hasMasterFilters && !master) return false; - return true; - }) - .map((row: any) => { - const item = itemMap[row.item_code]; - const master = masterMap[row.purchase_no]; - const rawUnit = row.unit || item?.inventory_unit || ""; - const fmtDate = (v: any) => v ? new Date(v).toLocaleDateString("ko-KR") : ""; - const od = master?.order_date || ""; - const dd = row.due_date || ""; - return { - ...row, - item_name: row.item_name || item?.item_name || "", - spec: row.spec || item?.size || "", - unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit, - status: master?.status || "", - supplier_name: master?.supplier_name || "", - order_date: od ? fmtDate(od) : "", - due_date: dd ? fmtDate(dd) : "", - memo: row.memo || master?.memo || "", - }; - }); - - setOrders(data); - setTotalCount(data.length); - } catch { - toast.error("발주 목록을 불러오는데 실패했어요."); + const f = { ...filter, ...override }; + const res = await purchaseApi.listOrder(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [filter]); - useEffect(() => { fetchOrders(); }, [fetchOrders]); - - // purchase_no 기준 그룹핑 - - const getCategoryLabel = (col: string, code: string) => { - if (!code) return ""; - const found = categoryOptions[col]?.find((o) => o.code === code); - return found?.label || code; - }; - - // 등록 모달 열기 - const openRegisterModal = () => { - const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; - const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; - setMasterForm({ - input_mode: defaultInputMode, - price_mode: defaultPriceMode, - manager: user?.userId || "", - order_date: new Date().toISOString().split("T")[0], - status: "작성중", - }); - setDetailRows([]); - setIsEditMode(false); - setIsModalOpen(true); - }; - - // 수정 모달 열기 - const openEditModal = async (purchaseNo: string) => { - try { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0]; - - const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 100, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; - - setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); - setIsEditMode(true); - setIsModalOpen(true); - } catch { - toast.error("발주 정보를 불러오는데 실패했어요."); - } - }; - - // 삭제 (다중 선택) - const handleDelete = async () => { - if (checkedIds.length === 0) { toast.error("삭제할 발주를 선택해주세요."); return; } - const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); - const purchaseNos = [...new Set(selectedItems.map((o) => o.purchase_no))]; - const ok = await confirm(`${checkedIds.length}건의 발주 데이터를 삭제하시겠어요?`, { - description: "삭제된 데이터는 복구할 수 없어요.", - variant: "destructive", - confirmText: "삭제", - }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { - data: checkedIds.map((id) => ({ id })), - }); - for (const purchaseNo of purchaseNos) { - const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const remainRows = remaining.data?.data?.data || remaining.data?.data?.rows || []; - if (remainRows.length === 0) { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), - }); - } - } - } - toast.success("삭제되었어요."); - setCheckedIds([]); - fetchOrders(); - } catch { - toast.error("삭제에 실패했어요."); - } - }; - - // 저장 (마스터 + 디테일) - const handleSave = async () => { - if (detailRows.length === 0) { - toast.error("품목을 1개 이상 추가해주세요."); - return; - } - setSaving(true); - try { - const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm; - - if (isEditMode && id) { - await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, { - originalData: { id }, - updatedData: masterFields, - }); - const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 100, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: masterForm.purchase_no }] }, - autoFilter: true, - }); - const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || []; - if (existings.length > 0) { - await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { - data: existings.map((d: any) => ({ id: d.id })), - }); - } - for (const [idx, row] of detailRows.entries()) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; - await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { - id: crypto.randomUUID(), - ...detailFields, - purchase_no: masterForm.purchase_no, - seq_no: idx + 1, - }); - } - } else { - const { purchase_no: _pn, ...fieldsWithoutPurchaseNo } = masterFields; - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { id: crypto.randomUUID(), ...fieldsWithoutPurchaseNo }); - const createdData = masterRes.data?.data; - let purchaseNo = createdData?.purchase_no; - if (!purchaseNo) { - const queryRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - sort: { columnName: "created_date", order: "desc" }, - autoFilter: true, - }); - const records = queryRes.data?.data?.data || queryRes.data?.data?.rows || []; - purchaseNo = records[0]?.purchase_no; - } - if (!purchaseNo) { - toast.error("발주번호를 가져올 수 없어요. 다시 시도해주세요."); - return; - } - for (const [idx, row] of detailRows.entries()) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; - await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { - id: crypto.randomUUID(), - ...detailFields, - purchase_no: purchaseNo, - seq_no: idx + 1, - }); - } - } - - toast.success(isEditMode ? "수정되었어요." : "등록되었어요."); - setIsModalOpen(false); - fetchOrders(); - } catch (err: any) { - toast.error(err.response?.data?.message || "저장에 실패했어요."); - } finally { - setSaving(false); - } - }; - - // 품목 검색 (수주관리와 동일한 서버 페이징 방식) - const searchItems = async (page?: number, size?: number) => { - const p = page ?? itemPage; - const s = size ?? itemPageSize; - setItemSearchLoading(true); - try { - const filters: any[] = []; - if (itemSearchKeyword) { - filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); - } - // 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응) - if (itemSearchDivision !== "all") { - const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; - const divValues = [itemSearchDivision]; - if (divLabel) divValues.push(divLabel); - filters.push({ columnName: "division", operator: "in", value: divValues }); - } - - // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 - const supplierCode = masterForm.supplier_code; - if (supplierCode) { - try { - const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, - autoFilter: true, - }); - const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; - const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; - if (rawIds.length === 0) { - setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); - setItemSearchLoading(false); - return; - } - // UUID와 문자열(item_number) 분리 - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); - const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); - - // 문자열(item_number)을 item_info에서 id로 변환 - let convertedIds: string[] = []; - if (codeIds.length > 0) { - const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: codeIds.length + 10, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, - autoFilter: true, - }); - const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; - convertedIds = convRows.map((r: any) => r.id).filter(Boolean); - } - - const finalIds = [...new Set([...uuidIds, ...convertedIds])]; - if (finalIds.length === 0) { - setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); - setItemSearchLoading(false); - return; - } - filters.push({ columnName: "id", operator: "in", value: finalIds }); - } catch { /* skip */ } - } - - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: p, size: s, - dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, - autoFilter: true, - }); - const resData = res.data?.data; - const rows = resData?.data || resData?.rows || []; - const serverTotal = resData?.total || resData?.totalCount || rows.length; - setItemSearchResults(rows); - setItemTotal(serverTotal); - setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s))); - } catch { /* skip */ } finally { - setItemSearchLoading(false); - } - }; - - const handleItemPageChange = (newPage: number) => { - if (newPage < 1 || newPage > itemTotalPages) return; - setItemPage(newPage); - setItemPageInput(String(newPage)); - searchItems(newPage); - }; - - const commitItemPageInput = () => { - const parsed = parseInt(itemPageInput, 10); - if (isNaN(parsed) || itemPageInput.trim() === "") { - setItemPageInput(String(itemPage)); - return; - } - const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); - if (clamped !== itemPage) handleItemPageChange(clamped); - setItemPageInput(String(clamped)); - }; - - const triggerNewSearch = () => { - setItemPage(1); - setItemPageInput("1"); - searchItems(1); - }; - - const addSelectedItemsToDetail = async () => { - const selected = Array.from(itemSelectedMap.values()); - if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } - - const supplierCode = masterForm.supplier_code; - const isStandard = masterForm.price_mode === "standard"; - const isSupplier = masterForm.price_mode === "supplier"; - let supplierPriceMap: Record = {}; - if (isSupplier && supplierCode) { + useEffect(() => { + let dead = false; + (async () => { try { - const itemIds = selected.map((item) => item.item_number || item.id); - const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 0, - dataFilter: { - enabled: true, - filters: [ - { columnName: "supplier_id", operator: "equals", value: supplierCode }, - { columnName: "item_id", operator: "in", value: itemIds }, - ], - }, - autoFilter: true, - }); - const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); - for (const p of prices) { - if (p.start_date && p.start_date > today) continue; - if (p.end_date && p.end_date < today) continue; - const price = p.calculated_price || p.base_price || p.unit_price || ""; - if (price && Number(price) > 0) supplierPriceMap[p.item_id] = String(price); - } + const [v, u, cat, prod, pur] = await Promise.all([ + purchaseApi.listVendors(), + purchaseApi.listUsers(), + loadCodes(CODE_GROUP.CATEGORY), + loadCodes(CODE_GROUP.PRODUCT), + loadCodes(CODE_GROUP.PURCHASE), + ]); + if (dead) return; + setVendorOpts(v); + setUserOpts(u); + setCategoryOpts(cat); + setProductOpts(prod); + setPurchaseOpts(pur); } catch { /* skip */ } - } + })(); + fetchList(EMPTY_FILTER); + return () => { dead = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const newRows = selected.map((item) => { - const itemCode = item.item_number || item.id; - let unitPrice = ""; - if (isStandard) { - unitPrice = item.purchase_price || item.standard_price || ""; - } else if (isSupplier && supplierCode) { - unitPrice = supplierPriceMap[itemCode] || ""; - } - return { - _id: `new_${Date.now()}_${Math.random()}`, - item_code: itemCode, - item_name: item.item_name, - spec: item.size || "", - material: getCategoryLabel("item_material", item.material) || item.material || "", - unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", - order_qty: "", - received_qty: "0", - remain_qty: "0", - unit_price: unitPrice, - amount: "", - due_date: "", - memo: "", - }; - }); + const gridRows = useMemo( + () => rows.map((r, i) => ({ ...r, id: r.objid ?? `r_${i}` })), + [rows], + ); - setDetailRows((prev) => [...prev, ...newRows]); - toast.success(`${selected.length}개 품목이 추가되었어요.`); - setItemSelectedMap(new Map()); - setItemSelectOpen(false); - }; + const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ + { key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" }, + { key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" }, + { key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center" }, + { key: "purchase_type_name", label: "구매유형", width: "w-[110px]", align: "center" }, + { key: "category_name", label: "주문유형", width: "w-[110px]", align: "center" }, + { key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" }, + { key: "part_no", label: "품번", width: "w-[170px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[280px]" }, + { key: "partner_name", label: "공급업체", minWidth: "min-w-[170px]" }, + { key: "currency_name", label: "환종", width: "w-[80px]", align: "center" }, + { key: "total_supply_price", label: "총액", width: "w-[140px]", align: "right", formatMoney: true }, + { key: "mail_send_yn", label: "메일발송", width: "w-[100px]", align: "center" }, + { key: "mail_send_date", label: "발주일", width: "w-[115px]", align: "center" }, + { key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" }, + { key: "regdate", label: "작성일", width: "w-[115px]", align: "center" }, + ]), []); - // 단가 재계산: 단가방식/공급업체 변경 시 기존 품목 단가 갱신 - const recalcPrices = useCallback(async (priceMode: string, supplierCode: string) => { - if (detailRows.length === 0) return; - const isStandard = priceMode === "standard"; - const isSupplier = priceMode === "supplier"; - - if (isStandard) { - const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean); - if (itemCodes.length === 0) return; - try { - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, - autoFilter: true, - }); - const items = res.data?.data?.data || res.data?.data?.rows || []; - const priceMap: Record = {}; - for (const item of items) { - const price = item.purchase_price || item.standard_price || ""; - if (price) priceMap[item.item_number] = String(price); - } - setDetailRows((prev) => prev.map((row) => { - const up = priceMap[row.item_code] || ""; - const qty = parseFloat(row.order_qty) || 0; - const price = parseFloat(up) || 0; - return { ...row, unit_price: up, amount: (qty * price).toString() }; - })); - } catch { /* skip */ } - } else if (isSupplier && supplierCode) { - const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean); - if (itemCodes.length === 0) return; - try { - const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [ - { columnName: "supplier_id", operator: "equals", value: supplierCode }, - { columnName: "item_id", operator: "in", value: itemCodes }, - ]}, - autoFilter: true, - }); - const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); - const priceMap: Record = {}; - for (const p of prices) { - if (p.start_date && p.start_date > today) continue; - if (p.end_date && p.end_date < today) continue; - const price = p.calculated_price || p.base_price || p.unit_price || ""; - if (price && Number(price) > 0) priceMap[p.item_id] = String(price); - } - setDetailRows((prev) => prev.map((row) => { - const up = priceMap[row.item_code] || ""; - const qty = parseFloat(row.order_qty) || 0; - const price = parseFloat(up) || 0; - return { ...row, unit_price: up, amount: (qty * price).toString() }; - })); - } catch { /* skip */ } - } - }, [detailRows]); - - const updateDetailRow = (idx: number, field: string, value: string) => { - setDetailRows((prev) => { - const next = [...prev]; - next[idx] = { ...next[idx], [field]: value }; - if (field === "order_qty" || field === "unit_price") { - const qty = parseFloat(field === "order_qty" ? value : next[idx].order_qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; - next[idx].amount = (qty * price).toString(); - const received = parseFloat(next[idx].received_qty) || 0; - next[idx].remain_qty = (qty - received).toString(); - } - return next; - }); - }; - - const removeDetailRow = (idx: number) => { - setDetailRows((prev) => prev.filter((_, i) => i !== idx)); - }; - - const handleExcelDownload = async () => { - if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; } - const data = orders.map((o) => { - const row: Record = {}; - for (const col of EXCEL_COLUMNS) row[col.label] = o[col.key] || ""; - return row; - }); - await exportToExcel(data, "발주관리.xlsx", "발주목록"); - toast.success("다운로드 완료"); - }; + 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_FILTER); fetchList(EMPTY_FILTER); }; return ( -
- {/* 검색 필터 바 */} - +
+ - {/* 액션 바 */} -
-
-

발주 목록

- - {totalCount}건 - -
-
- - - -
- - -
- -
-
+ 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, customer_cd: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, purchase_order_no: e.target.value })} /> + + + setFilter({ ...filter, partner_objid: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, delivery_start_date: v })} + to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })} + /> + + + setFilter({ ...filter, reg_start_date: v })} + to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })} + /> + + + setFilter({ ...filter, category_cd: v })} /> + + + setFilter({ ...filter, product_cd: v })} /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, mail_send_yn: v })} /> + + - {/* 데이터 테이블 — 영업관리 4개 메뉴와 동일한 DataGrid + logicstudio props */} openEditModal(row.purchase_no)} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} - showColumnSettings - paginationStyle="range" + gridId="purchase-order" pageSizeOptions={[10, 15, 20, 50, 100]} - summaryStats={[ - { label: "건수", value: totalCount.toLocaleString(), suffix: "건" }, - { label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" }, - { - label: "금액 합계", - value: orders.reduce((acc, o: any) => acc + Number(o.amount || 0), 0).toLocaleString(), - }, - ]} - onRefresh={fetchOrders} - onDownload={handleExcelDownload} + paginationStyle="range" + serverPaging + serverPage={filter.page ?? 1} + serverPageSize={filter.page_size ?? 50} + serverTotalItems={total} + onPageChange={(p) => { 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 /> - - - {/* 발주 등록/수정 모달 */} - - - - {isEditMode ? (isReadOnly ? "발주 상세" : "발주 수정") : "발주 등록"} - - {isEditMode ? (isReadOnly ? "발주 상세 정보를 확인해요." : "발주 정보를 수정해요.") : "새로운 발주를 등록해요."} - - - -
-
- {/* 기본 정보 */} -
-
- 기본 정보 -
-
-
-
- - -
-
- - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" - disabled={isReadOnly} - /> -
-
- - {isReadOnly ? ( -
- - {masterForm.status} - -
- ) : ( - - )} -
-
- - -
-
- - -
-
- - -
-
-
- - {/* 공급업체 / 담당자 — 입력방식이 '공급업체 우선'일 때만 표시 */} - {masterForm.input_mode === "supplierFirst" && ( -
-
- 공급업체 정보 -
-
-
- - { - const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v); - const name = supp?.label.replace(` (${v})`, "") || ""; - setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name })); - recalcPrices(masterForm.price_mode || "", v); - }} - placeholder="공급업체 선택" - disabled={isReadOnly} - /> -
-
-
- )} - - {/* 품목 내역 */} -
-
-
- 품목 내역 - - {detailRows.length} - -
- {!isReadOnly && ( - - )} -
- {detailRows.length === 0 ? ( -
- - 아직 추가된 품목이 없어요. 위 버튼으로 품목을 추가해주세요. -
- ) : ( -
- - - - c.key)} strategy={horizontalListSortingStrategy}> - - {!isReadOnly && } - {visibleModalColumns.map((col) => ( - - ))} - - - - - {detailRows.map((row, idx) => ( - - {!isReadOnly && ( - - - - )} - {visibleModalColumns.map((col) => { - switch (col.key) { - case "item_code": - return {row.item_code}; - case "item_name": - return {row.item_name}; - case "supplier": - return ( - - {isReadOnly ? ( - {(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"} - ) : ( - - )} - - ); - case "spec": - return {row.spec}; - case "unit": - return {row.unit}; - case "order_qty": - return ( - - {isReadOnly ? ( - {row.order_qty ? Number(row.order_qty).toLocaleString() : ""} - ) : ( - updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" /> - )} - - ); - case "received_qty": - return {row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}; - case "remain_qty": - return {row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}; - case "unit_price": - return ( - - {isReadOnly ? ( - {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} - ) : ( - updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" /> - )} - - ); - case "amount": - return {row.amount ? Number(row.amount).toLocaleString() : ""}; - case "due_date": - return ( - - {isReadOnly ? ( - {row.due_date} - ) : ( - updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" /> - )} - - ); - case "memo": - return ( - - {isReadOnly ? ( - {row.memo} - ) : ( - updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" /> - )} - - ); - default: - return ; - } - })} - - ))} - -
-
-
- )} -
- - {/* 비고 */} -
-
- 비고 -
-
- setMasterForm((p) => ({ ...p, memo: e.target.value }))} - placeholder="특이사항이나 메모를 입력해주세요" - className="h-9" - disabled={isReadOnly} - /> -
-
-
- - - {isReadOnly ? ( - - ) : ( - <> - - - - )} - - - {/* 품목 선택 모달 (중첩) */} - - e.preventDefault()}> - - 품목 선택 - 발주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. - -
- setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} - className="h-9 flex-1" - /> - - -
-
- - - - - 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))} - onChange={(e) => { - setItemSelectedMap((prev) => { - const next = new Map(prev); - if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i)); - else itemSearchResults.forEach((i) => next.delete(i.id)); - return next; - }); - }} /> - - 품목코드 - 품명 - 규격 - 재질 - 단위 - - - - {itemSearchResults.length === 0 ? ( - 검색 결과가 없어요 - ) : itemSearchResults.map((item) => ( - setItemSelectedMap((prev) => { - const next = new Map(prev); - if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item); - return next; - })}> - - - - {item.item_number} - {item.item_name} - {item.size} - {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} - {categoryOptions["item_inventory_unit"]?.find((o) => o.code === item.inventory_unit)?.label || item.inventory_unit} - - ))} - -
-
-
-
- 표시: - { - const v = Math.min(200, Math.max(1, Number(e.target.value) || 20)); - setItemPageSize(v); - setItemPage(1); - setItemPageInput("1"); - searchItems(1, v); - }} - className="h-7 w-14 rounded-md border px-1 text-center text-xs" /> -
-
- - - setItemPageInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }} - onBlur={commitItemPageInput} - onFocus={(e) => e.target.select()} - className="h-7 w-10 rounded-md border px-1 text-center text-xs" /> - / {itemTotalPages || 1} - - -
- 총 {itemTotal}건 -
- -
- {itemSelectedMap.size}개 선택됨 -
- - -
-
-
-
-
- -
- - {/* 엑셀 업로드 */} - fetchOrders()} - /> - - {/* 테이블 설정 모달 */} - - - {ConfirmDialogComponent}
); } diff --git a/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx index 6767855c..c2eabfb6 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx @@ -73,7 +73,7 @@ export default function ProjectStatusPage() { { key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center", frozen: true }, { key: "product_name", label: "제품구분", width: "w-[115px]", align: "center" }, { key: "part_no", label: "품번", width: "w-[150px]" }, - { key: "part_name", label: "품명", minWidth: "min-w-[200px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[280px]" }, { key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" }, // 전체 (BOM기준) { key: "total_item_cnt", label: "전체품목수", width: "w-[115px]", align: "right", formatNumber: true }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx index c659c89b..8e50b2f0 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx @@ -94,7 +94,7 @@ export default function ProposalPage() { { key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" }, { key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" }, { key: "part_display", label: "품번", width: "w-[160px]" }, - { key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" }, + { key: "part_name_display", label: "품명", minWidth: "min-w-[280px]" }, { key: "status_title", label: "결재상태", width: "w-[115px]", align: "center" }, { key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" }, { key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx index c5837bc6..d9dd8fce 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx @@ -4,7 +4,6 @@ // 검색: 년도 / 프로젝트번호 / 견적요청서No / 공급업체 / 메일발송 / 작성자 / 제품구분 // 그리드: 13컬럼 (견적번호 / 요청번호 / 구매유형 / 프로젝트번호 / 주문유형 / 제품구분 / 품번 / 품명 / 공급업체 / 견적요청서(파일) / 메일발송 / 수신견적서 / 작성자) // 액션: 메일발송 / 삭제 / 조회 -// ⚠️ 백엔드 quotation_request_master 미존재 → 빈 그리드 (UI 만 제공) import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Input } from "@/components/ui/input"; @@ -66,7 +65,7 @@ export default function QuoteRequestPage() { try { const [p, s, u] = await Promise.all([ apiClient.get(`/sales/codes/${PARENT_PRODUCT}`), - purchaseApi.listSuppliers(), + purchaseApi.listVendors(), purchaseApi.listUsers(), ]); if (dead) return; @@ -90,7 +89,7 @@ export default function QuoteRequestPage() { { key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" }, { key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" }, { key: "part_no", label: "품번", width: "w-[150px]" }, - { key: "part_name", label: "품명", minWidth: "min-w-[180px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[280px]" }, { key: "vendor_name", label: "공급업체", minWidth: "min-w-[150px]" }, { key: "quotation_file", label: "견적요청서", width: "w-[115px]", align: "center", renderType: "clip" }, { key: "mail_send_date_title", label: "메일발송", width: "w-[125px]", align: "center" }, @@ -113,12 +112,12 @@ export default function QuoteRequestPage() { actions={<> } diff --git a/frontend/components/common/PageHeader.tsx b/frontend/components/common/PageHeader.tsx index dea24e55..b7cf0654 100644 --- a/frontend/components/common/PageHeader.tsx +++ b/frontend/components/common/PageHeader.tsx @@ -1,21 +1,20 @@ "use client"; /** - * PageHeader — 페이지 상단 메뉴명 + 설명 + 액션 슬롯. + * PageHeader — 페이지 상단 "대메뉴_중메뉴" 제목 + 액션/검색 슬롯. * - * customer-cs/cs 페이지 패턴 1:1 추출. 모든 RPS 메뉴 페이지의 상단에 의무 배치. + * 모든 RPS 메뉴 페이지의 상단에 의무 배치. * * 자동 매칭 (탭 시스템 대응): * - RPS 는 탭 기반이라 usePathname() 이 /main 으로 고정됨. * - useTabStore 의 활성 탭 adminUrl → /COMPANY_NN prefix 제거 → menu_info.menu_url 매칭. - * - useCurrent2ndLevelMenuObjid 와 동일 패턴. + * - 매칭된 menu 의 parent_obj_id 로 부모 메뉴를 찾아 "{부모}_{자식}" 으로 표기 (wace 컨벤션). + * - 루트 그룹(parent_obj_id 가 0뎁스)이면 자식만 단독 표기. * * 명시 지정: - * + * * - * 원칙: - * - 모든 page.tsx 의 최상위 자식으로 를 배치한다. - * - menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭. + * 원칙: menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭. */ import React from "react"; @@ -29,7 +28,6 @@ import { cn } from "@/lib/utils"; interface PageHeaderProps { title?: string; - description?: string; /** 업무 액션 슬롯 (등록/삭제/상신 등). 검색·초기화는 onSearch/onReset 로 전달. */ actions?: React.ReactNode; /** 검색 핸들러. 지정 시 우측에 검색 버튼 자동 렌더. */ @@ -47,6 +45,17 @@ function stripCompanyPrefix(p: string): string { return p.replace(/^\/COMPANY_\d+/, "") || "/"; } +function findParentMenu(menus: MenuItem[], menu: MenuItem | null): MenuItem | null { + if (!menu) return null; + const pid = menu.parent_obj_id ?? menu.PARENT_OBJ_ID; + if (!pid) return null; + for (const m of menus) { + const oid = m.objid ?? m.OBJID; + if (oid && String(oid) === String(pid)) return m; + } + return null; +} + function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null { // menu_info.menu_url 이 /COMPANY_16/... 으로 저장되어 있으므로 양쪽 비교 for (const m of menus) { @@ -68,7 +77,7 @@ function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null { } export function PageHeader({ - title, description, actions, onSearch, onReset, loading, + title, actions, onSearch, onReset, loading, searchLabel = "검색", resetLabel = "초기화", className, }: PageHeaderProps) { const pathname = usePathname() ?? ""; @@ -76,6 +85,7 @@ export function PageHeader({ const activeTabId = useTabStore(selectActiveTabId); let menu: MenuItem | null = null; + let parentMenu: MenuItem | null = null; try { const { userMenus, adminMenus } = useMenu(); // RPS 탭 시스템: pathname=/main 이면 활성 탭의 adminUrl 사용 @@ -87,25 +97,31 @@ export function PageHeader({ targetUrl = stripCompanyPrefix(activeTab.adminUrl); } } + const allMenus = [...(userMenus as MenuItem[]), ...(adminMenus as MenuItem[])]; menu = findByUrl(userMenus as MenuItem[], targetUrl) ?? findByUrl(adminMenus as MenuItem[], targetUrl); + parentMenu = findParentMenu(allMenus, menu); } catch { /* Provider 밖 — 자동 매칭 생략 */ } - const resolvedTitle = title ?? menu?.menu_name_kor ?? ""; - const resolvedDesc = description ?? menu?.menu_desc ?? ""; + // wace 컨벤션: "대메뉴_중메뉴" (parent_obj_id 가 루트 그룹이면 단독 표기) + const parentName = parentMenu?.menu_name_kor ?? parentMenu?.MENU_NAME_KOR ?? ""; + const ownName = menu?.menu_name_kor ?? menu?.MENU_NAME_KOR ?? ""; + const parentParentPid = parentMenu?.parent_obj_id ?? parentMenu?.PARENT_OBJ_ID; + // 부모의 부모가 있어야 (즉, 부모가 1뎁스 그룹) "부모_자식" 표기. 부모 없거나 부모가 루트이면 자식만. + const autoTitle = parentName && parentParentPid && ownName + ? `${parentName}_${ownName}` + : ownName; + const resolvedTitle = title ?? autoTitle; const hasSearchButtons = !!(onSearch || onReset); - if (!resolvedTitle && !resolvedDesc && !actions && !hasSearchButtons) return null; + if (!resolvedTitle && !actions && !hasSearchButtons) return null; return ( -
+
{resolvedTitle && ( -

{resolvedTitle}

- )} - {resolvedDesc && ( -

{resolvedDesc}

+

{resolvedTitle}

)}
{(actions || hasSearchButtons) && ( 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()}> + + + 불출의뢰 등록 + 선택한 자재의 불출의뢰수량을 입력합니다. + + +
+
+ + +
+
+
+ +