From e48bd83667db9f97ecadf40abedffbf8d8e5fa55 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 19 May 2026 13:06:18 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EC=84=9C=20=ED=8F=BC=20=EC=A0=80=EC=9E=A5/?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20+=20general=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: POST /api/purchase/order-form/save (마스터 55 + 파트 40 컬럼 UPSERT + 삭제파트 cascade, 트랜잭션, wace mergePurchaseOrderMaster/PartInfo 1:1) - 백엔드: DELETE /api/purchase/order-form/:objid (마스터+파트 cascade) - 프론트 lib/api: initOrderForm/getOrderForm/saveOrderForm/deleteOrderForm - 프론트 컴포넌트: PurchaseOrderGeneralFormDialog — wace purchaseOrderFormPopup_general.jsp 1:1 (좌 5필드/우 담당자 + 회사정보 2줄/그리드 10컬럼/총공급가액/보안문구) - /purchase/proposal "발주서생성" 버튼 활성화 → 품의서 자동 채움 다이얼로그 - /purchase/order 행 클릭/체크 → 수정/삭제 액션 + 다이얼로그 - Radix UI 접근성: DialogTitle/Description sr-only 처리 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/purchaseController.ts | 34 + backend-node/src/routes/purchaseRoutes.ts | 6 +- .../src/services/purchaseOrderFormService.ts | 345 ++++++++++ .../(main)/COMPANY_16/purchase/order/page.tsx | 58 +- .../COMPANY_16/purchase/proposal/page.tsx | 19 +- .../PurchaseOrderGeneralFormDialog.tsx | 591 ++++++++++++++++++ frontend/lib/api/purchase.ts | 39 ++ 7 files changed, 1088 insertions(+), 4 deletions(-) create mode 100644 frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index 6ca10c11..579f25a2 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -73,6 +73,40 @@ export async function getPurchaseOrderForm(req: AuthenticatedRequest, res: Respo } } +/** + * POST /api/purchase/order-form/save + * 마스터 + 파트 UPSERT + 삭제 처리. 트랜잭션. + */ +export async function savePurchaseOrderForm(req: AuthenticatedRequest, res: Response) { + try { + const payload = req.body as formSvc.SaveOrderFormPayload; + if (!payload || !payload.master) { + return res.status(400).json({ success: false, message: "master 가 필요해요" }); + } + const writer = String(req.user?.userId ?? ""); + const result = await formSvc.savePurchaseOrderForm(payload, 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 }); + } +} + +/** + * DELETE /api/purchase/order-form/:objid + */ +export async function deletePurchaseOrderForm(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid required" }); + await formSvc.deletePurchaseOrderForm(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 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 a382029f..fcfef1cb 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -21,8 +21,10 @@ 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 ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움 +router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT +router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 +router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade // 공통 옵션 router.get("/options/suppliers", ctrl.getSuppliers); diff --git a/backend-node/src/services/purchaseOrderFormService.ts b/backend-node/src/services/purchaseOrderFormService.ts index 444b146c..a541ef32 100644 --- a/backend-node/src/services/purchaseOrderFormService.ts +++ b/backend-node/src/services/purchaseOrderFormService.ts @@ -21,12 +21,24 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; export interface OrderFormInitResult { master: Record; parts: Record[]; } +export interface SaveOrderFormPayload { + master: Record; + parts: Record[]; + deletedPartObjids?: string[]; +} + +export interface SaveOrderFormResult { + objid: string; + purchase_order_no: string; +} + /** * GET /api/purchase/order-form/init?proposal_objid=... * @@ -238,3 +250,336 @@ function toNum(v: any): number { function formatKorDate(d: Date): string { return `${d.getFullYear()}년 ${String(d.getMonth() + 1).padStart(2, "0")}월 ${String(d.getDate()).padStart(2, "0")}일`; } + +/** "1,234,567.50" / 1234567.5 / null → '1234567.5' (numeric 컬럼은 모두 varchar 저장) */ +function strNum(v: any): string { + if (v == null) return ""; + const s = String(v).replace(/,/g, "").trim(); + return s; +} + +/** pick from various-cased keys (UPPER/lower/snake). 클라이언트가 어느 케이스로 보내든 수용. */ +function pick(o: Record, ...keys: string[]): any { + for (const k of keys) { + if (o == null) continue; + if (k in o && o[k] !== undefined && o[k] !== null) return o[k]; + const lk = k.toLowerCase(); + if (lk in o && o[lk] !== undefined && o[lk] !== null) return o[lk]; + const uk = k.toUpperCase(); + if (uk in o && o[uk] !== undefined && o[uk] !== null) return o[uk]; + } + return ""; +} + +/** + * POST /api/purchase/order-form/save + * + * wace `PurchaseOrderService.savePurchaseOrder_new` (1472-1817) 의 단일-마스터 분기 1:1. + * 1) 마스터 UPSERT (mergePurchaseOrderMaster — INSERT ... ON CONFLICT(OBJID) DO UPDATE) + * 2) 파트 UPSERT (mergePurchaseOrderPartInfo — INSERT ... ON CONFLICT(OBJID) DO UPDATE) + * 3) deletedPartObjids 일괄 DELETE + * 트랜잭션. 동시발주(MULTI_*)는 RPS 미사용이라 제외. + * + * payload.master.objid 가 비어 있으면 신규 채번(createObjId). + * payload.parts[i].objid 가 비어 있으면 신규 채번. + */ +export async function savePurchaseOrderForm( + payload: SaveOrderFormPayload, + writerId: string, +): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const m = payload.master ?? {}; + let masterObjid = String(pick(m, "objid") || "").trim(); + const isNew = !masterObjid; + if (isNew) masterObjid = createObjId(); + + // 마스터 UPSERT — wace mergePurchaseOrderMaster (purchaseOrder.xml 530-714) 1:1 + // PURCHASE_ORDER_NO: 신규일 때만 채번 서브쿼리, 수정은 기존 값 유지. + const writer = writerId || String(pick(m, "writer") || ""); + const params: any[] = [ + /* 1 */ masterObjid, + /* 2 */ String(pick(m, "po_client_id") || ""), + /* 3 */ String(pick(m, "category_cd") || ""), + /* 4 */ String(pick(m, "product_group") || ""), + /* 5 */ String(pick(m, "product") || ""), + /* 6 */ String(pick(m, "product_code") || ""), + /* 7 */ String(pick(m, "my_company_objid") || ""), + /* 8 */ String(pick(m, "partner_objid") || ""), + /* 9 */ String(pick(m, "delivery_date") || ""), + /* 10 */ String(pick(m, "delivery_place") || ""), + /* 11 */ String(pick(m, "effective_date") || ""), + /* 12 */ String(pick(m, "payment_terms") || ""), + /* 13 */ String(pick(m, "remark") || ""), + /* 14 */ String(pick(m, "request_content") || ""), + /* 15 */ writer, + /* 16 */ String(pick(m, "status") || "create"), + /* 17 */ String(pick(m, "sales_request_objid") || ""), + /* 18 */ String(pick(m, "sales_mng_user_id") || ""), + /* 19 */ String(pick(m, "sales_mng_user_id2") || ""), + /* 20 */ String(pick(m, "form_type") || "general"), + /* 21 */ String(pick(m, "title") || ""), + /* 22 */ String(pick(m, "purchase_date") || ""), + /* 23 */ String(pick(m, "contract_mgmt_objid") || ""), + /* 24 */ String(pick(m, "type") || ""), + /* 25 */ String(pick(m, "inspect_method") || ""), + /* 26 */ String(pick(m, "total_price_txt") || ""), + /* 27 */ String(pick(m, "total_price_txt_all") || ""), + /* 28 */ String(pick(m, "vat_method") || ""), + /* 29 */ strNum(pick(m, "total_supply_unit_price")), + /* 30 */ strNum(pick(m, "total_supply_price")), + /* 31 */ strNum(pick(m, "total_real_supply_price")), + /* 32 */ strNum(pick(m, "discount_price")), + /* 33 */ strNum(pick(m, "total_price")), + /* 34 */ strNum(pick(m, "total_price_all")), + /* 35 */ strNum(pick(m, "nego_rate")), + /* 36 */ String(pick(m, "supply_bus_no") || ""), + /* 37 */ String(pick(m, "supply_user_name") || ""), + /* 38 */ String(pick(m, "supply_user_hp") || ""), + /* 39 */ String(pick(m, "supply_user_tel") || ""), + /* 40 */ String(pick(m, "supply_user_fax") || ""), + /* 41 */ String(pick(m, "supply_user_email") || ""), + /* 42 */ String(pick(m, "supply_addr") || ""), + /* 43 */ String(pick(m, "unit_code") || ""), + /* 44 */ String(pick(m, "bom_report_objid") || ""), + /* 45 */ String(pick(m, "order_type_cd") || ""), + /* 46 */ String(pick(m, "multi_yn") || "N"), + /* 47 */ String(pick(m, "multi_master_yn") || "N"), + /* 48 */ String(pick(m, "multi_master_objid") || ""), + /* 49 */ String(pick(m, "delivery_plan_date") || ""), + /* 50 */ String(pick(m, "delivery_plan_qty") || ""), + /* 51 */ String(pick(m, "purchase_order_no_org") || ""), + /* 52 */ String(pick(m, "shipment") || ""), + /* 53 */ String(pick(m, "packing") || ""), + /* 54 */ String(pick(m, "validity") || ""), + /* 55 */ String(pick(m, "attn_to") || ""), + ]; + + const upsertMasterSql = ` + INSERT INTO PURCHASE_ORDER_MASTER ( + OBJID, PO_CLIENT_ID, PURCHASE_ORDER_NO, CATEGORY_CD, PRODUCT_GROUP, PRODUCT, PRODUCT_CODE, + MY_COMPANY_OBJID, PARTNER_OBJID, DELIVERY_DATE, DELIVERY_PLACE, EFFECTIVE_DATE, PAYMENT_TERMS, + REMARK, REQUEST_CONTENT, WRITER, REGDATE, STATUS, SALES_REQUEST_OBJID, SALES_MNG_USER_ID, + SALES_MNG_USER_ID2, FORM_TYPE, TITLE, PURCHASE_DATE, CONTRACT_MGMT_OBJID, TYPE, INSPECT_METHOD, + TOTAL_PRICE_TXT, TOTAL_PRICE_TXT_ALL, VAT_METHOD, TOTAL_SUPPLY_UNIT_PRICE, TOTAL_SUPPLY_PRICE, + TOTAL_REAL_SUPPLY_PRICE, DISCOUNT_PRICE, TOTAL_PRICE, TOTAL_PRICE_ALL, NEGO_RATE, + SUPPLY_BUS_NO, SUPPLY_USER_NAME, SUPPLY_USER_HP, SUPPLY_USER_TEL, SUPPLY_USER_FAX, + SUPPLY_USER_EMAIL, SUPPLY_ADDR, UNIT_CODE, BOM_REPORT_OBJID, ORDER_TYPE_CD, MULTI_YN, + MULTI_MASTER_YN, MULTI_MASTER_OBJID, DELIVERY_PLAN_DATE, DELIVERY_PLAN_QTY, + PURCHASE_ORDER_NO_ORG, SHIPMENT, PACKING, VALIDITY, ATTN_TO + ) VALUES ( + $1, $2, + (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') + FROM PURCHASE_ORDER_MASTER), + $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + NOW(), $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, + $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, + $46, $47, $48, $49, $50, $51, $52, $53, $54, $55 + ) ON CONFLICT (OBJID) DO UPDATE SET + PO_CLIENT_ID = EXCLUDED.PO_CLIENT_ID, + CATEGORY_CD = EXCLUDED.CATEGORY_CD, + PRODUCT_GROUP = EXCLUDED.PRODUCT_GROUP, + PRODUCT = EXCLUDED.PRODUCT, + PRODUCT_CODE = EXCLUDED.PRODUCT_CODE, + MY_COMPANY_OBJID = EXCLUDED.MY_COMPANY_OBJID, + PARTNER_OBJID = EXCLUDED.PARTNER_OBJID, + DELIVERY_DATE = EXCLUDED.DELIVERY_DATE, + DELIVERY_PLACE = EXCLUDED.DELIVERY_PLACE, + EFFECTIVE_DATE = EXCLUDED.EFFECTIVE_DATE, + PAYMENT_TERMS = EXCLUDED.PAYMENT_TERMS, + REMARK = EXCLUDED.REMARK, + REQUEST_CONTENT = EXCLUDED.REQUEST_CONTENT, + WRITER = EXCLUDED.WRITER, + STATUS = EXCLUDED.STATUS, + SALES_MNG_USER_ID = EXCLUDED.SALES_MNG_USER_ID, + SALES_MNG_USER_ID2 = EXCLUDED.SALES_MNG_USER_ID2, + FORM_TYPE = CASE WHEN EXCLUDED.FORM_TYPE = '' THEN PURCHASE_ORDER_MASTER.FORM_TYPE ELSE EXCLUDED.FORM_TYPE END, + TITLE = EXCLUDED.TITLE, + PURCHASE_DATE = EXCLUDED.PURCHASE_DATE, + CONTRACT_MGMT_OBJID = EXCLUDED.CONTRACT_MGMT_OBJID, + TYPE = EXCLUDED.TYPE, + INSPECT_METHOD = EXCLUDED.INSPECT_METHOD, + TOTAL_PRICE_TXT = EXCLUDED.TOTAL_PRICE_TXT, + TOTAL_PRICE_TXT_ALL = EXCLUDED.TOTAL_PRICE_TXT_ALL, + VAT_METHOD = EXCLUDED.VAT_METHOD, + TOTAL_SUPPLY_UNIT_PRICE = EXCLUDED.TOTAL_SUPPLY_UNIT_PRICE, + TOTAL_SUPPLY_PRICE = EXCLUDED.TOTAL_SUPPLY_PRICE, + TOTAL_REAL_SUPPLY_PRICE = EXCLUDED.TOTAL_REAL_SUPPLY_PRICE, + DISCOUNT_PRICE = EXCLUDED.DISCOUNT_PRICE, + TOTAL_PRICE = EXCLUDED.TOTAL_PRICE, + TOTAL_PRICE_ALL = EXCLUDED.TOTAL_PRICE_ALL, + NEGO_RATE = EXCLUDED.NEGO_RATE, + SUPPLY_BUS_NO = EXCLUDED.SUPPLY_BUS_NO, + SUPPLY_USER_NAME = EXCLUDED.SUPPLY_USER_NAME, + SUPPLY_USER_HP = EXCLUDED.SUPPLY_USER_HP, + SUPPLY_USER_TEL = EXCLUDED.SUPPLY_USER_TEL, + SUPPLY_USER_FAX = EXCLUDED.SUPPLY_USER_FAX, + SUPPLY_USER_EMAIL = EXCLUDED.SUPPLY_USER_EMAIL, + SUPPLY_ADDR = EXCLUDED.SUPPLY_ADDR, + UNIT_CODE = EXCLUDED.UNIT_CODE, + BOM_REPORT_OBJID = EXCLUDED.BOM_REPORT_OBJID, + ORDER_TYPE_CD = EXCLUDED.ORDER_TYPE_CD, + DELIVERY_PLAN_DATE = EXCLUDED.DELIVERY_PLAN_DATE, + DELIVERY_PLAN_QTY = EXCLUDED.DELIVERY_PLAN_QTY, + MULTI_YN = EXCLUDED.MULTI_YN, + MULTI_MASTER_YN = EXCLUDED.MULTI_MASTER_YN, + PURCHASE_ORDER_NO_ORG = EXCLUDED.PURCHASE_ORDER_NO_ORG, + SHIPMENT = EXCLUDED.SHIPMENT, + PACKING = EXCLUDED.PACKING, + VALIDITY = EXCLUDED.VALIDITY, + ATTN_TO = EXCLUDED.ATTN_TO + RETURNING OBJID, PURCHASE_ORDER_NO`; + + const upRes = await client.query(upsertMasterSql, params); + const savedNo: string = upRes.rows[0]?.purchase_order_no ?? ""; + + // 파트 UPSERT — wace mergePurchaseOrderPartInfo (1205-1325) 1:1 + const parts = payload.parts ?? []; + for (const raw of parts) { + const p: Record = raw ?? {}; + let popObjid = String(pick(p, "objid") || "").trim(); + if (!popObjid) popObjid = createObjId(); + + const popParams: any[] = [ + /* 1 */ popObjid, + /* 2 */ masterObjid, + /* 3 */ String(pick(p, "part_objid") || ""), // bigint 캐스트 in SQL + /* 4 */ strNum(pick(p, "bom_qty")), + /* 5 */ strNum(pick(p, "qty", "order_qty")), + /* 6 */ strNum(pick(p, "order_qty")), + /* 7 */ strNum(pick(p, "partner_price")), + /* 8 */ String(pick(p, "remark") || ""), + /* 9 */ writer, + /* 10 */ String(pick(p, "status") || "create"), + /* 11 */ String(pick(p, "part_name") || ""), + /* 12 */ String(pick(p, "part_no") || ""), + /* 13 */ String(pick(p, "do_no") || ""), + /* 14 */ String(pick(p, "thickness") || ""), + /* 15 */ String(pick(p, "width") || ""), + /* 16 */ String(pick(p, "height") || ""), + /* 17 */ String(pick(p, "out_diameter") || ""), + /* 18 */ String(pick(p, "length") || ""), + /* 19 */ String(pick(p, "in_diameter") || ""), + /* 20 */ String(pick(p, "inven_total_qty") || ""), + /* 21 */ String(pick(p, "ld_part_objid") || ""), + /* 22 */ String(pick(p, "spec") || ""), + /* 23 */ String(pick(p, "maker") || ""), + /* 24 */ String(pick(p, "unit") || ""), + /* 25 */ strNum(pick(p, "supply_unit_price")), + /* 26 */ strNum(pick(p, "price1")), + /* 27 */ strNum(pick(p, "price2")), + /* 28 */ strNum(pick(p, "price3")), + /* 29 */ strNum(pick(p, "price4")), + /* 30 */ strNum(pick(p, "supply_unit_vat_price")), + /* 31 */ strNum(pick(p, "supply_unit_vat_sum_price")), + /* 32 */ strNum(pick(p, "total_order_qty")), + /* 33 */ strNum(pick(p, "stock_qty")), + /* 34 */ strNum(pick(p, "real_order_qty")), + /* 35 */ strNum(pick(p, "real_supply_price")), + /* 36 */ String(pick(p, "part_delivery_place") || ""), + /* 37 */ String(pick(p, "product_name") || ""), + /* 38 */ String(pick(p, "work_order_no") || ""), + /* 39 */ String(pick(p, "delivery_request_date") || ""), + /* 40 */ String(pick(p, "currency") || ""), + ]; + + // part_objid 가 빈 문자열이면 NULL 로 — bigint 컬럼은 빈 문자열 INSERT 실패. + const partObjidParam = popParams[2] === "" ? null : popParams[2]; + popParams[2] = partObjidParam; + + await client.query( + `INSERT INTO PURCHASE_ORDER_PART ( + OBJID, PURCHASE_ORDER_MASTER_OBJID, PART_OBJID, BOM_QTY, QTY, ORDER_QTY, + PARTNER_PRICE, REMARK, WRITER, REGDATE, STATUS, + PART_NAME, PART_NO, DO_NO, THICKNESS, WIDTH, HEIGHT, OUT_DIAMETER, LENGTH, + IN_DIAMETER, INVEN_TOTAL_QTY, LD_PART_OBJID, SPEC, MAKER, UNIT, + SUPPLY_UNIT_PRICE, PRICE1, PRICE2, PRICE3, PRICE4, + SUPPLY_UNIT_VAT_PRICE, SUPPLY_UNIT_VAT_SUM_PRICE, TOTAL_ORDER_QTY, + STOCK_QTY, REAL_ORDER_QTY, REAL_SUPPLY_PRICE, + PART_DELIVERY_PLACE, PRODUCT_NAME, WORK_ORDER_NO, DELIVERY_REQUEST_DATE, CURRENCY + ) VALUES ( + $1, $2, NULLIF($3::text,'')::bigint, $4, $5, + COALESCE(NULLIF($6,'')::numeric::integer, 0), + $7, $8, $9, NOW(), $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, + $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, + $36, $37, $38, $39, $40 + ) ON CONFLICT (OBJID) DO UPDATE SET + ORDER_QTY = COALESCE(NULLIF($6,'')::numeric::integer, 0), + PARTNER_PRICE = EXCLUDED.PARTNER_PRICE, + REMARK = EXCLUDED.REMARK, + STATUS = EXCLUDED.STATUS, + MODIFIER = EXCLUDED.WRITER, + UPDATE_DATE = NOW(), + LD_PART_OBJID = EXCLUDED.LD_PART_OBJID, + SPEC = EXCLUDED.SPEC, + MAKER = EXCLUDED.MAKER, + UNIT = EXCLUDED.UNIT, + SUPPLY_UNIT_PRICE = EXCLUDED.SUPPLY_UNIT_PRICE, + PRICE1 = EXCLUDED.PRICE1, + PRICE2 = EXCLUDED.PRICE2, + PRICE3 = EXCLUDED.PRICE3, + PRICE4 = EXCLUDED.PRICE4, + SUPPLY_UNIT_VAT_PRICE = EXCLUDED.SUPPLY_UNIT_VAT_PRICE, + SUPPLY_UNIT_VAT_SUM_PRICE = EXCLUDED.SUPPLY_UNIT_VAT_SUM_PRICE, + TOTAL_ORDER_QTY = EXCLUDED.TOTAL_ORDER_QTY, + STOCK_QTY = EXCLUDED.STOCK_QTY, + REAL_ORDER_QTY = EXCLUDED.REAL_ORDER_QTY, + REAL_SUPPLY_PRICE = EXCLUDED.REAL_SUPPLY_PRICE, + PART_DELIVERY_PLACE = EXCLUDED.PART_DELIVERY_PLACE, + PRODUCT_NAME = EXCLUDED.PRODUCT_NAME, + WORK_ORDER_NO = EXCLUDED.WORK_ORDER_NO, + DELIVERY_REQUEST_DATE = EXCLUDED.DELIVERY_REQUEST_DATE, + CURRENCY = EXCLUDED.CURRENCY`, + popParams, + ); + } + + // 삭제된 파트 정리 — 클라이언트가 보낸 deletedPartObjids 일괄 DELETE + const deletedIds = (payload.deletedPartObjids ?? []).filter((s) => !!s); + if (deletedIds.length > 0) { + await client.query( + `DELETE FROM PURCHASE_ORDER_PART + WHERE PURCHASE_ORDER_MASTER_OBJID = $1 + AND OBJID = ANY($2::text[])`, + [masterObjid, deletedIds], + ); + } + + await client.query("COMMIT"); + return { objid: masterObjid, purchase_order_no: savedNo }; + } catch (e: any) { + await client.query("ROLLBACK"); + logger.error("savePurchaseOrderForm 실패", { error: e.message, stack: e.stack }); + throw e; + } finally { + client.release(); + } +} + +/** + * DELETE /api/purchase/order-form/:objid + * + * 마스터 1건 + 파트 cascade 삭제. wace `deletePurchaseOrderMaster` 의 단일판. + */ +export async function deletePurchaseOrderForm(objid: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM PURCHASE_ORDER_PART WHERE PURCHASE_ORDER_MASTER_OBJID = $1`, [objid]); + await client.query(`DELETE FROM PURCHASE_ORDER_MASTER WHERE OBJID = $1`, [objid]); + await client.query("COMMIT"); + } catch (e: any) { + await client.query("ROLLBACK"); + logger.error("deletePurchaseOrderForm 실패", { error: e.message }); + throw e; + } finally { + client.release(); + } +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index e1f75167..5a9773aa 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -18,6 +18,10 @@ 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"; +import { Button } from "@/components/ui/button"; +import { Pencil, Trash2 } from "lucide-react"; +import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; const MAIL_SEND_OPTS: SmartSelectOption[] = [ { code: "Y", label: "발송완료" }, @@ -65,6 +69,11 @@ export default function PurchaseOrderWacePage() { const [productOpts, setProductOpts] = useState([]); const [purchaseOpts, setPurchaseOpts] = useState([]); + // 수정 다이얼로그 + const [editOpen, setEditOpen] = useState(false); + const [editObjid, setEditObjid] = useState(""); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const yearOpts = useMemo(() => getYearOptions(), []); const fetchList = useCallback(async (override?: Partial) => { @@ -138,7 +147,40 @@ export default function PurchaseOrderWacePage() { return (
- + + + + } /> 총 {total.toLocaleString()}건}> @@ -233,7 +275,21 @@ export default function PurchaseOrderWacePage() { exportToExcel(exportRows, "발주서관리.xlsx", "발주서"); }} showChart + onRowClick={(row: any) => { + if (!row?.objid) return; + setEditObjid(String(row.objid)); + setEditOpen(true); + }} /> + + setEditOpen(false)} + onSaved={() => { setEditOpen(false); fetchList(); }} + /> + + {ConfirmDialogComponent}
); } diff --git a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx index 8e50b2f0..bcc1a1f3 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx @@ -17,6 +17,7 @@ import { PageHeader } from "@/components/common/PageHeader"; import { apiClient } from "@/lib/api/client"; import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase"; import { exportToExcel } from "@/lib/utils/excelExport"; +import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog"; const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code @@ -46,6 +47,10 @@ export default function ProposalPage() { const [partTypeOpts, setPartTypeOpts] = useState([]); const [userOpts, setUserOpts] = useState([]); + // 발주서생성 다이얼로그 + const [orderFormOpen, setOrderFormOpen] = useState(false); + const [orderFormProposalId, setOrderFormProposalId] = useState(""); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -124,7 +129,12 @@ export default function ProposalPage() { } @@ -194,6 +204,13 @@ export default function ProposalPage() { }} showChart /> + + setOrderFormOpen(false)} + onSaved={() => { setOrderFormOpen(false); fetchList(); }} + /> ); } diff --git a/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx b/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx new file mode 100644 index 00000000..904bcfcf --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx @@ -0,0 +1,591 @@ +"use client"; + +// 구매관리 > 발주서관리 — general 양식 등록/수정 다이얼로그 (PDF 양식 1:1) +// wace 원본: purchaseOrder/purchaseOrderFormPopup_general.jsp (운영판 폼) +// - 좌 박스 5필드: 발주번호 / 발주일자 / 수신업체 / 합계금액(VAT별도) / 결제방식 +// (납기일·납품장소는 운영판 주석 처리됨 — 그대로 미노출) +// - 우 박스: "담당자" 라벨 + 담당자1 + 담당자1 연락처/이메일 + 담당자2 + 담당자2 연락처/이메일 +// + "㈜알피에스 대표이사 이 동 헌" + "대전광역시 유성구 국제과학10로8(둔곡동 402-4번지)" +// - 저장/닫기 버튼: 폼 박스 우측 상단 +// - 그리드 컬럼: ☑ / No / 품명 / 규격 / 수량 / 단위 / 배송지 / 단가 / 공급가액 / 비고 / 입고요청일 +// - 푸터: 총 공급 가액 (VAT별도) + 보안 문구 (빨간색) +// +// 저장: purchaseApi.saveOrderForm({ master, parts, deletedPartObjids }) +// - SUPPLY_UNIT_PRICE = ORDER_QTY × PARTNER_PRICE (행 자동 계산) +// - TOTAL_SUPPLY_PRICE = Σ SUPPLY_UNIT_PRICE (마스터 자동 합산) + +import React, { useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; +import { purchaseApi, OptionItem } from "@/lib/api/purchase"; + +interface Props { + open: boolean; + onClose: () => void; + onSaved?: (result: { objid: string; purchase_order_no: string }) => void; + /** 수정 모드 시 POM OBJID — 미지정이고 proposalObjid 있으면 신규 (품의서 자동채움) */ + pomObjid?: string; + /** 신규 등록 시 발주 원천 품의서 OBJID */ + proposalObjid?: string; +} + +interface PartRow { + rowKey: string; + objid: string; + part_objid: string; + part_no: string; + part_name: string; + spec: string; + order_qty: number | ""; + qty: number | ""; + unit: string; + part_delivery_place: string; + partner_price: number | ""; + supply_unit_price: number; + remark: string; + delivery_request_date: string; + currency?: string; + _src?: string; +} + +interface MasterState { + objid: string; + purchase_order_no: string; + purchase_date: string; + partner_objid: string; + payment_terms: string; + sales_mng_user_id: string; + manager_name: string; + manager_position: string; + manager_phone: string; + manager_email: string; + sales_mng_user_id2: string; + manager_name2: string; + manager_position2: string; + manager_phone2: string; + manager_email2: string; + title: string; + request_content: string; + sales_request_objid: string; + contract_mgmt_objid: string; + form_type: string; + status: string; +} + +const EMPTY_MASTER: MasterState = { + objid: "", purchase_order_no: "", purchase_date: "", + partner_objid: "", payment_terms: "", + sales_mng_user_id: "", manager_name: "", manager_position: "", + manager_phone: "", manager_email: "", + sales_mng_user_id2: "", manager_name2: "", manager_position2: "", + manager_phone2: "", manager_email2: "", + title: "", request_content: "", + sales_request_objid: "", contract_mgmt_objid: "", + form_type: "general", status: "create", +}; + +const UNIT_GROUP_ID = "0001399"; // wace unit_cd (0001400=EA) +const DELIVERY_PLACE_GROUP = "0001146"; // wace delivery_place_cd +const PAYMENT_TERMS_GROUP = "0001074"; // wace payment_terms_cd + +let _rk = 0; +const nextKey = () => `r${++_rk}_${Date.now()}`; + +const toNum = (v: any): number => { + if (v == null || v === "") return 0; + const n = Number(String(v).replace(/,/g, "")); + return Number.isFinite(n) ? n : 0; +}; +const fmt = (n: number) => n.toLocaleString("ko-KR"); + +interface UserOptionExt extends OptionItem { + name?: string; + position?: string; + phone?: string; + email?: string; +} + +export function PurchaseOrderGeneralFormDialog({ + open, onClose, onSaved, pomObjid, proposalObjid, +}: Props) { + const isEdit = !!pomObjid; + const [master, setMaster] = useState(EMPTY_MASTER); + const [parts, setParts] = useState([]); + const [deletedPartIds, setDeletedPartIds] = useState([]); + const [checkedRowKeys, setCheckedRowKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const userOptsForSelect: SmartSelectOption[] = useMemo( + () => userOpts.map((u) => ({ code: u.code, label: u.label })), + [userOpts], + ); + + useEffect(() => { + if (!open) return; + setMaster(EMPTY_MASTER); + setParts([]); + setDeletedPartIds([]); + setCheckedRowKeys([]); + + (async () => { + try { + const [vs, us] = await Promise.all([ + purchaseApi.listVendors(), + purchaseApi.listUsers(), + ]); + setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label }))); + setUserOpts(us as UserOptionExt[]); + } catch {/* skip */} + })(); + + setLoading(true); + (async () => { + try { + if (isEdit && pomObjid) { + const r = await purchaseApi.getOrderForm(pomObjid); + applyServerData(r.master ?? {}, r.parts ?? []); + } else if (proposalObjid) { + const r = await purchaseApi.initOrderForm(proposalObjid); + applyServerData(r.master ?? {}, r.parts ?? []); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패"); + } finally { + setLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isEdit, pomObjid, proposalObjid]); + + const applyServerData = (m: Record, ps: Record[]) => { + setMaster({ + objid: String(m.objid ?? ""), + purchase_order_no: String(m.purchase_order_no ?? ""), + purchase_date: String(m.purchase_date ?? ""), + partner_objid: String(m.partner_objid ?? ""), + payment_terms: String(m.payment_terms ?? ""), + sales_mng_user_id: String(m.sales_mng_user_id ?? ""), + manager_name: String(m.manager_name ?? ""), + manager_position: String(m.manager_position ?? ""), + manager_phone: String(m.manager_phone ?? ""), + manager_email: String(m.manager_email ?? ""), + sales_mng_user_id2: String(m.sales_mng_user_id2 ?? ""), + manager_name2: String(m.manager_name2 ?? ""), + manager_position2: String(m.manager_position2 ?? ""), + manager_phone2: String(m.manager_phone2 ?? ""), + manager_email2: String(m.manager_email2 ?? ""), + title: String(m.title ?? ""), + request_content: String(m.request_content ?? ""), + sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""), + contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""), + form_type: String(m.form_type ?? "general"), + status: String(m.status ?? "create"), + }); + setParts(ps.map((p) => ({ + rowKey: nextKey(), + objid: String(p.objid ?? ""), + part_objid: String(p.part_objid ?? ""), + part_no: String(p.part_no ?? ""), + part_name: String(p.part_name ?? ""), + spec: String(p.spec ?? ""), + order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty), + qty: p.qty === "" || p.qty == null + ? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty)) + : Number(p.qty), + unit: String(p.unit || "0001400"), + part_delivery_place: String(p.part_delivery_place || "RPS"), + partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price), + supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)), + remark: String(p.remark ?? ""), + delivery_request_date: String(p.delivery_request_date ?? ""), + currency: String(p.currency ?? ""), + _src: p._src, + }))); + }; + + const totalSupplyPrice = useMemo( + () => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0), + [parts], + ); + + // 담당자 select 변경 시 hidden 필드(name/position/phone/email) 자동 채움 + const onManagerChange = (slot: 1 | 2, userId: string) => { + const u = userOpts.find((o) => o.code === userId); + setMaster((prev) => ({ + ...prev, + [`sales_mng_user_id${slot === 2 ? "2" : ""}`]: userId, + [`manager_name${slot === 2 ? "2" : ""}`]: u?.name ?? u?.label ?? "", + [`manager_position${slot === 2 ? "2" : ""}`]: u?.position ?? "", + [`manager_phone${slot === 2 ? "2" : ""}`]: u?.phone ?? "", + [`manager_email${slot === 2 ? "2" : ""}`]: u?.email ?? "", + })); + }; + + const updateRow = (rowKey: string, patch: Partial) => { + setParts((prev) => prev.map((r) => { + if (r.rowKey !== rowKey) return r; + const merged: PartRow = { ...r, ...patch }; + const q = toNum(merged.order_qty); + const u = toNum(merged.partner_price); + merged.qty = merged.order_qty; + merged.supply_unit_price = q * u; + return merged; + })); + }; + + const handleAddRow = () => { + setParts((prev) => [...prev, { + rowKey: nextKey(), + objid: "", part_objid: "", + part_no: "", part_name: "", spec: "", + order_qty: "", qty: "", + unit: "0001400", + part_delivery_place: "RPS", + partner_price: "", + supply_unit_price: 0, + remark: "", delivery_request_date: "", + }]); + }; + + const handleDeleteSelectedRows = () => { + if (checkedRowKeys.length === 0) { + toast.info("삭제할 행을 선택하세요"); + return; + } + setParts((prev) => { + const remaining: PartRow[] = []; + const deletedObjids: string[] = []; + for (const r of prev) { + if (checkedRowKeys.includes(r.rowKey)) { + if (r.objid) deletedObjids.push(r.objid); + } else { + remaining.push(r); + } + } + if (deletedObjids.length > 0) { + setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids]))); + } + return remaining; + }); + setCheckedRowKeys([]); + }; + + const toggleRowCheck = (rowKey: string, checked: boolean) => { + setCheckedRowKeys((prev) => + checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey)); + }; + const toggleAllCheck = (checked: boolean) => { + setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []); + }; + + const handleSave = async () => { + if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; } + if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; } + setSaving(true); + try { + const payload = { + master: { + ...master, + total_supply_price: String(totalSupplyPrice), + total_supply_unit_price: String(totalSupplyPrice), + total_price: String(totalSupplyPrice), + }, + parts: parts.map((p) => ({ + objid: p.objid, + part_objid: p.part_objid, + part_no: p.part_no, + part_name: p.part_name, + spec: p.spec, + order_qty: toNum(p.order_qty), + qty: toNum(p.qty || p.order_qty), + unit: p.unit, + part_delivery_place: p.part_delivery_place, + partner_price: toNum(p.partner_price), + supply_unit_price: toNum(p.supply_unit_price), + remark: p.remark, + delivery_request_date: p.delivery_request_date, + currency: p.currency, + })), + deletedPartObjids: deletedPartIds, + }; + const res = await purchaseApi.saveOrderForm(payload); + toast.success(`저장 완료 (${res.purchase_order_no})`); + onSaved?.(res); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + // ───────── 운영판 PDF 양식 1:1 (테두리 박스 + 좌/우 분할) ───────── + return ( + { if (!v) onClose(); }}> + + {/* Radix UI 접근성 — 시각 노출 없이 타이틀/설명 제공 */} + 발주서 (일반) + wace 운영판 일반 발주서 PDF 양식 +
+ {/* ── 상단 메인 박스 (PDF 양식) ── */} +
+ {/* 1행: 로고 + 타이틀 */} +
+
+ RPS +
+
+ 발 주 서 +
+
+
+ + {/* 2행: 좌(기본정보) + 우(담당자) */} +
+ {/* 좌: 기본정보 5필드 */} +
+ + + + + + + + + + + + + + + + + + + + + + + +
1. 발 주 번 호 : + {master.purchase_order_no || 자동생성} +
2. 발 주 일 자 : +
+ setMaster({ ...master, purchase_date: v })} /> +
+
3. 수 신 업 체 : +
+ setMaster({ ...master, partner_objid: v })} /> +
+
4. 합 계 금 액(VAT별도) : + {fmt(totalSupplyPrice)} +
5. 결 제 방 식 : +
+ setMaster({ ...master, payment_terms: v })} + withAll={false} /> +
+
+
+ + {/* 우: 담당자 박스 + 회사정보 */} +
+ + + + + + + + + + + + + + + + + + + + + + +
+ 담

자 +
+
+ onManagerChange(1, v)} /> +
+
+ ({master.manager_phone || "-"} / {master.manager_email || "-"}) +
+
+ onManagerChange(2, v)} /> +
+
+ ({master.manager_phone2 || "-"} / {master.manager_email2 || "-"}) +
+ ㈜알피에스 대표이사 이 동 헌 +
+ 대전광역시 유성구 국제과학10로8(둔곡동 402-4번지) +
+
+
+
+ + {/* 버튼 영역 (그리드 위, 우측 정렬) */} +
+ + + + +
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((row, i) => ( + + + + + + + + + + + + + + ))} + +
+ 0 && checkedRowKeys.length === parts.length} + onChange={(e) => toggleAllCheck(e.target.checked)} /> + No품명규격수량단위배송지단가공급가액비고입고요청일
+ 품의서에서 진입했다면 자동 채움 — 비어있으면 "행 추가" 로 시작 +
+ toggleRowCheck(row.rowKey, e.target.checked)} /> + {i + 1} + updateRow(row.rowKey, { part_name: e.target.value })} /> + + updateRow(row.rowKey, { spec: e.target.value })} /> + + updateRow(row.rowKey, { order_qty: v })} + className="h-7 text-[11px]" /> + + updateRow(row.rowKey, { unit: v })} + withAll={false} className="h-7" /> + + updateRow(row.rowKey, { part_delivery_place: v })} + withAll={false} className="h-7" /> + + updateRow(row.rowKey, { partner_price: v })} + className="h-7 text-[11px]" /> + + {fmt(row.supply_unit_price)} + + updateRow(row.rowKey, { remark: e.target.value })} /> + + updateRow(row.rowKey, { delivery_request_date: v })} + size="sm" /> +
+
+ + {/* 합계 영역 */} +
+ + + + + + + +
+ 총 공 급 가 액 (VAT별도) + + {fmt(totalSupplyPrice)} +
+
+ + {/* 보안 문구 */} +
+ ※ 보안문서(CONFIDENTIAL) : ㈜알피에스의 승인(APPROVAL) 없이 외부로 반출하거나 공유 할수 없습니다. +
+
+
+
+ ); +} diff --git a/frontend/lib/api/purchase.ts b/frontend/lib/api/purchase.ts index f25d0239..f747dbb4 100644 --- a/frontend/lib/api/purchase.ts +++ b/frontend/lib/api/purchase.ts @@ -57,6 +57,22 @@ async function getList(path: string, filter: PurchaseListFilter): Promi return res.data?.data as PurchaseListResponse; } +export interface OrderFormData { + master: Record; + parts: Record[]; +} + +export interface SaveOrderPayload { + master: Record; + parts: Record[]; + deletedPartObjids?: string[]; +} + +export interface SaveOrderResult { + objid: string; + purchase_order_no: string; +} + export const purchaseApi = { // 그리드 7종 listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f), @@ -68,6 +84,29 @@ export const purchaseApi = { listProjectStatus: (f: PurchaseListFilter = {}) => getList("project-status", f), listOrder: (f: PurchaseListFilter = {}) => getList("order-list", f), + // ─── 발주서 폼 (general 양식) ────────────────────────── + /** 품의서 OBJID 로 발주서 폼 초기값 + 품목 자동채움. */ + async initOrderForm(proposalObjid: string): Promise { + const r = await apiClient.get("/purchase/order-form/init", { + params: { proposal_objid: proposalObjid }, + }); + return r.data?.data as OrderFormData; + }, + /** 기존 발주서 마스터+파트 조회 (수정 모드). */ + async getOrderForm(objid: string): Promise { + const r = await apiClient.get(`/purchase/order-form/${encodeURIComponent(objid)}`); + return r.data?.data as OrderFormData; + }, + /** 마스터+파트 UPSERT + 누락파트 삭제. */ + async saveOrderForm(payload: SaveOrderPayload): Promise { + const r = await apiClient.post("/purchase/order-form/save", payload); + return r.data?.data as SaveOrderResult; + }, + /** 발주서 삭제 (cascade). */ + async deleteOrderForm(objid: string): Promise { + await apiClient.delete(`/purchase/order-form/${encodeURIComponent(objid)}`); + }, + // 공통 옵션 async listSuppliers(): Promise { const r = await apiClient.get("/purchase/options/suppliers"); From 806153174cad8c7460df873b1ca8b979f94c5358 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 19 May 2026 13:16:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EC=84=9C=20=ED=8F=BC=20=EB=A1=9C=EA=B3=A0/?= =?UTF-8?q?=EC=A7=81=EC=9D=B8=20+=20=EC=9D=BD=EA=B8=B0=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로고 이미지: 다이얼로그 상단 좌측 RPS 로고 (rps-logo-on.png 재사용, wace `images/logo.png` 1:1) - 직인 이미지: 담당자1 phone/email 행 절대위치 (rps-stamp-seal.png, wace `images/stamp_seal.png` 가져옴, onError hide) - 읽기전용 모드: APPR_STATUS='결재중'/'결재완료' 또는 STATUS='cancel' 일 때 - 마스터 폼 4필드(발주일자/수신업체/결제방식/담당자1·2) + 그리드 편집 셀 + 체크박스 disabled - 행 추가/삭제/저장 버튼 숨김 → '발주서다운'(window.print) 으로 교체 - 상단 amber 배너로 사유(결재상태/취소) 안내 - MasterState.appr_status 필드 추가 (백엔드 응답 그대로 매핑) wace _general.jsp isModify 분기(10~17줄) + 직인 절대위치(878줄) + 버튼 영역(941~948줄) 1:1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PurchaseOrderGeneralFormDialog.tsx | 118 +++++++++++++----- frontend/public/images/rps-stamp-seal.png | Bin 0 -> 323950 bytes 2 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 frontend/public/images/rps-stamp-seal.png diff --git a/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx b/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx index 904bcfcf..ef268a9f 100644 --- a/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx +++ b/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx @@ -77,6 +77,8 @@ interface MasterState { contract_mgmt_objid: string; form_type: string; status: string; + /** 결재 상태 — wace isModify 분기에 사용 ('결재중'/'결재완료' 시 읽기전용) */ + appr_status: string; } const EMPTY_MASTER: MasterState = { @@ -89,6 +91,7 @@ const EMPTY_MASTER: MasterState = { title: "", request_content: "", sales_request_objid: "", contract_mgmt_objid: "", form_type: "general", status: "create", + appr_status: "", }; const UNIT_GROUP_ID = "0001399"; // wace unit_cd (0001400=EA) @@ -190,6 +193,7 @@ export function PurchaseOrderGeneralFormDialog({ contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""), form_type: String(m.form_type ?? "general"), status: String(m.status ?? "create"), + appr_status: String(m.appr_status ?? ""), }); setParts(ps.map((p) => ({ rowKey: nextKey(), @@ -218,6 +222,18 @@ export function PurchaseOrderGeneralFormDialog({ [parts], ); + // 읽기전용 모드 — wace _general.jsp isModify 분기 1:1 + // APPR_STATUS='결재중' / '결재완료' / STATUS='cancel' 일 때 입력 잠금 + const isReadOnly = useMemo(() => { + const a = master.appr_status; + return a === "결재중" || a === "결재완료" || master.status === "cancel"; + }, [master.appr_status, master.status]); + + /** 발주서다운 — 읽기전용 모드용 PDF 저장 (간이판: 브라우저 인쇄 다이얼로그) */ + const handleDownload = () => { + window.print(); + }; + // 담당자 select 변경 시 hidden 필드(name/position/phone/email) 자동 채움 const onManagerChange = (slot: 1 | 2, userId: string) => { const u = userOpts.find((o) => o.code === userId); @@ -342,7 +358,9 @@ export function PurchaseOrderGeneralFormDialog({ {/* 1행: 로고 + 타이틀 */}
- RPS + {/* wace `<%=request.getContextPath()%>/images/logo.png` 1:1 */} + RPS Logo
발 주 서 @@ -367,7 +385,8 @@ export function PurchaseOrderGeneralFormDialog({
setMaster({ ...master, purchase_date: v })} /> + onChange={(v) => setMaster({ ...master, purchase_date: v })} + disabled={isReadOnly} />
@@ -377,7 +396,8 @@ export function PurchaseOrderGeneralFormDialog({
setMaster({ ...master, partner_objid: v })} /> + onValueChange={(v) => setMaster({ ...master, partner_objid: v })} + disabled={isReadOnly} />
@@ -394,7 +414,8 @@ export function PurchaseOrderGeneralFormDialog({ setMaster({ ...master, payment_terms: v })} - withAll={false} /> + withAll={false} + disabled={isReadOnly} />
@@ -415,13 +436,19 @@ export function PurchaseOrderGeneralFormDialog({
onManagerChange(1, v)} /> + onValueChange={(v) => onManagerChange(1, v)} + disabled={isReadOnly} />
({master.manager_phone || "-"} / {master.manager_email || "-"}) + {/* 직인 — wace `images/stamp_rps.png` (운영 파일은 stamp_seal.png) onerror hide */} + 직인 { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> @@ -429,7 +456,8 @@ export function PurchaseOrderGeneralFormDialog({
onManagerChange(2, v)} /> + onValueChange={(v) => onManagerChange(2, v)} + disabled={isReadOnly} />
@@ -454,21 +482,41 @@ export function PurchaseOrderGeneralFormDialog({
- {/* 버튼 영역 (그리드 위, 우측 정렬) */} + {/* 읽기전용 안내 — wace isModify=false 분기 */} + {isReadOnly && ( +
+ 결재 진행/완료 또는 취소 상태의 발주서는 수정할 수 없습니다. + {master.appr_status ? ` (결재상태: ${master.appr_status})` : ""} + {master.status === "cancel" ? " (취소됨)" : ""} +
+ )} + + {/* 버튼 영역 (그리드 위, 우측 정렬) — wace _general.jsp 941-948 1:1 */}
- - - + {!isReadOnly && ( + <> + + + + + )} + {isReadOnly && ( + + )} @@ -278,15 +283,27 @@ export default function PurchaseOrderWacePage() { onRowClick={(row: any) => { if (!row?.objid) return; setEditObjid(String(row.objid)); - setEditOpen(true); + setEditFormType(String(row.form_type || "general") as PurchaseOrderFormType); }} /> setEditOpen(false)} - onSaved={() => { setEditOpen(false); fetchList(); }} + onClose={() => setEditFormType("")} + onSaved={() => { setEditFormType(""); fetchList(); }} + /> + setEditFormType("")} + onSaved={() => { setEditFormType(""); fetchList(); }} + /> + setEditFormType("")} + onSaved={() => { setEditFormType(""); fetchList(); }} /> {ConfirmDialogComponent} diff --git a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx index bcc1a1f3..0790af9d 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx @@ -18,6 +18,12 @@ import { apiClient } from "@/lib/api/client"; import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase"; import { exportToExcel } from "@/lib/utils/excelExport"; import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog"; +import { PurchaseOrderOutsourcingFormDialog } from "@/components/purchase/PurchaseOrderOutsourcingFormDialog"; +import { PurchaseOrderEnglishFormDialog } from "@/components/purchase/PurchaseOrderEnglishFormDialog"; +import { + PurchaseOrderFormTypeSelectDialog, + PurchaseOrderFormType, +} from "@/components/purchase/PurchaseOrderFormTypeSelectDialog"; const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code @@ -47,8 +53,9 @@ export default function ProposalPage() { const [partTypeOpts, setPartTypeOpts] = useState([]); const [userOpts, setUserOpts] = useState([]); - // 발주서생성 다이얼로그 - const [orderFormOpen, setOrderFormOpen] = useState(false); + // 발주서생성 — 양식 선택 모달 → 양식별 다이얼로그 + const [typeSelectOpen, setTypeSelectOpen] = useState(false); + const [orderFormType, setOrderFormType] = useState(""); const [orderFormProposalId, setOrderFormProposalId] = useState(""); const fetchList = useCallback(async (override?: Partial) => { @@ -133,7 +140,7 @@ export default function ProposalPage() { const proposalId = checkedIds[0]; if (!proposalId) return; setOrderFormProposalId(proposalId); - setOrderFormOpen(true); + setTypeSelectOpen(true); }}> 발주서생성 @@ -205,11 +212,29 @@ export default function ProposalPage() { showChart /> + setTypeSelectOpen(false)} + onSelect={(t) => setOrderFormType(t)} + /> + setOrderFormOpen(false)} - onSaved={() => { setOrderFormOpen(false); fetchList(); }} + onClose={() => setOrderFormType("")} + onSaved={() => { setOrderFormType(""); fetchList(); }} + /> + setOrderFormType("")} + onSaved={() => { setOrderFormType(""); fetchList(); }} + /> + setOrderFormType("")} + onSaved={() => { setOrderFormType(""); fetchList(); }} />
); diff --git a/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx new file mode 100644 index 00000000..0d0332b1 --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx @@ -0,0 +1,586 @@ +"use client"; + +// 구매관리 > 발주서관리 — english 양식 등록/수정 다이얼로그 +// wace 원본: purchaseOrder/purchaseOrderFormPopup_english.jsp +// - 헤더: 로고 110px + "R P S CO., LTD." + 영문 회사정보 (주소/Tel/Fax/E-mail/Purchasing Team Manager) +// - 타이틀: "Purchase Order" (밑줄) + 부제 "We are pleased to issue Purchase Order ..." +// - 좌+우 2열 5행 필드: Messrs./Shipment, Attn.to/Payment, Date/Packing, Ref.NO/Validity, (빈)/Remarks +// - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery +// - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65) + +import React, { useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; +import { purchaseApi, OptionItem } from "@/lib/api/purchase"; + +interface Props { + open: boolean; + onClose: () => void; + onSaved?: (result: { objid: string; purchase_order_no: string }) => void; + pomObjid?: string; + proposalObjid?: string; +} + +interface PartRow { + rowKey: string; + objid: string; + part_objid: string; + part_no: string; + part_name: string; + spec: string; + order_qty: number | ""; + qty: number | ""; + unit: string; + currency: string; + partner_price: number | ""; + supply_unit_price: number; + delivery_request_date: string; + _src?: string; +} + +interface MasterState { + objid: string; + purchase_order_no: string; + purchase_date: string; + partner_objid: string; + payment_terms: string; + shipment: string; + attn_to: string; + packing: string; + validity: string; + remark: string; + sales_mng_user_id: string; + manager_name: string; + manager_position: string; + manager_phone: string; + manager_email: string; + sales_request_objid: string; + contract_mgmt_objid: string; + form_type: string; + status: string; + appr_status: string; +} + +const EMPTY_MASTER: MasterState = { + objid: "", purchase_order_no: "", purchase_date: "", + partner_objid: "", payment_terms: "", + shipment: "", attn_to: "", packing: "", validity: "", remark: "", + sales_mng_user_id: "", manager_name: "", manager_position: "", + manager_phone: "", manager_email: "", + sales_request_objid: "", contract_mgmt_objid: "", + form_type: "english", status: "create", + appr_status: "", +}; + +const UNIT_GROUP_ID = "0001399"; +const CURRENCY_GROUP_ID = "0001533"; // wace currency_cd (0001534=USD default) +const DEFAULT_CURRENCY = "0001534"; + +let _rk = 0; +const nextKey = () => `r${++_rk}_${Date.now()}`; + +const toNum = (v: any): number => { + if (v == null || v === "") return 0; + const n = Number(String(v).replace(/,/g, "")); + return Number.isFinite(n) ? n : 0; +}; +const fmt2 = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +interface UserOptionExt extends OptionItem { + name?: string; + position?: string; + phone?: string; + email?: string; +} + +export function PurchaseOrderEnglishFormDialog({ + open, onClose, onSaved, pomObjid, proposalObjid, +}: Props) { + const isEdit = !!pomObjid; + const [master, setMaster] = useState(EMPTY_MASTER); + const [parts, setParts] = useState([]); + const [deletedPartIds, setDeletedPartIds] = useState([]); + const [checkedRowKeys, setCheckedRowKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + useEffect(() => { + if (!open) return; + setMaster({ ...EMPTY_MASTER }); + setParts([]); + setDeletedPartIds([]); + setCheckedRowKeys([]); + + (async () => { + try { + const [vs, us] = await Promise.all([ + purchaseApi.listVendors(), + purchaseApi.listUsers(), + ]); + setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label }))); + setUserOpts(us as UserOptionExt[]); + } catch {/* skip */} + })(); + + setLoading(true); + (async () => { + try { + if (isEdit && pomObjid) { + const r = await purchaseApi.getOrderForm(pomObjid); + applyServerData(r.master ?? {}, r.parts ?? []); + } else if (proposalObjid) { + const r = await purchaseApi.initOrderForm(proposalObjid); + applyServerData(r.master ?? {}, r.parts ?? []); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패"); + } finally { + setLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isEdit, pomObjid, proposalObjid]); + + const applyServerData = (m: Record, ps: Record[]) => { + setMaster({ + objid: String(m.objid ?? ""), + purchase_order_no: String(m.purchase_order_no ?? ""), + purchase_date: String(m.purchase_date ?? ""), + partner_objid: String(m.partner_objid ?? ""), + payment_terms: String(m.payment_terms ?? ""), + shipment: String(m.shipment ?? ""), + attn_to: String(m.attn_to ?? ""), + packing: String(m.packing ?? ""), + validity: String(m.validity ?? ""), + remark: String(m.remark ?? ""), + sales_mng_user_id: String(m.sales_mng_user_id ?? ""), + manager_name: String(m.manager_name ?? ""), + manager_position: String(m.manager_position ?? ""), + manager_phone: String(m.manager_phone ?? ""), + manager_email: String(m.manager_email ?? ""), + sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""), + contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""), + form_type: "english", + status: String(m.status ?? "create"), + appr_status: String(m.appr_status ?? ""), + }); + setParts(ps.map((p) => ({ + rowKey: nextKey(), + objid: String(p.objid ?? ""), + part_objid: String(p.part_objid ?? ""), + part_no: String(p.part_no ?? ""), + part_name: String(p.part_name ?? ""), + spec: String(p.spec ?? ""), + order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty), + qty: p.qty === "" || p.qty == null + ? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty)) + : Number(p.qty), + unit: String(p.unit || "0001400"), + currency: String(p.currency || DEFAULT_CURRENCY), + partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price), + supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)), + delivery_request_date: String(p.delivery_request_date ?? ""), + _src: p._src, + }))); + }; + + const totalSupplyPrice = useMemo( + () => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0), + [parts], + ); + + const isReadOnly = useMemo(() => { + const a = master.appr_status; + return a === "결재중" || a === "결재완료" || master.status === "cancel"; + }, [master.appr_status, master.status]); + + const handleDownload = () => window.print(); + + const updateRow = (rowKey: string, patch: Partial) => { + setParts((prev) => prev.map((r) => { + if (r.rowKey !== rowKey) return r; + const merged: PartRow = { ...r, ...patch }; + const q = toNum(merged.order_qty); + const u = toNum(merged.partner_price); + merged.qty = merged.order_qty; + merged.supply_unit_price = q * u; + return merged; + })); + }; + + const handleAddRow = () => { + setParts((prev) => [...prev, { + rowKey: nextKey(), + objid: "", part_objid: "", + part_no: "", part_name: "", spec: "", + order_qty: "", qty: "", + unit: "0001400", + currency: DEFAULT_CURRENCY, + partner_price: "", + supply_unit_price: 0, + delivery_request_date: "", + }]); + }; + + const handleDeleteSelectedRows = () => { + if (checkedRowKeys.length === 0) { + toast.info("Select rows to delete"); + return; + } + setParts((prev) => { + const remaining: PartRow[] = []; + const deletedObjids: string[] = []; + for (const r of prev) { + if (checkedRowKeys.includes(r.rowKey)) { + if (r.objid) deletedObjids.push(r.objid); + } else { + remaining.push(r); + } + } + if (deletedObjids.length > 0) { + setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids]))); + } + return remaining; + }); + setCheckedRowKeys([]); + }; + + const toggleRowCheck = (rowKey: string, checked: boolean) => { + setCheckedRowKeys((prev) => + checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey)); + }; + const toggleAllCheck = (checked: boolean) => { + setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []); + }; + + const handleSave = async () => { + if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; } + if (parts.length === 0) { toast.warning("No items"); return; } + setSaving(true); + try { + const payload = { + master: { + ...master, + form_type: "english", + total_supply_price: String(totalSupplyPrice), + total_supply_unit_price: String(totalSupplyPrice), + total_price: String(totalSupplyPrice), + }, + parts: parts.map((p) => ({ + objid: p.objid, + part_objid: p.part_objid, + part_no: p.part_no, + part_name: p.part_name, + spec: p.spec, + order_qty: toNum(p.order_qty), + qty: toNum(p.qty || p.order_qty), + unit: p.unit, + currency: p.currency, + partner_price: toNum(p.partner_price), + supply_unit_price: toNum(p.supply_unit_price), + delivery_request_date: p.delivery_request_date, + })), + deletedPartObjids: deletedPartIds, + }; + const res = await purchaseApi.saveOrderForm(payload); + toast.success(`Saved (${res.purchase_order_no})`); + onSaved?.(res); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "Save failed"); + } finally { + setSaving(false); + } + }; + + return ( + { if (!v) onClose(); }}> + + Purchase Order (English) + wace English Purchase Order PDF form +
+ {/* 헤더 */} +
+
+ RPS Logo +
+
+
R P S CO., LTD.
+
www.rps-korea.com
+
8, Gukjegwahak 10-ro, Yuseong-gu, Daejeon, Republic of Korea
+
Tel : +82-42-602-3300 / Fax : +82-42-672-3399 / E-mail : ady1225@rps-korea.com
+
Purchasing Team Manager, An-Dong-Yoon
+
+
+ +
+ Purchase Order +
+

+ We are pleased to issue Purchase Order with the terms and condition described as below. +

+ + {/* 좌+우 2열 5행 정보 테이블 */} + + + setMaster({ ...master, partner_objid: v })} + disabled={isReadOnly} /> + } + rightLabel="Shipment" + rightBg="#ebf1de" + rightCell={ + setMaster({ ...master, shipment: e.target.value })} + disabled={isReadOnly} /> + } + /> + setMaster({ ...master, attn_to: e.target.value })} + disabled={isReadOnly} /> + } + rightLabel="Payment" + rightCell={ + setMaster({ ...master, payment_terms: e.target.value })} + disabled={isReadOnly} /> + } + /> + setMaster({ ...master, purchase_date: v })} + disabled={isReadOnly} /> + } + rightLabel="Packing" + rightCell={ + setMaster({ ...master, packing: e.target.value })} + disabled={isReadOnly} /> + } + /> + {master.purchase_order_no || auto} + } + rightLabel="Validity" + rightCell={ + setMaster({ ...master, validity: e.target.value })} + disabled={isReadOnly} /> + } + /> +  } + rightLabel="Remarks" + rightCell={ + setMaster({ ...master, remark: e.target.value })} + disabled={isReadOnly} /> + } + /> + +
+ + {isReadOnly && ( +
+ This order is locked (approval in progress / completed or cancelled). + {master.appr_status ? ` (status: ${master.appr_status})` : ""} + {master.status === "cancel" ? " (cancelled)" : ""} +
+ )} + +
+ {!isReadOnly && ( + <> + + + + + )} + {isReadOnly && ( + + )} + +
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((row) => { + const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name; + return ( + + + + + + + + + + + + ); + })} + +
+ 0 && checkedRowKeys.length === parts.length} + onChange={(e) => toggleAllCheck(e.target.checked)} + disabled={isReadOnly} /> + Item No.Commodity & DescriptionUnitQ'tyCurrencyUnit PriceAmountDelivery
+ No items — use "Add Row" to start +
+ toggleRowCheck(row.rowKey, e.target.checked)} + disabled={isReadOnly} /> + {row.part_no}{desc} + updateRow(row.rowKey, { unit: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { order_qty: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { currency: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { partner_price: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + {fmt2(row.supply_unit_price)} + + updateRow(row.rowKey, { delivery_request_date: v })} + size="sm" + disabled={isReadOnly} /> +
+
+ + {/* TOTAL */} + + + + + + + +
+ TOTAL + + {fmt2(totalSupplyPrice)} +
+ +

+ Look forward to your soonest delivery with good condition. +

+ + {/* 서명 영역 */} +
+
+
Signed by Dong-Heon Lee / President
+
RPS CO.,LTD
+ Stamp { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> +
+
+
+
+ ); +} + +interface FieldRowProps { + leftLabel: string; + leftCell: React.ReactNode; + rightLabel: string; + rightCell: React.ReactNode; + rightBg?: string; +} +function FieldRow({ leftLabel, leftCell, rightLabel, rightCell, rightBg }: FieldRowProps) { + return ( + + {leftLabel || " "} + {leftCell} + + {rightLabel} + {rightCell} + + ); +} diff --git a/frontend/components/purchase/PurchaseOrderFormTypeSelectDialog.tsx b/frontend/components/purchase/PurchaseOrderFormTypeSelectDialog.tsx new file mode 100644 index 00000000..8aafcd46 --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderFormTypeSelectDialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +// 구매관리 > 품의서관리 > 발주서생성 — 양식 선택 모달 +// 운영판 wace 1:1 — 일반 / 외주가공 / 영문 / 취소 + +import React from "react"; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export type PurchaseOrderFormType = "general" | "outsourcing" | "english"; + +interface Props { + open: boolean; + onClose: () => void; + onSelect: (formType: PurchaseOrderFormType) => void; +} + +export function PurchaseOrderFormTypeSelectDialog({ open, onClose, onSelect }: Props) { + const handlePick = (t: PurchaseOrderFormType) => { + onSelect(t); + onClose(); + }; + + return ( + { if (!v) onClose(); }}> + + 발주서 양식 선택 + 일반, 외주가공, 영문 중 하나를 선택하세요 +
+
발주서 양식을 선택하세요
+
+ + + +
+ +
+
+
+ ); +} diff --git a/frontend/components/purchase/PurchaseOrderOutsourcingFormDialog.tsx b/frontend/components/purchase/PurchaseOrderOutsourcingFormDialog.tsx new file mode 100644 index 00000000..38f5a211 --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderOutsourcingFormDialog.tsx @@ -0,0 +1,598 @@ +"use client"; + +// 구매관리 > 발주서관리 — outsourcing 양식 등록/수정 다이얼로그 +// wace 원본: purchaseOrder/purchaseOrderFormPopup_outsourcing.jsp +// - 타이틀: "외주가공 발주서" +// - 좌 박스 4필드: 발주번호 / 발주일자 / 수신업체 / 합계금액(VAT별도) — 결제방식 X +// - 우 박스: 일반발주서와 동일 (담당자1/2 + 회사정보 2줄 + 직인) +// - 그리드 컬럼: ☑ / No / 업체명 / 제품명 / 부품명 / 수량 / 단위 / 단가 / 합계 / 작업지시번호 / 부품품번 / 입고요청일 +// - 푸터: 총공급가액(VAT별도) + 한글 보안문구 + +import React, { useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; +import { purchaseApi, OptionItem } from "@/lib/api/purchase"; + +interface Props { + open: boolean; + onClose: () => void; + onSaved?: (result: { objid: string; purchase_order_no: string }) => void; + pomObjid?: string; + proposalObjid?: string; +} + +interface PartRow { + rowKey: string; + objid: string; + part_objid: string; + part_no: string; + product_name: string; + part_name: string; + spec: string; + order_qty: number | ""; + qty: number | ""; + unit: string; + partner_price: number | ""; + supply_unit_price: number; + work_order_no: string; + delivery_request_date: string; + _src?: string; +} + +interface MasterState { + objid: string; + purchase_order_no: string; + purchase_date: string; + partner_objid: string; + sales_mng_user_id: string; + manager_name: string; + manager_position: string; + manager_phone: string; + manager_email: string; + sales_mng_user_id2: string; + manager_name2: string; + manager_position2: string; + manager_phone2: string; + manager_email2: string; + title: string; + request_content: string; + sales_request_objid: string; + contract_mgmt_objid: string; + form_type: string; + status: string; + appr_status: string; +} + +const EMPTY_MASTER: MasterState = { + objid: "", purchase_order_no: "", purchase_date: "", + partner_objid: "", + sales_mng_user_id: "", manager_name: "", manager_position: "", + manager_phone: "", manager_email: "", + sales_mng_user_id2: "", manager_name2: "", manager_position2: "", + manager_phone2: "", manager_email2: "", + title: "", request_content: "", + sales_request_objid: "", contract_mgmt_objid: "", + form_type: "outsourcing", status: "create", + appr_status: "", +}; + +const UNIT_GROUP_ID = "0001399"; + +let _rk = 0; +const nextKey = () => `r${++_rk}_${Date.now()}`; + +const toNum = (v: any): number => { + if (v == null || v === "") return 0; + const n = Number(String(v).replace(/,/g, "")); + return Number.isFinite(n) ? n : 0; +}; +const fmt = (n: number) => n.toLocaleString("ko-KR"); + +interface UserOptionExt extends OptionItem { + name?: string; + position?: string; + phone?: string; + email?: string; +} + +export function PurchaseOrderOutsourcingFormDialog({ + open, onClose, onSaved, pomObjid, proposalObjid, +}: Props) { + const isEdit = !!pomObjid; + const [master, setMaster] = useState(EMPTY_MASTER); + const [parts, setParts] = useState([]); + const [deletedPartIds, setDeletedPartIds] = useState([]); + const [checkedRowKeys, setCheckedRowKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const userOptsForSelect: SmartSelectOption[] = useMemo( + () => userOpts.map((u) => ({ code: u.code, label: u.label })), + [userOpts], + ); + + const partnerName = useMemo(() => { + const v = vendorOpts.find((o) => o.code === master.partner_objid); + return v?.label ?? ""; + }, [vendorOpts, master.partner_objid]); + + useEffect(() => { + if (!open) return; + setMaster({ ...EMPTY_MASTER }); + setParts([]); + setDeletedPartIds([]); + setCheckedRowKeys([]); + + (async () => { + try { + const [vs, us] = await Promise.all([ + purchaseApi.listVendors(), + purchaseApi.listUsers(), + ]); + setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label }))); + setUserOpts(us as UserOptionExt[]); + } catch {/* skip */} + })(); + + setLoading(true); + (async () => { + try { + if (isEdit && pomObjid) { + const r = await purchaseApi.getOrderForm(pomObjid); + applyServerData(r.master ?? {}, r.parts ?? []); + } else if (proposalObjid) { + const r = await purchaseApi.initOrderForm(proposalObjid); + applyServerData(r.master ?? {}, r.parts ?? []); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패"); + } finally { + setLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isEdit, pomObjid, proposalObjid]); + + const applyServerData = (m: Record, ps: Record[]) => { + setMaster({ + objid: String(m.objid ?? ""), + purchase_order_no: String(m.purchase_order_no ?? ""), + purchase_date: String(m.purchase_date ?? ""), + partner_objid: String(m.partner_objid ?? ""), + sales_mng_user_id: String(m.sales_mng_user_id ?? ""), + manager_name: String(m.manager_name ?? ""), + manager_position: String(m.manager_position ?? ""), + manager_phone: String(m.manager_phone ?? ""), + manager_email: String(m.manager_email ?? ""), + sales_mng_user_id2: String(m.sales_mng_user_id2 ?? ""), + manager_name2: String(m.manager_name2 ?? ""), + manager_position2: String(m.manager_position2 ?? ""), + manager_phone2: String(m.manager_phone2 ?? ""), + manager_email2: String(m.manager_email2 ?? ""), + title: String(m.title ?? ""), + request_content: String(m.request_content ?? ""), + sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""), + contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""), + form_type: "outsourcing", + status: String(m.status ?? "create"), + appr_status: String(m.appr_status ?? ""), + }); + setParts(ps.map((p) => ({ + rowKey: nextKey(), + objid: String(p.objid ?? ""), + part_objid: String(p.part_objid ?? ""), + part_no: String(p.part_no ?? ""), + product_name: String(p.product_name ?? p.part_name ?? ""), + part_name: String(p.part_name ?? ""), + spec: String(p.spec ?? ""), + order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty), + qty: p.qty === "" || p.qty == null + ? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty)) + : Number(p.qty), + unit: String(p.unit || "0001400"), + partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price), + supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)), + work_order_no: String(p.work_order_no ?? ""), + delivery_request_date: String(p.delivery_request_date ?? ""), + _src: p._src, + }))); + }; + + const totalSupplyPrice = useMemo( + () => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0), + [parts], + ); + + const isReadOnly = useMemo(() => { + const a = master.appr_status; + return a === "결재중" || a === "결재완료" || master.status === "cancel"; + }, [master.appr_status, master.status]); + + const handleDownload = () => window.print(); + + const onManagerChange = (slot: 1 | 2, userId: string) => { + const u = userOpts.find((o) => o.code === userId); + setMaster((prev) => ({ + ...prev, + [`sales_mng_user_id${slot === 2 ? "2" : ""}`]: userId, + [`manager_name${slot === 2 ? "2" : ""}`]: u?.name ?? u?.label ?? "", + [`manager_position${slot === 2 ? "2" : ""}`]: u?.position ?? "", + [`manager_phone${slot === 2 ? "2" : ""}`]: u?.phone ?? "", + [`manager_email${slot === 2 ? "2" : ""}`]: u?.email ?? "", + })); + }; + + const updateRow = (rowKey: string, patch: Partial) => { + setParts((prev) => prev.map((r) => { + if (r.rowKey !== rowKey) return r; + const merged: PartRow = { ...r, ...patch }; + const q = toNum(merged.order_qty); + const u = toNum(merged.partner_price); + merged.qty = merged.order_qty; + merged.supply_unit_price = q * u; + return merged; + })); + }; + + const handleAddRow = () => { + setParts((prev) => [...prev, { + rowKey: nextKey(), + objid: "", part_objid: "", + part_no: "", product_name: "", part_name: "", spec: "", + order_qty: "", qty: "", + unit: "0001400", + partner_price: "", + supply_unit_price: 0, + work_order_no: "", delivery_request_date: "", + }]); + }; + + const handleDeleteSelectedRows = () => { + if (checkedRowKeys.length === 0) { + toast.info("삭제할 행을 선택하세요"); + return; + } + setParts((prev) => { + const remaining: PartRow[] = []; + const deletedObjids: string[] = []; + for (const r of prev) { + if (checkedRowKeys.includes(r.rowKey)) { + if (r.objid) deletedObjids.push(r.objid); + } else { + remaining.push(r); + } + } + if (deletedObjids.length > 0) { + setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids]))); + } + return remaining; + }); + setCheckedRowKeys([]); + }; + + const toggleRowCheck = (rowKey: string, checked: boolean) => { + setCheckedRowKeys((prev) => + checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey)); + }; + const toggleAllCheck = (checked: boolean) => { + setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []); + }; + + const handleSave = async () => { + if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; } + if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; } + setSaving(true); + try { + const payload = { + master: { + ...master, + form_type: "outsourcing", + total_supply_price: String(totalSupplyPrice), + total_supply_unit_price: String(totalSupplyPrice), + total_price: String(totalSupplyPrice), + }, + parts: parts.map((p) => ({ + objid: p.objid, + part_objid: p.part_objid, + part_no: p.part_no, + product_name: p.product_name, + part_name: p.part_name, + spec: p.spec, + order_qty: toNum(p.order_qty), + qty: toNum(p.qty || p.order_qty), + unit: p.unit, + partner_price: toNum(p.partner_price), + supply_unit_price: toNum(p.supply_unit_price), + work_order_no: p.work_order_no, + delivery_request_date: p.delivery_request_date, + })), + deletedPartObjids: deletedPartIds, + }; + const res = await purchaseApi.saveOrderForm(payload); + toast.success(`저장 완료 (${res.purchase_order_no})`); + onSaved?.(res); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + { if (!v) onClose(); }}> + + 외주가공 발주서 + wace 운영판 외주가공 발주서 PDF 양식 +
+
+
+
+ RPS Logo +
+
+
㈜ 알피에스
+
외주가공 발주서
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
1. 발 주 번 호 : + {master.purchase_order_no || 자동생성} +
2. 발 주 일 자 : +
+ setMaster({ ...master, purchase_date: v })} + disabled={isReadOnly} /> +
+
3. 수 신 업 체 : +
+ setMaster({ ...master, partner_objid: v })} + disabled={isReadOnly} /> +
+
4. 합 계 금 액(VAT별도) : + {fmt(totalSupplyPrice)} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ 담

자 +
+
+ onManagerChange(1, v)} + disabled={isReadOnly} /> +
+
+ ({master.manager_phone || "-"} / {master.manager_email || "-"}) + 직인 { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> +
+
+ onManagerChange(2, v)} + disabled={isReadOnly} /> +
+
+ ({master.manager_phone2 || "-"} / {master.manager_email2 || "-"}) +
+ ㈜알피에스 대표이사 이 동 헌 +
+ 대전광역시 유성구 국제과학10로8(둔곡동 402-4번지) +
+
+
+
+ + {isReadOnly && ( +
+ 결재 진행/완료 또는 취소 상태의 발주서는 수정할 수 없습니다. + {master.appr_status ? ` (결재상태: ${master.appr_status})` : ""} + {master.status === "cancel" ? " (취소됨)" : ""} +
+ )} + +
+ {!isReadOnly && ( + <> + + + + + )} + {isReadOnly && ( + + )} + +
+ +
+ + + + + + + + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((row, i) => ( + + + + + + + + + + + + + + + ))} + +
+ 0 && checkedRowKeys.length === parts.length} + onChange={(e) => toggleAllCheck(e.target.checked)} + disabled={isReadOnly} /> + No업체명제품명부품명수량단위단가합계작업지시번호부품품번입고요청일
+ 품의서에서 진입했다면 자동 채움 — 비어있으면 "행 추가" 로 시작 +
+ toggleRowCheck(row.rowKey, e.target.checked)} + disabled={isReadOnly} /> + {i + 1}{partnerName} + updateRow(row.rowKey, { product_name: e.target.value })} + disabled={isReadOnly} /> + {row.part_name} + updateRow(row.rowKey, { order_qty: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { unit: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { partner_price: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + {fmt(row.supply_unit_price)} + + updateRow(row.rowKey, { work_order_no: e.target.value })} + disabled={isReadOnly} /> + {row.part_no} + updateRow(row.rowKey, { delivery_request_date: v })} + size="sm" + disabled={isReadOnly} /> +
+
+ +
+ + + + + + + +
+ 총 공 급 가 액 (VAT별도) + + {fmt(totalSupplyPrice)} +
+
+ +
+ ※ 보안문서(CONFIDENTIAL) : ㈜알피에스의 승인(APPROVAL) 없이 외부로 반출하거나 공유 할수 없습니다. +
+
+
+
+ ); +} From 17b08c7a091e7d90ec89bf70875928f057525e2d Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 19 May 2026 14:57:47 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EC=84=9C=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20+=20PDF=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20+?= =?UTF-8?q?=20=ED=96=89=EC=B6=94=EA=B0=80/=EC=82=AD=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend purchaseOrderMailService 신설 — getOrderMailInfo / getPartnerManagerList / sendOrderMail (SMTP PURCHASE, 발송 성공 시 mail_send_yn='Y'/mail_send_date 갱신) - backend routes — GET /order-form/mail-info/:objid, POST /order-form/mail, GET /options/partner-managers/:partnerObjid - frontend lib/utils/purchaseOrderPdf — html2canvas-pro + jsPDF (A4, scale=2, input/textarea → 텍스트 변환). download:true 면 파일 저장, 아니면 base64 반환 - PurchaseOrderMailDialog 신설 — EstimateMailDialog 패턴 단순화 (한글/영문 본문 분기, 공급업체 단일 email 자동 채움) - 3개 양식 다이얼로그 — 읽기전용 + 저장된 발주서일 때 "메일 발송" + "PDF 다운로드" 버튼 노출. window.print 간이판 제거 - 3개 양식 다이얼로그 — "행 추가"/"선택 행 삭제" 버튼 + 그리드 체크박스 컬럼 제거 (wace 운영판은 모두 주석 처리/부재. 발주서는 품의서에서 자동 채움된 품목 그대로 사용) --- .../src/controllers/purchaseController.ts | 49 +++ backend-node/src/routes/purchaseRoutes.ts | 13 +- .../src/services/purchaseOrderMailService.ts | 158 +++++++++ .../PurchaseOrderEnglishFormDialog.tsx | 141 ++++---- .../PurchaseOrderGeneralFormDialog.tsx | 144 ++++----- .../purchase/PurchaseOrderMailDialog.tsx | 305 ++++++++++++++++++ .../PurchaseOrderOutsourcingFormDialog.tsx | 140 ++++---- frontend/lib/api/purchase.ts | 47 +++ frontend/lib/utils/purchaseOrderPdf.ts | 59 ++++ 9 files changed, 789 insertions(+), 267 deletions(-) create mode 100644 backend-node/src/services/purchaseOrderMailService.ts create mode 100644 frontend/components/purchase/PurchaseOrderMailDialog.tsx create mode 100644 frontend/lib/utils/purchaseOrderPdf.ts diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index 579f25a2..7cb6b9cc 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -6,6 +6,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/purchaseService"; import * as formSvc from "../services/purchaseOrderFormService"; +import * as mailSvc from "../services/purchaseOrderMailService"; import { logger } from "../utils/logger"; function parseFilter(q: Record): svc.PurchaseListFilter { @@ -107,6 +108,54 @@ export async function deletePurchaseOrderForm(req: AuthenticatedRequest, res: Re } } +// ─── 발주서 메일 발송 ───────────────────────────────────────── + +/** GET /api/purchase/order-form/mail-info/:objid */ +export async function getPurchaseOrderMailInfo(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 mailSvc.getOrderMailInfo(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 }); + } +} + +/** GET /api/purchase/options/partner-managers/:partnerObjid */ +export async function getPartnerManagers(req: AuthenticatedRequest, res: Response) { + try { + const partnerObjid = String(req.params.partnerObjid ?? "").trim(); + if (!partnerObjid) return res.json({ success: true, data: [] }); + const data = await mailSvc.getPartnerManagerList(partnerObjid); + 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/order-form/mail */ +export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Response) { + try { + const body = req.body as mailSvc.PurchaseOrderMailBody; + if (!body || !body.pomObjid) { + return res.status(400).json({ success: false, message: "pomObjid 가 필요해요" }); + } + const userId = String(req.user?.userId ?? ""); + const result = await mailSvc.sendOrderMail(userId, body); + if (!result.success) { + return res.status(400).json(result); + } + return res.json(result); + } 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 { const data = await svc.listSupplierOptions(); diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index fcfef1cb..fb9b6482 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -20,16 +20,19 @@ 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.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT -router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 -router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade +// 발주서 폼 (general / outsourcing / english 양식, wace purchaseOrderFormPopup_*.do 1:1) +router.get ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움 +router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT +router.post ("/order-form/mail", ctrl.sendPurchaseOrderMail); // 메일 발송 (PDF 첨부) +router.get ("/order-form/mail-info/:objid", ctrl.getPurchaseOrderMailInfo); // 메일 다이얼로그 자동 채움 +router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 +router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade // 공통 옵션 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); // 발주서 메일 담당자 export default router; diff --git a/backend-node/src/services/purchaseOrderMailService.ts b/backend-node/src/services/purchaseOrderMailService.ts new file mode 100644 index 00000000..34359d1d --- /dev/null +++ b/backend-node/src/services/purchaseOrderMailService.ts @@ -0,0 +1,158 @@ +// ============================================================ +// 구매관리 > 발주서관리 — 메일 발송 서비스 +// +// wace ContractMgmtService.sendEstimateMailCustom 패턴(영업관리) 재사용: +// - 본문(contents): 다이얼로그 입력 그대로 (HTML 변환은 textToHtml) +// - 첨부: 프론트가 html2canvas + jsPDF 로 만든 base64 PDF 1장 +// - SMTP 계정: PURCHASE (mailUtil SmtpAccountType) +// - mail_log title 에 [OBJID:nnn] 토큰 부착 — 그리드 LIKE 매칭 호환 +// - 발송 성공 시 purchase_order_master.mail_send_yn='Y', mail_send_date=NOW() +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { sendMailUTF8 } from "../utils/mailUtil"; + +export interface PurchaseOrderMailBody { + pomObjid: string; + toEmails: string; // ; 또는 , 로 구분 + ccEmails?: string; + subject: string; + contents: string; + /** 프론트 html2canvas + jsPDF base64 (data URL 또는 raw base64) */ + pdfBase64: string; +} + +/** 메일 다이얼로그 자동채움 — 공급업체/작성자/발주번호 */ +export async function getOrderMailInfo(pomObjid: string) { + const pool = getPool(); + const sql = ` + SELECT + POM.OBJID AS pom_objid, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + POM.PARTNER_OBJID AS partner_objid, + POM.MANAGER_EMAIL AS writer_email, + POM.MANAGER_NAME AS writer_name, + POM.FORM_TYPE AS form_type, + CM.CLIENT_NM AS partner_name, + CM.EMAIL AS partner_email + FROM PURCHASE_ORDER_MASTER POM + LEFT JOIN CLIENT_MNG CM + ON CM.OBJID = POM.PARTNER_OBJID + WHERE POM.OBJID = $1 + LIMIT 1`; + const r = await pool.query(sql, [pomObjid]); + return r.rows[0] ?? null; +} + +/** 공급업체 담당자 리스트 — RPS client_mng 는 단일 email 만 보관 (별도 contact 테이블 없음) */ +export async function getPartnerManagerList(partnerObjid: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT CLIENT_NM AS name, EMAIL AS email, TEL_NO AS phone, '' AS department, 'Y' AS is_main + FROM CLIENT_MNG + WHERE OBJID = $1 + AND COALESCE(EMAIL, '') <> ''`, + [partnerObjid], + ); + return r.rows; +} + +function decodeBase64Pdf(input: string): Buffer { + const m = /^data:application\/pdf;base64,(.*)$/i.exec(input); + const b64 = m ? m[1] : input; + return Buffer.from(b64, "base64"); +} + +function textToHtml(text: string): string { + return (text || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
"); +} + +export async function sendOrderMail(userId: string, body: PurchaseOrderMailBody) { + const info = await getOrderMailInfo(body.pomObjid); + if (!info) { + return { success: false, message: "발주서 정보를 찾을 수 없습니다." }; + } + + const splitEmails = (s: string | undefined) => (s || "") + .split(/[;,]/) + .map((e) => e.trim()) + .filter((e) => e !== ""); + + const toEmails = splitEmails(body.toEmails); + if (toEmails.length === 0) { + return { success: false, message: "수신인 이메일이 없습니다." }; + } + + const ccEmails = splitEmails(body.ccEmails); + // 작성자 이메일 자동 cc + if (info.writer_email && !ccEmails.includes(info.writer_email)) { + ccEmails.push(info.writer_email); + } + + if (!body.pdfBase64) { + return { success: false, message: "발주서 PDF 가 전달되지 않았습니다." }; + } + + const pdfBuf = decodeBase64Pdf(body.pdfBase64); + const safeNo = (info.purchase_order_no || "발주서").toString().replace(/[^\w가-힣\-_.]/g, "_"); + const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14); + const attachment = { + filename: `${safeNo}_${ts}.pdf`, + content: pdfBuf, + contentType: "application/pdf", + }; + + const subject = body.subject.trim(); + const subjectForLog = subject.includes("[OBJID:") + ? subject + : `${subject} [OBJID:${body.pomObjid}]`; + const html = textToHtml(body.contents); + + const result = await sendMailUTF8({ + accountType: "PURCHASE", + fromUserId: userId, + toEmails, + ccEmails: ccEmails.length > 0 ? ccEmails : undefined, + subject, + subjectForLog, + html, + attachments: [attachment], + mailType: "PURCHASE_ORDER", + }); + + logger.info("발주서 메일 발송 완료", { + pomObjid: body.pomObjid, + mailLogObjid: result.objid, + sent: result.sent, + to: toEmails, + cc: ccEmails, + }); + + if (!result.sent) { + return { + success: false, + message: result.error || "메일 발송에 실패했습니다.", + objid: result.objid, + }; + } + + // 발송 성공 시 mail_send_yn / mail_send_date 갱신 + try { + await getPool().query( + `UPDATE PURCHASE_ORDER_MASTER + SET MAIL_SEND_YN = 'Y', + MAIL_SEND_DATE = NOW() + WHERE OBJID = $1`, + [body.pomObjid], + ); + } catch (e: any) { + logger.warn("mail_send_yn 갱신 실패", { error: e.message, pomObjid: body.pomObjid }); + } + + return { success: true, message: "발주서가 성공적으로 발송되었습니다.", objid: result.objid }; +} diff --git a/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx index 0d0332b1..7928f9f1 100644 --- a/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx +++ b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx @@ -8,17 +8,19 @@ // - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery // - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65) -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, Trash2 } from "lucide-react"; +import { Download, Mail } from "lucide-react"; import { toast } from "sonner"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { DateInput } from "@/components/common/DateInput"; import { NumberInput } from "@/components/common/NumberInput"; import { purchaseApi, OptionItem } from "@/lib/api/purchase"; +import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf"; +import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog"; interface Props { open: boolean; @@ -106,8 +108,6 @@ export function PurchaseOrderEnglishFormDialog({ const isEdit = !!pomObjid; const [master, setMaster] = useState(EMPTY_MASTER); const [parts, setParts] = useState([]); - const [deletedPartIds, setDeletedPartIds] = useState([]); - const [checkedRowKeys, setCheckedRowKeys] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -118,8 +118,6 @@ export function PurchaseOrderEnglishFormDialog({ if (!open) return; setMaster({ ...EMPTY_MASTER }); setParts([]); - setDeletedPartIds([]); - setCheckedRowKeys([]); (async () => { try { @@ -204,7 +202,27 @@ export function PurchaseOrderEnglishFormDialog({ return a === "결재중" || a === "결재완료" || master.status === "cancel"; }, [master.appr_status, master.status]); - const handleDownload = () => window.print(); + const pdfContainerRef = useRef(null); + const [mailOpen, setMailOpen] = useState(false); + const [generatingPdf, setGeneratingPdf] = useState(false); + + const handleDownload = async () => { + if (!pdfContainerRef.current) return; + setGeneratingPdf(true); + try { + const filename = `${master.purchase_order_no || "purchase_order_english"}.pdf`; + await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename }); + } catch (e: any) { + toast.error("PDF generation failed: " + (e?.message ?? "")); + } finally { + setGeneratingPdf(false); + } + }; + + const handleRequestPdf = async (): Promise => { + if (!pdfContainerRef.current) throw new Error("Purchase order container not found"); + return generatePurchaseOrderPdf(pdfContainerRef.current); + }; const updateRow = (rowKey: string, patch: Partial) => { setParts((prev) => prev.map((r) => { @@ -218,51 +236,6 @@ export function PurchaseOrderEnglishFormDialog({ })); }; - const handleAddRow = () => { - setParts((prev) => [...prev, { - rowKey: nextKey(), - objid: "", part_objid: "", - part_no: "", part_name: "", spec: "", - order_qty: "", qty: "", - unit: "0001400", - currency: DEFAULT_CURRENCY, - partner_price: "", - supply_unit_price: 0, - delivery_request_date: "", - }]); - }; - - const handleDeleteSelectedRows = () => { - if (checkedRowKeys.length === 0) { - toast.info("Select rows to delete"); - return; - } - setParts((prev) => { - const remaining: PartRow[] = []; - const deletedObjids: string[] = []; - for (const r of prev) { - if (checkedRowKeys.includes(r.rowKey)) { - if (r.objid) deletedObjids.push(r.objid); - } else { - remaining.push(r); - } - } - if (deletedObjids.length > 0) { - setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids]))); - } - return remaining; - }); - setCheckedRowKeys([]); - }; - - const toggleRowCheck = (rowKey: string, checked: boolean) => { - setCheckedRowKeys((prev) => - checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey)); - }; - const toggleAllCheck = (checked: boolean) => { - setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []); - }; - const handleSave = async () => { if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; } if (parts.length === 0) { toast.warning("No items"); return; } @@ -290,7 +263,7 @@ export function PurchaseOrderEnglishFormDialog({ supply_unit_price: toNum(p.supply_unit_price), delivery_request_date: p.delivery_request_date, })), - deletedPartObjids: deletedPartIds, + deletedPartObjids: [], }; const res = await purchaseApi.saveOrderForm(payload); toast.success(`Saved (${res.purchase_order_no})`); @@ -308,7 +281,7 @@ export function PurchaseOrderEnglishFormDialog({ Purchase Order (English) wace English Purchase Order PDF form -
+
{/* 헤더 */}
@@ -420,29 +393,26 @@ export function PurchaseOrderEnglishFormDialog({
{!isReadOnly && ( - <> - - - - - )} - {isReadOnly && ( )} + {isReadOnly && master.objid && ( + <> + + + + )}