From e51f5f7b6924e563a1c4767883c4f4d29755caac Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 20 May 2026 10:04:39 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=9E=85=EA=B3=A0=EA=B4=80=EB=A6=AC=20=EC=9E=85=EA=B3=A0?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20+=20=EC=9E=85=EA=B3=A0=EC=9D=BC=EB=B3=84?= =?UTF-8?q?=20=EB=A7=88=EA=B0=90=EC=A0=95=EB=B3=B4=EC=9E=85=EB=A0=A5=20+?= =?UTF-8?q?=20=EB=A7=A4=EC=9E=85=EB=A7=88=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend purchaseInboundService 신설 — getInboundFormInit / saveInboundForm (arrival_plan UPSERT 트랜잭션) / saveDeadlineInfo (8필드 일괄 UPDATE) / closeArrival (이미 마감된 건 차단) + listWarehouseOptions / listAcctCodeOptions - backend routes — GET /inbound-form/:pomObjid / POST /inbound-form/save / POST /arrival/deadline / POST /arrival/close + 옵션 2개 - InboundFormDialog 신설 — wace deliveryAcceptanceFormPopUp_new.jsp 1:1 (좌 발주품목 read-only + 우 차수별 입고입력 + 미입고 일괄적용) - DeadlineInfoDialog 신설 — wace swal 모달 1:1 (8필드 일괄, 단건 시 prefill) - inbound 페이지 입고등록 / inbound-by-date 마감정보입력+매입마감 연결 - 입고등록 master SELECT 함정 수정 — RPS 에 POM.delivery_status 없어 reception_status fallback - DataGrid 다중 frozen 누적 left 계산 인프라 추가 (frozenLeftPx props 보강) — shadcn Table 기반이라 진짜 column pinning 불가 (자연 위치 도달 후 sticky), 입고 3페이지의 frozen 부여는 일단 제거. 진짜 pinning 은 별도 작업 --- .../src/controllers/purchaseController.ts | 81 ++++ backend-node/src/routes/purchaseRoutes.ts | 8 + .../src/services/purchaseInboundService.ts | 372 +++++++++++++++++ .../purchase/inbound-by-date/page.tsx | 66 ++- .../purchase/inbound-by-item/page.tsx | 2 +- .../COMPANY_16/purchase/inbound/page.tsx | 22 +- frontend/components/common/DataGrid.tsx | 47 ++- .../purchase/DeadlineInfoDialog.tsx | 165 ++++++++ .../components/purchase/InboundFormDialog.tsx | 391 ++++++++++++++++++ frontend/lib/api/purchase.ts | 90 ++++ 10 files changed, 1228 insertions(+), 16 deletions(-) create mode 100644 backend-node/src/services/purchaseInboundService.ts create mode 100644 frontend/components/purchase/DeadlineInfoDialog.tsx create mode 100644 frontend/components/purchase/InboundFormDialog.tsx diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index 7cb6b9cc..f9974ab3 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -7,6 +7,7 @@ import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/purchaseService"; import * as formSvc from "../services/purchaseOrderFormService"; import * as mailSvc from "../services/purchaseOrderMailService"; +import * as inboundSvc from "../services/purchaseInboundService"; import { logger } from "../utils/logger"; function parseFilter(q: Record): svc.PurchaseListFilter { @@ -156,6 +157,86 @@ export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Resp } } +// ─── 입고관리 (입고등록 / 마감정보 / 매입마감) ───────────────── + +/** GET /api/purchase/inbound-form/:pomObjid */ +export async function getInboundFormInit(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.pomObjid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "pomObjid required" }); + const data = await inboundSvc.getInboundFormInit(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 }); + } +} + +/** POST /api/purchase/inbound-form/save */ +export async function saveInboundForm(req: AuthenticatedRequest, res: Response) { + try { + const { pomObjid, rows } = req.body as { pomObjid: string; rows: inboundSvc.InboundSaveRow[] }; + if (!pomObjid) return res.status(400).json({ success: false, message: "pomObjid required" }); + const writer = String(req.user?.userId ?? ""); + const result = await inboundSvc.saveInboundForm(pomObjid, rows ?? [], writer); + return res.json({ success: true, data: result }); + } catch (e: any) { + logger.error("입고등록 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** POST /api/purchase/arrival/deadline */ +export async function saveArrivalDeadline(req: AuthenticatedRequest, res: Response) { + try { + const body = req.body as inboundSvc.DeadlineInfoBody; + if (!body || !Array.isArray(body.objIds) || body.objIds.length === 0) { + return res.status(400).json({ success: false, message: "objIds required" }); + } + const result = await inboundSvc.saveDeadlineInfo(body); + return res.json({ success: true, data: result }); + } catch (e: any) { + logger.error("마감정보 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** GET /api/purchase/options/warehouses */ +export async function getWarehouses(_req: AuthenticatedRequest, res: Response) { + try { + const data = await inboundSvc.listWarehouseOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** GET /api/purchase/options/acct-codes */ +export async function getAcctCodes(_req: AuthenticatedRequest, res: Response) { + try { + const data = await inboundSvc.listAcctCodeOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** POST /api/purchase/arrival/close */ +export async function closeArrival(req: AuthenticatedRequest, res: Response) { + try { + const { objIds } = req.body as { objIds: string[] }; + if (!Array.isArray(objIds) || objIds.length === 0) { + return res.status(400).json({ success: false, message: "objIds required" }); + } + const result = await inboundSvc.closeArrival(objIds); + return res.json({ success: true, data: result }); + } catch (e: any) { + logger.error("매입마감 처리 실패", { error: e.message }); + return res.status(400).json({ success: false, message: e.message }); + } +} + export async function getSuppliers(_req: AuthenticatedRequest, res: Response) { try { const data = await svc.listSupplierOptions(); diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index fb9b6482..7a67908f 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -28,11 +28,19 @@ router.get ("/order-form/mail-info/:objid", ctrl.getPurchaseOrderMailInfo); router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade +// 입고관리 (wace deliveryAcceptanceFormPopUp_new 1:1) +router.get ("/inbound-form/:pomObjid", ctrl.getInboundFormInit); // 입고등록 팝업 자동채움 +router.post("/inbound-form/save", ctrl.saveInboundForm); // arrival_plan 다수 UPSERT +router.post("/arrival/deadline", ctrl.saveArrivalDeadline); // 마감정보 일괄 UPDATE +router.post("/arrival/close", ctrl.closeArrival); // 매입마감 일괄 + // 공통 옵션 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); router.get("/options/partner-managers/:partnerObjid", ctrl.getPartnerManagers); // 발주서 메일 담당자 +router.get("/options/warehouses", ctrl.getWarehouses); // 입고창고 (warehouse_info) +router.get("/options/acct-codes", ctrl.getAcctCodes); // 계정과목 (account_code_info) export default router; diff --git a/backend-node/src/services/purchaseInboundService.ts b/backend-node/src/services/purchaseInboundService.ts new file mode 100644 index 00000000..c0be7aac --- /dev/null +++ b/backend-node/src/services/purchaseInboundService.ts @@ -0,0 +1,372 @@ +// ============================================================ +// 구매관리 > 입고관리 — 입고등록 / 마감정보입력 / 매입마감 +// +// wace_plm 1:1 이식: +// - 입고등록 팝업: purchaseOrder/deliveryAcceptanceFormPopUp_new.do +// - 입고 저장: purchaseOrder/saveDeliveryInfo.do +// → supplyChainMgmt.saveDeliveryInfo (ARRIVAL_PLAN UPSERT) +// - 마감정보: purchaseOrder/saveArrivalPlanDeadlineInfo.do +// → purchaseOrder.saveArrivalPlanDeadlineInfo (8필드 조건부 UPDATE) +// - 매입마감: purchaseOrder/purchaseCloseByArrival.do +// → purchaseOrder.updateArrivalPlanCloseDate (PURCHASE_CLOSE_DATE) +// +// RPS 단순화: +// - 동시발주(MULTI_YN), inventory_mgmt 동기, ERROR_QTY/ERROR_REASON 흐름은 추후 +// - 입고등록은 ARRIVAL_PLAN UPSERT 만 처리 (자재 신규/입고 이력은 차후 도메인) +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +export interface InboundFormInitResult { + master: { + pom_objid: string; + purchase_order_no: string; + project_no: string; + contract_mgmt_objid: string; + partner_objid: string; + partner_name: string; + delivery_status: string; + }; + /** 발주 품목 — 입고 등록 팝업 좌측 그리드 */ + parts: { + order_part_objid: string; + part_objid: string; + part_no: string; + part_name: string; + spec: string; + maker: string; + unit_title: string; + order_qty: number; + arrival_qty: number; // 이미 입고된 수량 + non_arrival_qty: number; // 미입고 수량 (order_qty - arrival_qty) + delivery_request_date: string; + }[]; + /** 기존 입고 차수 — 팝업 우측 그리드 */ + arrivals: { + objid: string; + order_part_objid: string; + part_objid: string; + group_seq: string; + seq: string; + receipt_date: string; + location: string; + sub_location: string; + receipt_qty: number; + arrival_qty: number; + inventory_status: string; + }[]; +} + +export interface InboundSaveRow { + objid?: string; + parent_objid: string; // PURCHASE_ORDER_MASTER objid + order_part_objid: string; + part_objid: string; // bigint as string OK + group_seq: string; + seq: string; + receipt_date: string; + location: string; + sub_location: string; + receipt_qty: number; + arrival_qty: number; + arrival_plan_date?: string; +} + +export interface DeadlineInfoBody { + objIds: string[]; + taxType: string; + taxInvoiceDate?: string; + exportDeclNo?: string; + loadingDate?: string; + foreignType?: string; + duty?: string; + importVat?: string; + exchangeRate?: string; +} + +/** GET /api/purchase/inbound-form/:pomObjid — 입고등록 팝업 자동채움 */ +export async function getInboundFormInit(pomObjid: string): Promise { + const pool = getPool(); + try { + const m = await pool.query( + `SELECT POM.OBJID AS pom_objid, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + POM.CONTRACT_MGMT_OBJID AS contract_mgmt_objid, + POM.PARTNER_OBJID AS partner_objid, + CM.PROJECT_NO AS project_no, + C.CLIENT_NM AS partner_name, + COALESCE(POM.RECEPTION_STATUS, '') AS delivery_status + FROM PURCHASE_ORDER_MASTER POM + LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID + LEFT JOIN CLIENT_MNG C ON C.OBJID = POM.PARTNER_OBJID + WHERE POM.OBJID = $1 + LIMIT 1`, + [pomObjid], + ); + if (m.rows.length === 0) return null; + const master = m.rows[0]; + + const p = await pool.query( + `SELECT POP.OBJID AS order_part_objid, + POP.PART_OBJID AS part_objid, + POP.PART_NO AS part_no, + POP.PART_NAME AS part_name, + POP.SPEC AS spec, + '' AS maker, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title, + COALESCE(NULLIF(POP.ORDER_QTY,'')::numeric, 0) AS order_qty, + COALESCE(POP.DELIVERY_REQUEST_DATE, '') AS delivery_request_date, + COALESCE((SELECT SUM(COALESCE(NULLIF(AP.RECEIPT_QTY,'')::numeric, 0)) + FROM ARRIVAL_PLAN AP + WHERE AP.ORDER_PART_OBJID = POP.OBJID), 0) AS arrival_qty + FROM PURCHASE_ORDER_PART POP + WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1 + ORDER BY POP.REGDATE`, + [pomObjid], + ); + + const parts = p.rows.map((r) => { + const orderQty = Number(r.order_qty || 0); + const arrivalQty = Number(r.arrival_qty || 0); + return { + order_part_objid: String(r.order_part_objid ?? ""), + part_objid: String(r.part_objid ?? ""), + part_no: String(r.part_no ?? ""), + part_name: String(r.part_name ?? ""), + spec: String(r.spec ?? ""), + maker: String(r.maker ?? ""), + unit_title: String(r.unit_title ?? ""), + order_qty: orderQty, + arrival_qty: arrivalQty, + non_arrival_qty: Math.max(orderQty - arrivalQty, 0), + delivery_request_date: String(r.delivery_request_date ?? ""), + }; + }); + + const a = await pool.query( + `SELECT OBJID, ORDER_PART_OBJID, PART_OBJID::VARCHAR, GROUP_SEQ, SEQ, + RECEIPT_DATE, LOCATION, SUB_LOCATION, + COALESCE(NULLIF(RECEIPT_QTY,'')::numeric, 0) AS receipt_qty, + COALESCE(NULLIF(ARRIVAL_QTY,'')::numeric, 0) AS arrival_qty, + COALESCE(INVENTORY_STATUS, '') AS inventory_status + FROM ARRIVAL_PLAN + WHERE PARENT_OBJID = $1 + ORDER BY GROUP_SEQ, SEQ`, + [pomObjid], + ); + + const arrivals = a.rows.map((r) => ({ + objid: String(r.objid ?? ""), + order_part_objid: String(r.order_part_objid ?? ""), + part_objid: String(r.part_objid ?? ""), + group_seq: String(r.group_seq ?? "1"), + seq: String(r.seq ?? ""), + receipt_date: String(r.receipt_date ?? ""), + location: String(r.location ?? ""), + sub_location: String(r.sub_location ?? ""), + receipt_qty: Number(r.receipt_qty || 0), + arrival_qty: Number(r.arrival_qty || 0), + inventory_status: String(r.inventory_status ?? ""), + })); + + return { + master: { + pom_objid: String(master.pom_objid ?? ""), + purchase_order_no: String(master.purchase_order_no ?? ""), + project_no: String(master.project_no ?? ""), + contract_mgmt_objid: String(master.contract_mgmt_objid ?? ""), + partner_objid: String(master.partner_objid ?? ""), + partner_name: String(master.partner_name ?? ""), + delivery_status: String(master.delivery_status ?? ""), + }, + parts, + arrivals, + }; + } catch (e: any) { + logger.error("getInboundFormInit 실패", { error: e.message, pomObjid }); + throw e; + } +} + +/** POST /api/purchase/inbound-form/save — arrival_plan 다수 UPSERT 트랜잭션 */ +export async function saveInboundForm( + pomObjid: string, + rows: InboundSaveRow[], + writer: string, +): Promise<{ saved: number }> { + if (!pomObjid) throw new Error("pomObjid is required"); + if (!Array.isArray(rows)) throw new Error("rows must be array"); + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + let saved = 0; + for (const row of rows) { + const qty = Number(row.receipt_qty || 0); + // 입고수량 0 인 행은 저장 skip + if (qty <= 0) continue; + + const objid = row.objid || createObjId(); + // ARRIVAL_PLAN UPSERT (wace supplyChainMgmt.saveDeliveryInfo 1:1) + await client.query( + `INSERT INTO ARRIVAL_PLAN + (OBJID, PARENT_OBJID, ORDER_PART_OBJID, PART_OBJID, + RECEIPT_QTY, RECEIPT_DATE, LOCATION, SUB_LOCATION, + WRITER, RECEIVER_ID, GROUP_SEQ, SEQ, ARRIVAL_QTY, ARRIVAL_PLAN_DATE) + VALUES ($1, $2, $3, $4::bigint, + $5, $6, $7, $8, + $9, $9, $10, $11, $12, $13) + ON CONFLICT (OBJID) DO UPDATE SET + RECEIPT_QTY = EXCLUDED.RECEIPT_QTY, + RECEIPT_DATE = EXCLUDED.RECEIPT_DATE, + LOCATION = EXCLUDED.LOCATION, + SUB_LOCATION = EXCLUDED.SUB_LOCATION, + ARRIVAL_QTY = EXCLUDED.ARRIVAL_QTY, + ARRIVAL_PLAN_DATE = EXCLUDED.ARRIVAL_PLAN_DATE, + RECEIVER_ID = EXCLUDED.RECEIVER_ID`, + [ + objid, + pomObjid, + row.order_part_objid, + row.part_objid || null, + String(qty), + row.receipt_date || "", + row.location || "", + row.sub_location || "", + writer || "", + row.group_seq || "1", + row.seq || "", + String(row.arrival_qty || qty), + row.arrival_plan_date || "", + ], + ); + saved++; + } + await client.query("COMMIT"); + return { saved }; + } catch (e: any) { + await client.query("ROLLBACK"); + logger.error("saveInboundForm 실패", { error: e.message, pomObjid }); + throw e; + } finally { + client.release(); + } +} + +/** POST /api/purchase/arrival/deadline — 마감정보 일괄 UPDATE */ +export async function saveDeadlineInfo(body: DeadlineInfoBody): Promise<{ updated: number }> { + if (!Array.isArray(body.objIds) || body.objIds.length === 0) { + throw new Error("objIds required"); + } + const pool = getPool(); + + // wace 패턴: tax_type 은 항상 SET, 나머지는 비어있지 않을 때만 + const sets: string[] = [`tax_type = $1`]; + const vals: any[] = [body.taxType ?? ""]; + let i = 2; + const addIf = (col: string, v?: string) => { + if (v != null && v !== "") { + sets.push(`${col} = $${i++}`); + vals.push(v); + } + }; + const addNumIf = (col: string, v?: string) => { + if (v != null && v !== "") { + sets.push(`${col} = $${i++}::numeric`); + vals.push(v); + } + }; + addIf("tax_invoice_date", body.taxInvoiceDate); + if (body.exportDeclNo != null) { sets.push(`export_decl_no = $${i++}`); vals.push(body.exportDeclNo); } + addIf("loading_date", body.loadingDate); + addIf("foreign_type", body.foreignType); + addNumIf("duty", body.duty); + addNumIf("exchange_rate", body.exchangeRate); + addNumIf("import_vat", body.importVat); + + const idIdx = i; + vals.push(body.objIds); + + const sql = `UPDATE arrival_plan + SET ${sets.join(", ")} + WHERE OBJID = ANY($${idIdx}::text[])`; + try { + const r = await pool.query(sql, vals); + return { updated: r.rowCount ?? 0 }; + } catch (e: any) { + logger.error("saveDeadlineInfo 실패", { error: e.message }); + throw e; + } +} + +/** 입고창고 옵션 — RPS warehouse_info 기반 (wace 는 WAREHOUSE_LOCATION) */ +export async function listWarehouseOptions(): Promise<{ code: string; label: string }[]> { + const pool = getPool(); + try { + const r = await pool.query( + `SELECT WAREHOUSE_CODE AS code, + WAREHOUSE_CODE || ' ' || COALESCE(WAREHOUSE_NAME, '') AS label + FROM WAREHOUSE_INFO + WHERE COALESCE(STATUS, 'active') NOT IN ('inactive', 'delete', 'D', 'N') + AND WAREHOUSE_CODE IS NOT NULL AND WAREHOUSE_CODE <> '' + ORDER BY WAREHOUSE_CODE`, + ); + return r.rows; + } catch (e: any) { + logger.error("listWarehouseOptions 실패", { error: e.message }); + return []; + } +} + +/** 계정과목 옵션 — RPS account_code_info 기반 (wace 는 ERP_ACCT_CODE) */ +export async function listAcctCodeOptions(): Promise<{ code: string; label: string }[]> { + const pool = getPool(); + try { + const r = await pool.query( + `SELECT ACCOUNT_CODE AS code, + ACCOUNT_CODE || ' ' || COALESCE(ACCOUNT_NAME, '') AS label + FROM ACCOUNT_CODE_INFO + WHERE COALESCE(USE_YN, 'Y') IN ('Y', 'y', '1') + AND ACCOUNT_CODE IS NOT NULL AND ACCOUNT_CODE <> '' + ORDER BY ACCOUNT_CODE`, + ); + return r.rows; + } catch (e: any) { + logger.error("listAcctCodeOptions 실패", { error: e.message }); + return []; + } +} + +/** POST /api/purchase/arrival/close — 매입마감 일괄 (PURCHASE_CLOSE_DATE = 오늘) */ +export async function closeArrival(objIds: string[]): Promise<{ updated: number }> { + if (!Array.isArray(objIds) || objIds.length === 0) { + throw new Error("objIds required"); + } + const pool = getPool(); + try { + // 이미 마감된 건 차단 (wace fn_purchaseClose 와 동일) + const check = await pool.query( + `SELECT OBJID FROM ARRIVAL_PLAN + WHERE OBJID = ANY($1::text[]) + AND COALESCE(PURCHASE_CLOSE_DATE, '') <> ''`, + [objIds], + ); + if (check.rows.length > 0) { + const dup = check.rows.map((r: any) => r.objid).join(", "); + throw new Error(`이미 매입마감된 건이 포함돼 있어요 (${dup})`); + } + const r = await pool.query( + `UPDATE ARRIVAL_PLAN + SET PURCHASE_CLOSE_DATE = TO_CHAR(NOW(), 'YYYY-MM-DD') + WHERE OBJID = ANY($1::text[])`, + [objIds], + ); + return { updated: r.rowCount ?? 0 }; + } catch (e: any) { + logger.error("closeArrival 실패", { error: e.message }); + throw e; + } +} 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 96edf1ee..b9d3e1bb 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 @@ -20,6 +20,8 @@ import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/compon import { PageHeader } from "@/components/common/PageHeader"; import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase"; import { exportToExcel } from "@/lib/utils/excelExport"; +import { DeadlineInfoDialog, DeadlinePrefill } from "@/components/purchase/DeadlineInfoDialog"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; const CLOSE_OPTS: SmartSelectOption[] = [ { code: "N", label: "미마감" }, @@ -45,6 +47,12 @@ export default function InboundByDatePage() { const [supplierOpts, setSupplierOpts] = useState([]); const [userOpts, setUserOpts] = useState([]); + + // 마감정보입력 / 매입마감 + const [deadlineOpen, setDeadlineOpen] = useState(false); + const [deadlineObjIds, setDeadlineObjIds] = useState([]); + const [deadlinePrefill, setDeadlinePrefill] = useState(undefined); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const yearOpts = useMemo(() => getYearOptions(), []); const fetchList = useCallback(async (override?: Partial) => { @@ -83,7 +91,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-[280px]" }, + { key: "part_name", label: "품명", width: "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" }, @@ -121,12 +129,55 @@ export default function InboundByDatePage() { actions={<> } @@ -212,6 +263,15 @@ export default function InboundByDatePage() { }} showChart /> + + setDeadlineOpen(false)} + onSaved={() => { setDeadlineOpen(false); fetchList(); }} + /> + {ConfirmDialogComponent} ); } 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 fdeb28c7..caa431f8 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-[280px]" }, + { key: "part_name", label: "품명", width: "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 e828a19a..9f631b33 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx @@ -19,6 +19,7 @@ import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/compon import { PageHeader } from "@/components/common/PageHeader"; import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase"; import { exportToExcel } from "@/lib/utils/excelExport"; +import { InboundFormDialog } from "@/components/purchase/InboundFormDialog"; const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [ { code: "입고중", label: "입고중" }, @@ -47,6 +48,10 @@ export default function InboundPage() { const [supplierOpts, setSupplierOpts] = useState([]); const [userOpts, setUserOpts] = useState([]); + // 입고등록 다이얼로그 + const [inboundOpen, setInboundOpen] = useState(false); + const [inboundPomObjid, setInboundPomObjid] = useState(""); + const yearOpts = useMemo(() => getYearOptions(), []); const fetchList = useCallback(async (override?: Partial) => { @@ -88,7 +93,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-[280px]" }, + { key: "part_name", label: "품명", width: "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" }, @@ -119,7 +124,13 @@ export default function InboundPage() { actions={ } @@ -211,6 +222,13 @@ export default function InboundPage() { }} showChart /> + + setInboundOpen(false)} + onSaved={() => { setInboundOpen(false); fetchList(); }} + /> ); } diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 13431e69..0f57df78 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -129,6 +129,7 @@ function SortableHeaderCell({ col, sortKey, sortDir, onSort, headerFilterValues, uniqueValues, onToggleFilter, onClearFilter, frozenLeftClass = "left-0", + frozenLeftPx, widthPx, onResizeStart, }: { col: DataGridColumn; @@ -140,6 +141,8 @@ function SortableHeaderCell({ onToggleFilter: (colKey: string, value: string) => void; onClearFilter: (colKey: string) => void; frozenLeftClass?: string; + /** 다중 frozen 누적 left 픽셀 (지정 시 frozenLeftClass 무시) */ + frozenLeftPx?: number; /** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */ widthPx?: number; /** 리사이즈 핸들 mousedown 핸들러 */ @@ -148,17 +151,24 @@ function SortableHeaderCell({ const [filterSearch, setFilterSearch] = useState(""); 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, - cursor: "grab", - }; + // frozen 컬럼은 CSS sticky 가 작동하도록 dnd-kit transform/transition 모두 제외 + // (transform 이 적용되면 sticky 의 containing block 이 변경돼 sticky 가 깨짐) + const style: React.CSSProperties = col.frozen + ? {} + : { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + cursor: "grab", + }; if (widthPx != null) { style.width = widthPx; style.minWidth = widthPx; style.maxWidth = widthPx; } + if (col.frozen && frozenLeftPx != null) { + style.left = frozenLeftPx; + } const isSorted = sortKey === col.key; const hasFilter = headerFilterValues.size > 0; @@ -171,7 +181,7 @@ function SortableHeaderCell({ className={cn( widthPx == null && col.width, widthPx == null && col.minWidth, "select-none relative group/th !px-1.5", - col.frozen && cn("sticky z-20 bg-background", frozenLeftClass), + col.frozen && cn("sticky z-20 bg-background", frozenLeftPx == null && frozenLeftClass), )} >
@@ -762,6 +772,19 @@ export function DataGrid({ const stickyFirstColBodyClass = "sticky left-0 z-[6]"; const frozenLeftClass = hasFirstCol ? "left-10" : "left-0"; + // 다중 frozen 컬럼 누적 left 픽셀 (No/체크박스 다음 위치부터) + const frozenLeftPxMap = useMemo(() => { + const map: Record = {}; + let cursor = hasFirstCol ? 40 : 0; + for (const c of visibleColumns) { + if (!c.frozen) continue; + map[c.key] = cursor; + const px = columnWidths[c.key] ?? parseWidthClass(c.width) ?? parseWidthClass(c.minWidth) ?? 100; + cursor += px; + } + return map; + }, [visibleColumns, columnWidths, hasFirstCol]); + // 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정. const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]); const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]); @@ -908,6 +931,7 @@ export function DataGrid({ onToggleFilter={toggleHeaderFilter} onClearFilter={clearHeaderFilter} frozenLeftClass={frozenLeftClass} + frozenLeftPx={frozenLeftPxMap[col.key]} widthPx={columnWidths[col.key]} onResizeStart={startResize} /> @@ -987,19 +1011,22 @@ export function DataGrid({ )} {visibleColumns.map((col) => { const w = columnWidths[col.key]; - const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined; + const frozenLeft = col.frozen ? frozenLeftPxMap[col.key] : undefined; + const inlineStyle: React.CSSProperties = {}; + if (w != null) { inlineStyle.width = w; inlineStyle.minWidth = w; inlineStyle.maxWidth = w; } + if (frozenLeft != null) { inlineStyle.left = frozenLeft; } // 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외) const cellClickable = !!col.onClick && !col.editable; return ( 0 ? inlineStyle : undefined} className={cn( w == null && col.width, w == null && col.minWidth, "py-1", col.editable && "cursor-text", cellClickable && "cursor-pointer hover:underline text-primary", isSelected && "bg-accent", - col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass), + col.frozen && cn("sticky z-[5]", frozenLeft == null && frozenLeftClass, stickyBgClass), )} onClick={cellClickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined} onDoubleClick={(e) => { diff --git a/frontend/components/purchase/DeadlineInfoDialog.tsx b/frontend/components/purchase/DeadlineInfoDialog.tsx new file mode 100644 index 00000000..f764fe0e --- /dev/null +++ b/frontend/components/purchase/DeadlineInfoDialog.tsx @@ -0,0 +1,165 @@ +"use client"; + +// 구매관리 > 입고일별 입고관리 > 마감정보입력 다이얼로그 +// wace 원본: purchaseCloseList.jsp:75-246 swal 모달 1:1 +// - 다중 행 선택 → 8필드 일괄 UPDATE +// - 단건 선택 시 그리드 행에서 기존 값 자동 채움 (호출자가 prefill 로 전달) + +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter, DialogHeader } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; +import { purchaseApi, DeadlineInfoPayload } from "@/lib/api/purchase"; + +// wace purchaseCloseList.jsp:490-499 하드코딩 옵션 +const FOREIGN_TYPE_OPTS: SmartSelectOption[] = [ + { code: "0001220", label: "국내" }, + { code: "0001221", label: "해외" }, +]; +const TAX_TYPE_OPTS: SmartSelectOption[] = [ + { code: "0900218", label: "과세매입" }, + { code: "0900219", label: "영세매입" }, + { code: "0900220", label: "수입" }, +]; + +export interface DeadlinePrefill { + taxType?: string; + taxInvoiceDate?: string; + exportDeclNo?: string; + loadingDate?: string; + foreignType?: string; + duty?: string; + exchangeRate?: string; + importVat?: string; +} + +interface Props { + open: boolean; + onClose: () => void; + onSaved?: () => void; + /** 선택된 arrival_plan.OBJID 목록 */ + objIds: string[]; + /** 단건 선택 시 기존 값 자동 채움 */ + prefill?: DeadlinePrefill; +} + +export function DeadlineInfoDialog({ open, onClose, onSaved, objIds, prefill }: Props) { + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({}); + + useEffect(() => { + if (!open) return; + setForm({ + taxType: prefill?.taxType ?? "", + taxInvoiceDate: prefill?.taxInvoiceDate ?? "", + exportDeclNo: prefill?.exportDeclNo ?? "", + loadingDate: prefill?.loadingDate ?? "", + foreignType: prefill?.foreignType ?? "", + duty: prefill?.duty ?? "", + exchangeRate: prefill?.exchangeRate ?? "", + importVat: prefill?.importVat ?? "", + }); + }, [open, prefill]); + + const handleSave = async () => { + if (objIds.length === 0) { + toast.warning("선택된 입고건이 없습니다"); + return; + } + setSaving(true); + try { + const payload: DeadlineInfoPayload = { + objIds, + taxType: form.taxType ?? "", + taxInvoiceDate: form.taxInvoiceDate, + exportDeclNo: form.exportDeclNo, + loadingDate: form.loadingDate, + foreignType: form.foreignType, + duty: form.duty, + exchangeRate: form.exchangeRate, + importVat: form.importVat, + }; + const r = await purchaseApi.saveArrivalDeadline(payload); + toast.success(`마감정보 저장 완료 (${r.updated}건)`); + onSaved?.(); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + { if (!v && !saving) onClose(); }}> + + + 마감정보입력 + 선택된 {objIds.length}건의 입고건에 마감정보를 일괄 적용합니다 + + +
+ + setForm({ ...form, foreignType: v })} /> + + + setForm({ ...form, exchangeRate: String(v) })} + className="h-8 text-[12px]" /> + + + setForm({ ...form, taxType: v })} /> + + + setForm({ ...form, taxInvoiceDate: v })} /> + + + setForm({ ...form, exportDeclNo: e.target.value })} + className="h-8 text-[12px]" /> + + + setForm({ ...form, loadingDate: v })} /> + + + setForm({ ...form, duty: String(v) })} + className="h-8 text-[12px]" /> + + + setForm({ ...form, importVat: String(v) })} + className="h-8 text-[12px]" /> + +
+ + + + + +
+
+ ); +} + +function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/components/purchase/InboundFormDialog.tsx b/frontend/components/purchase/InboundFormDialog.tsx new file mode 100644 index 00000000..674a4a98 --- /dev/null +++ b/frontend/components/purchase/InboundFormDialog.tsx @@ -0,0 +1,391 @@ +"use client"; + +// 구매관리 > 입고관리 > 입고등록 다이얼로그 +// wace 원본: purchaseOrder/deliveryAcceptanceFormPopUp_new.jsp 1:1 +// - 헤더: 발주번호 / 프로젝트번호 (readonly) +// - 좌측: 발주 품목 read-only (품번/품명/규격/단위/수량/입고요청일) +// - 우측: 차수별 입고 입력 (입고일/입고창고/계정과목/입고수량) +// - 차수 추가: 같은 품목에 N개 차수 행 추가 가능 (group_seq) +// - 저장: arrival_plan 다수 UPSERT 트랜잭션 (receipt_qty=0 행은 skip) + +import React, { useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; +import { purchaseApi, InboundFormData, InboundSaveRow } from "@/lib/api/purchase"; + +interface Props { + open: boolean; + onClose: () => void; + onSaved?: () => void; + pomObjid: string; +} + +interface PartRow { + order_part_objid: string; + part_objid: string; + part_no: string; + part_name: string; + spec: string; + maker: string; + unit_title: string; + order_qty: number; + arrival_qty: number; + non_arrival_qty: number; + delivery_request_date: string; +} + +interface ArrivalRow { + rowKey: string; + objid: string; + order_part_objid: string; + part_objid: string; + group_seq: string; + seq: string; + receipt_date: string; + location: string; + sub_location: string; + receipt_qty: number | ""; + /** 같은 발주품목의 다른 차수가 차지하는 총량 — 잔여수량 계산용 */ +} + +let _rk = 0; +const nextKey = () => `k${++_rk}_${Date.now()}`; + +const todayIso = () => new Date().toISOString().slice(0, 10); + +export function InboundFormDialog({ open, onClose, onSaved, pomObjid }: Props) { + const [master, setMaster] = useState(null); + const [parts, setParts] = useState([]); + const [arrivals, setArrivals] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [warehouseOpts, setWarehouseOpts] = useState([]); + const [acctOpts, setAcctOpts] = useState([]); + + // 일괄적용 (좌 미입고 영역) — 운영판 LOCATION_CD/SUB_LOCATION_CD 일괄적용 + const [bulkLocation, setBulkLocation] = useState(""); + const [bulkSubLocation, setBulkSubLocation] = useState(""); + + useEffect(() => { + if (!open || !pomObjid) return; + setMaster(null); + setParts([]); + setArrivals([]); + + (async () => { + try { + const [ws, ac] = await Promise.all([ + purchaseApi.listWarehouses(), + purchaseApi.listAcctCodes(), + ]); + setWarehouseOpts(ws.map((v) => ({ code: v.code, label: v.label }))); + setAcctOpts(ac.map((v) => ({ code: v.code, label: v.label }))); + } catch {/* skip */} + })(); + + setLoading(true); + (async () => { + try { + const r = await purchaseApi.getInboundForm(pomObjid); + setMaster(r.master); + setParts(r.parts); + const initRows: ArrivalRow[] = []; + // 기존 입고 차수가 있으면 그대로 + for (const a of r.arrivals) { + initRows.push({ + rowKey: nextKey(), + objid: a.objid, + order_part_objid: a.order_part_objid, + part_objid: a.part_objid, + group_seq: a.group_seq || "1", + seq: a.seq || "", + receipt_date: a.receipt_date || todayIso(), + location: a.location || "", + sub_location: a.sub_location || "", + receipt_qty: a.receipt_qty || "", + }); + } + // 신규 — 각 발주품목마다 1차 행 (미입고수량 기본값) + if (initRows.length === 0) { + let seqCounter = 0; + for (const p of r.parts) { + seqCounter++; + initRows.push({ + rowKey: nextKey(), + objid: "", + order_part_objid: p.order_part_objid, + part_objid: p.part_objid, + group_seq: "1", + seq: String(seqCounter), + receipt_date: todayIso(), + location: "", + sub_location: "", + receipt_qty: p.non_arrival_qty || "", + }); + } + } + setArrivals(initRows); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "입고 정보 로드 실패"); + } finally { + setLoading(false); + } + })(); + }, [open, pomObjid]); + + /** 차수별 그룹 매핑 — order_part_objid → [arrival rows] */ + const groupedByPart = useMemo(() => { + const g: Record = {}; + for (const a of arrivals) { + const k = a.order_part_objid; + if (!g[k]) g[k] = []; + g[k].push(a); + } + return g; + }, [arrivals]); + + const handleAddArrivalForPart = (orderPartObjid: string, partObjid: string) => { + const existing = arrivals.filter((a) => a.order_part_objid === orderPartObjid); + const nextSeq = existing.length + 1; + setArrivals((prev) => [ + ...prev, + { + rowKey: nextKey(), + objid: "", + order_part_objid: orderPartObjid, + part_objid: partObjid, + group_seq: String(nextSeq), + seq: String(nextSeq), + receipt_date: todayIso(), + location: "", + sub_location: "", + receipt_qty: "", + }, + ]); + }; + + const handleRemoveArrival = (rowKey: string) => { + setArrivals((prev) => prev.filter((a) => a.rowKey !== rowKey)); + }; + + const updateArrival = (rowKey: string, patch: Partial) => { + setArrivals((prev) => prev.map((a) => a.rowKey === rowKey ? { ...a, ...patch } : a)); + }; + + const handleBulkApply = () => { + if (!bulkLocation && !bulkSubLocation) { + toast.info("일괄 적용할 입고창고 또는 계정과목을 선택하세요"); + return; + } + setArrivals((prev) => prev.map((a) => ({ + ...a, + ...(bulkLocation ? { location: bulkLocation } : {}), + ...(bulkSubLocation ? { sub_location: bulkSubLocation } : {}), + }))); + }; + + const handleSave = async () => { + if (!master) return; + const toSave = arrivals.filter((a) => Number(a.receipt_qty) > 0); + if (toSave.length === 0) { + toast.warning("입고 수량이 입력된 행이 없습니다"); + return; + } + setSaving(true); + try { + const rows: InboundSaveRow[] = toSave.map((a) => ({ + objid: a.objid || undefined, + parent_objid: master.pom_objid, + order_part_objid: a.order_part_objid, + part_objid: a.part_objid, + group_seq: a.group_seq || "1", + seq: a.seq || "", + receipt_date: a.receipt_date || "", + location: a.location || "", + sub_location: a.sub_location || "", + receipt_qty: Number(a.receipt_qty), + arrival_qty: Number(a.receipt_qty), + })); + const r = await purchaseApi.saveInboundForm(master.pom_objid, rows); + toast.success(`입고 등록 완료 (${r.saved}건)`); + onSaved?.(); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + { if (!v && !saving) onClose(); }}> + + 입고등록 + wace 운영판 입고등록 팝업 + +
+
입고등록
+
+ 발주번호 + {master?.purchase_order_no || "-"} + 프로젝트번호 + {master?.project_no || "-"} + 공급업체 + {master?.partner_name || "-"} +
+
+ +
+ {/* 일괄적용 영역 */} +
+ 미입고 +
+ +
+
+ +
+ +
+ + +
+ +
+ {/* 좌측: 발주 품목 */} +
+
+ + + + + + + + + + + + + + + + + {parts.length === 0 ? ( + + ) : parts.map((p, idx) => ( + + + + + + + + + + ))} + +
발주품목
품번품명규격단위수량미입고입고요청일
발주 품목이 없습니다
{p.part_no}{p.part_name}{p.spec}{p.unit_title}{p.order_qty.toLocaleString()}{p.non_arrival_qty.toLocaleString()}{p.delivery_request_date}
+
+
+ + {/* 우측: 차수별 입고 입력 */} +
+
+ + + + + + + + + + + + + + {parts.length === 0 ? ( + + ) : parts.map((p) => { + const groupRows = groupedByPart[p.order_part_objid] || []; + const rows: React.ReactElement[] = []; + groupRows.forEach((a, idx) => { + rows.push( + + {idx === 0 && ( + + )} + + + + + + + + ); + }); + // 차수 추가 행 + rows.push( + + + + ); + return rows; + }).flat()} + +
발주품목차수입고일입고창고계정과목입고수량
발주 품목이 없습니다
+
{p.part_no}
+
{p.part_name}
+
{a.group_seq}차 + updateArrival(a.rowKey, { receipt_date: v })} + size="sm" /> + + updateArrival(a.rowKey, { location: v })} + placeholder="선택" /> + + updateArrival(a.rowKey, { sub_location: v })} + placeholder="선택" /> + + updateArrival(a.rowKey, { receipt_qty: v })} + className="h-7 text-[11px]" /> + + +
+ +
+
+
+
+
+ +
+ ); +} diff --git a/frontend/lib/api/purchase.ts b/frontend/lib/api/purchase.ts index 9ab13c4e..32470ee4 100644 --- a/frontend/lib/api/purchase.ts +++ b/frontend/lib/api/purchase.ts @@ -101,6 +101,70 @@ export interface SendOrderMailPayload { pdfBase64: string; } +export interface InboundFormData { + master: { + pom_objid: string; + purchase_order_no: string; + project_no: string; + contract_mgmt_objid: string; + partner_objid: string; + partner_name: string; + delivery_status: string; + }; + parts: { + order_part_objid: string; + part_objid: string; + part_no: string; + part_name: string; + spec: string; + maker: string; + unit_title: string; + order_qty: number; + arrival_qty: number; + non_arrival_qty: number; + delivery_request_date: string; + }[]; + arrivals: { + objid: string; + order_part_objid: string; + part_objid: string; + group_seq: string; + seq: string; + receipt_date: string; + location: string; + sub_location: string; + receipt_qty: number; + arrival_qty: number; + inventory_status: string; + }[]; +} + +export interface InboundSaveRow { + objid?: string; + parent_objid: string; + order_part_objid: string; + part_objid: string; + group_seq: string; + seq: string; + receipt_date: string; + location: string; + sub_location: string; + receipt_qty: number; + arrival_qty: number; +} + +export interface DeadlineInfoPayload { + objIds: string[]; + taxType: string; + taxInvoiceDate?: string; + exportDeclNo?: string; + loadingDate?: string; + foreignType?: string; + duty?: string; + importVat?: string; + exchangeRate?: string; +} + export const purchaseApi = { // 그리드 7종 listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f), @@ -154,6 +218,32 @@ export const purchaseApi = { return r.data as { success: boolean; message: string; objid?: string }; }, + // ─── 입고관리 (입고등록 / 마감정보 / 매입마감) ───────────── + async getInboundForm(pomObjid: string): Promise { + const r = await apiClient.get(`/purchase/inbound-form/${encodeURIComponent(pomObjid)}`); + return r.data?.data as InboundFormData; + }, + async saveInboundForm(pomObjid: string, rows: InboundSaveRow[]): Promise<{ saved: number }> { + const r = await apiClient.post("/purchase/inbound-form/save", { pomObjid, rows }); + return r.data?.data as { saved: number }; + }, + async saveArrivalDeadline(body: DeadlineInfoPayload): Promise<{ updated: number }> { + const r = await apiClient.post("/purchase/arrival/deadline", body); + return r.data?.data as { updated: number }; + }, + async closeArrival(objIds: string[]): Promise<{ updated: number }> { + const r = await apiClient.post("/purchase/arrival/close", { objIds }); + return r.data?.data as { updated: number }; + }, + async listWarehouses(): Promise { + const r = await apiClient.get("/purchase/options/warehouses"); + return (r.data?.data ?? []) as OptionItem[]; + }, + async listAcctCodes(): Promise { + const r = await apiClient.get("/purchase/options/acct-codes"); + return (r.data?.data ?? []) as OptionItem[]; + }, + // 공통 옵션 async listSuppliers(): Promise { const r = await apiClient.get("/purchase/options/suppliers");