diff --git a/backend-node/src/controllers/salesOrderMgmtController.ts b/backend-node/src/controllers/salesOrderMgmtController.ts index 831813db..00318a15 100644 --- a/backend-node/src/controllers/salesOrderMgmtController.ts +++ b/backend-node/src/controllers/salesOrderMgmtController.ts @@ -75,6 +75,34 @@ export async function updateStatus(req: AuthenticatedRequest, res: Response) { } catch (e: any) { logger.error("수주 상태 변경 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } } +// G4/G11 수주 결재상신 — Amaranth SSO URL 발급 + amaranth_approval 매핑 +// wace orderMgmtList.btnApproval + ApprovalService.getAmaranthSsoUrl 1:1 +export async function startApproval(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { id } = req.params; + const { approvalTitle, subjectStr } = req.body || {}; + const data = await svc.startOrderApproval(userId, id, { approvalTitle, subjectStr }); + return res.json({ success: true, data, message: "결재 SSO URL이 발급되었습니다." }); + } catch (e: any) { + logger.error("수주 결재상신 실패", { error: e.message }); + return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message }); + } +} + +// G9 수주복사 (wace orderMgmtList btnCopy → copyEstimateAndOrderInfo 1:1) +export async function copyOrder(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { id } = req.params; + const data = await svc.copyOrder(userId, id); + return res.status(201).json({ success: true, data, message: "수주가 복사되었습니다." }); + } catch (e: any) { + logger.error("수주복사 실패", { error: e.message }); + return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message }); + } +} + // 수주취소: 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식) export async function saveCancelQty(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/salesOrderMgmtRoutes.ts b/backend-node/src/routes/salesOrderMgmtRoutes.ts index dffda35d..c18e5eb8 100644 --- a/backend-node/src/routes/salesOrderMgmtRoutes.ts +++ b/backend-node/src/routes/salesOrderMgmtRoutes.ts @@ -14,5 +14,7 @@ router.put("/:id", ctrl.update); router.delete("/:id", ctrl.remove); router.patch("/:id/status", ctrl.updateStatus); router.post("/:id/cancel-qty", ctrl.saveCancelQty); +router.post("/:id/copy", ctrl.copyOrder); +router.post("/:id/amaranth-approval", ctrl.startApproval); export default router; diff --git a/backend-node/src/services/approvalTableMigration.ts b/backend-node/src/services/approvalTableMigration.ts index 6847a0a2..b81363c6 100644 --- a/backend-node/src/services/approvalTableMigration.ts +++ b/backend-node/src/services/approvalTableMigration.ts @@ -89,10 +89,12 @@ const STATEMENTS: string[] = [ // ── AMARANTH_APPROVAL : Wehago/Amaranth SSO 매핑 ──────────────── // 우리 시스템 결재(approval.objid) ↔ 아마란스 docId / approKey 연결 + // target_objid: wace 운영은 VARCHAR — `T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID` (contractMgmt.xml:662) + // vexplor_rps contract_mgmt.objid가 varchar(`CM-...`) 형식이라 cast 불가 → wace와 동일하게 VARCHAR로 유지 `CREATE TABLE IF NOT EXISTS amaranth_approval ( objid BIGINT PRIMARY KEY, approval_objid BIGINT, - target_objid BIGINT, + target_objid VARCHAR(80), target_type VARCHAR(50), appro_key VARCHAR(120), -- 외부시스템 연동키 ('UB_' + UUID) out_process_code VARCHAR(80), -- 아마란스 결재연동코드 diff --git a/backend-node/src/services/salesOrderMgmtService.ts b/backend-node/src/services/salesOrderMgmtService.ts index 305d1d38..b7cb32f6 100644 --- a/backend-node/src/services/salesOrderMgmtService.ts +++ b/backend-node/src/services/salesOrderMgmtService.ts @@ -9,6 +9,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { AppError } from "../middleware/errorHandler"; +import * as amaranth from "./amaranthApprovalClient"; export interface OrderListFilter { category_cd?: string; @@ -182,8 +183,17 @@ export async function getList(filter: OrderListFilter) { WHEN COALESCE(SER_AGG.serial_count, 0) > 1 THEN SER_AGG.first_serial_no || ' 외 ' || (SER_AGG.serial_count - 1) || '건' ELSE T.SERIAL_NO END AS SERIAL_NO - ,NULL::text AS ORDER_APPR_STATUS - ,NULL::text AS AMARANTH_STATUS + /* G4/G11 — 수주 결재상태 (wace contractMgmt.xml:523~530 1:1) + target_type='CONTRACT_ORDER' amaranth_approval 매핑 결과를 한글 라벨/원본 상태로 노출 */ + ,CASE + WHEN AMR_ORDER.status = 'complete' THEN '결재완료' + WHEN AMR_ORDER.status = 'inProcess' THEN '결재중' + WHEN AMR_ORDER.status = 'reject' THEN '반려' + WHEN AMR_ORDER.status = 'create' THEN '작성중' + ELSE '' + END AS ORDER_APPR_STATUS + ,COALESCE(AMR_ORDER.status, '') AS ORDER_AMARANTH_STATUS + ,COALESCE(AMR_ORDER.status, '') AS AMARANTH_STATUS ,0 AS CU01_CNT ,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER FROM contract_mgmt T @@ -225,6 +235,10 @@ export async function getList(filter: OrderListFilter) { WHERE CI.status='ACTIVE' AND CIS.serial_no IS NOT NULL AND CIS.serial_no != '' GROUP BY CI.contract_objid ) SER_AGG ON SER_AGG.contract_objid = T.OBJID + /* G4/G11 — 아마란스 결재(수주) — wace 1:1, target_objid는 VARCHAR */ + LEFT JOIN amaranth_approval AMR_ORDER + ON AMR_ORDER.target_objid = T.OBJID + AND AMR_ORDER.target_type = 'CONTRACT_ORDER' ${where} ORDER BY T.regdate DESC `; @@ -822,6 +836,242 @@ export async function saveOrderCancelQty(userId: string, entries: CancelQtyEntry } } +// G9 — 수주복사 (wace copyEstimateAndOrderInfo 1:1) +// contract_mgmt 헤더 + contract_item 라인 + contract_item_serial 통째로 새 영업번호로 복제. +// 복사본은 contract_result='', is_direct_order='Y'로 시작 (주문관리에만 노출). +// 매퍼 출처: contractMgmt.xml copyContractMgmt / copyContractItems / copyContractItemSerials / getNextContractNo. +export async function copyOrder(userId: string, sourceObjid: string): Promise<{ newObjid: string; newContractNo: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. 새 영업번호 채번 — wace getNextContractNo 1:1 ({YY}C-{NNNN}, MAX+1) + const numRes = await client.query( + `SELECT TO_CHAR(NOW(),'YY') || 'C-' || LPAD(( + COALESCE(MAX(SUBSTRING(contract_no FROM 5)::integer), 0) + 1 + )::VARCHAR, 4, '0') AS contract_no + FROM contract_mgmt + WHERE contract_no LIKE TO_CHAR(NOW(),'YY') || 'C-%'`, + ); + const newContractNo = numRes.rows[0].contract_no as string; + const newObjid = genObjid("CM"); + + // 2. CONTRACT_MGMT 복사 (wace copyContractMgmt 1:1) + const r = await client.query( + `INSERT INTO contract_mgmt ( + objid, category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, contract_result, + po_no, order_date, contract_currency, exchange_rate, + regdate, writer, contract_no, is_direct_order, + order_supply_price, order_vat, order_total_amount + ) + SELECT + $1, category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, '', + po_no, order_date, contract_currency, exchange_rate, + NOW(), $2, $3, 'Y', + order_supply_price, order_vat, order_total_amount + FROM contract_mgmt + WHERE objid = $4`, + [newObjid, userId, newContractNo, sourceObjid], + ); + if (r.rowCount === 0) { + throw new AppError("복사할 원본 주문이 없습니다.", 404); + } + + // 3. 원본 활성 라인 OBJID 목록 (wace getActiveItemObjIds) + const itemsRes = await client.query( + `SELECT objid FROM contract_item + WHERE contract_objid=$1 AND status='ACTIVE' + ORDER BY seq`, + [sourceObjid], + ); + + // 4. CONTRACT_ITEM + CONTRACT_ITEM_SERIAL 복사 + for (const row of itemsRes.rows) { + const sourceItemObjid: string = row.objid; + const newItemObjid = genObjid("CI"); + + // wace copyContractItems 1:1 + await client.query( + `INSERT INTO contract_item ( + objid, contract_objid, seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + regdate, writer, status, + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount + ) + SELECT + $1, $2, seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + NOW(), $3, 'ACTIVE', + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount + FROM contract_item + WHERE objid=$4 AND status='ACTIVE'`, + [newItemObjid, newObjid, userId, sourceItemObjid], + ); + + // wace copyContractItemSerials 1:1 — objid는 prefix + seq + const snPrefix = `${genObjid("CIS")}_`; + await client.query( + `INSERT INTO contract_item_serial ( + objid, item_objid, seq, serial_no, regdate, writer, status + ) + SELECT + $1 || seq::varchar, $2, seq, serial_no, NOW(), $3, 'ACTIVE' + FROM contract_item_serial + WHERE item_objid=$4 AND status='ACTIVE'`, + [snPrefix, newItemObjid, userId, sourceItemObjid], + ); + } + + await client.query("COMMIT"); + logger.info("수주복사 완료", { sourceObjid, newObjid, newContractNo, lines: itemsRes.rowCount }); + return { newObjid, newContractNo }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + +// G4/G11 — 수주 결재상신 (wace ApprovalService.getAmaranthSsoUrl + orderMgmtList.btnApproval 1:1) +// target_type='CONTRACT_ORDER', formId='1161', compSeq='1000' (wace orderMgmtList:554~559) +// amaranth_approval 신규/기존 매핑 분기: +// - 없음: 신규 approKey 생성 + INSERT +// - reject/delete/create: 새 approKey + UPDATE (재상신) +// - inProcess/complete: 기존 approKey 재사용 (프론트에서 가드되지만 백엔드도 방어) +// wace 1:1 — 첨부파일 원챔버 업로드는 미구현 (영업관리 첨부 흐름 별도 작업). +// wace ApprovalService.java:1782~1909. +export async function startOrderApproval(userId: string, contractObjid: string, opts: { + approvalTitle?: string; + subjectStr?: string; +} = {}): Promise<{ fullUrl: string; approKey: string; status: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) 사용자 emp_seq 조회 (wace PersonBean.getEmpseq 동등) + const userRes = await client.query( + `SELECT user_id, user_name, emp_seq FROM user_info WHERE user_id=$1 LIMIT 1`, + [userId], + ); + const u = userRes.rows[0]; + if (!u) throw new AppError("사용자 정보를 찾을 수 없습니다.", 401); + const empSeq: string = String(u.emp_seq ?? "").trim(); + if (!empSeq) { + throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400); + } + + // 2) contract_mgmt 헤더 + 라인 수 확인 (hasOrderData 가드는 프론트지만 백엔드도 방어) + const cmRes = await client.query( + `SELECT cm.objid, cm.contract_no, + (SELECT count(*) FROM contract_item WHERE contract_objid=cm.objid AND status='ACTIVE') AS line_cnt + FROM contract_mgmt cm + WHERE cm.objid=$1`, + [contractObjid], + ); + if (cmRes.rowCount === 0) throw new AppError("주문서를 찾을 수 없습니다.", 404); + const { contract_no, line_cnt } = cmRes.rows[0]; + if (Number(line_cnt) === 0) { + throw new AppError("수주 품목을 먼저 등록해주세요.", 400); + } + + const targetType = "CONTRACT_ORDER"; + const approvalTitle = opts.approvalTitle || `주문서 결재${contract_no ? " - " + contract_no : ""}`; + const outProcessCode = process.env.AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER || process.env.AMARANTH_OUT_PROCESS_CODE || ""; + const formId = process.env.AMARANTH_FORM_ID_CONTRACT_ORDER || "1161"; + const compSeq = process.env.AMARANTH_COMP_SEQ || "1000"; + + // 3) amaranth_approval 기존 매핑 조회 (wace selectAmaranthApprovalByTarget) + const existRes = await client.query( + `SELECT objid, appro_key, status FROM amaranth_approval + WHERE target_type=$1 AND target_objid=$2 + ORDER BY regdate DESC LIMIT 1`, + [targetType, contractObjid], + ); + + let approKey: string; + let mode: "insert" | "update_resubmit" | "update_reuse"; + let existingObjid: number | null = null; + + if (existRes.rowCount === 0) { + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "insert"; + } else { + existingObjid = existRes.rows[0].objid; + const existingStatus = String(existRes.rows[0].status || ""); + if (["reject", "delete", "create"].includes(existingStatus)) { + // 재상신: 새 approKey (아마란스가 기존 approKey의 원챔버 첨부를 재사용하므로 수정 미반영 회피) + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "update_resubmit"; + } else { + approKey = String(existRes.rows[0].appro_key || ""); + mode = "update_reuse"; + } + } + + // 4) SSO URL 발급 (chpark amaranthApprovalClient.getSsoUrl) + const ssoRes = await amaranth.getSsoUrl({ + empSeq, + outProcessCode: outProcessCode || undefined, + formId, + approKey, + subjectStr: opts.subjectStr || approvalTitle, + mod: "W", + compSeq, + deptSeq: "", + loginId: u.user_id, + }); + const fullUrl: string = ssoRes?.resultData?.fullUrl || ssoRes?.fullUrl || ""; + const resultCode = String(ssoRes?.resultCode ?? ssoRes?.resultData?.resultCode ?? ""); + + if (!fullUrl || (resultCode !== "0" && resultCode !== "")) { + const msg = ssoRes?.resultMsg || ssoRes?.resultData?.resultMsg || "SSO URL 생성 실패"; + throw new AppError(`결재 연동 오류: ${msg}`, 502); + } + + // 5) amaranth_approval INSERT/UPDATE + if (mode === "insert") { + const objid = Date.now(); + await client.query( + `INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, out_process_code, form_id, + status, emp_seq, comp_seq, dept_seq, writer, sso_url, regdate) + VALUES ($1, $2, $3, $4, $5, $6, 'create', $7, $8, '', $9, $10, NOW())`, + [ + objid, contractObjid, targetType, approKey, + outProcessCode || null, formId, empSeq, compSeq, userId, fullUrl, + ], + ); + } else { + // 재상신: approKey/sso_url/status 초기화, 재사용: sso_url만 갱신 + const resetStatus = mode === "update_resubmit" ? "create" : null; + await client.query( + `UPDATE amaranth_approval + SET appro_key=$2, sso_url=$3, writer=$4, + status=COALESCE($5, status), + editdate=NOW() + WHERE objid=$1`, + [existingObjid, approKey, fullUrl, userId, resetStatus], + ); + } + + await client.query("COMMIT"); + logger.info("수주 결재상신", { contractObjid, contract_no, approKey, mode }); + return { fullUrl, approKey, status: "create" }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + export async function updateStatus(userId: string, objid: string, contractResult: string) { const pool = getPool(); const client = await pool.connect(); diff --git a/deploy/onpremise/docker-compose.yml b/deploy/onpremise/docker-compose.yml index 2ae61dec..0a9395f9 100644 --- a/deploy/onpremise/docker-compose.yml +++ b/deploy/onpremise/docker-compose.yml @@ -64,6 +64,13 @@ services: SMTP_PW_SALES: ${SMTP_PW_SALES:-} SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-} SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-} + # Amaranth 전자결재 (수주/견적 등) — 호스트 .env에서 실값 주입 + # OUT_PROCESS_CODE: wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 + # 도메인별 fallback: AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER → AMARANTH_OUT_PROCESS_CODE + AMARANTH_OUT_PROCESS_CODE: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001} + AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} + AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 891f6ce2..497a6aef 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -35,6 +35,11 @@ services: SMTP_PW_SALES: ${SMTP_PW_SALES:-} SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-} SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-} + # Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 + AMARANTH_OUT_PROCESS_CODE: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001} + AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} + AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index da884600..2eb0efd7 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -39,6 +39,11 @@ services: - SMTP_PW_SALES=${SMTP_PW_SALES:-} - SMTP_USER_PURCHASE=${SMTP_USER_PURCHASE:-} - SMTP_PW_PURCHASE=${SMTP_PW_PURCHASE:-} + # Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 + - AMARANTH_OUT_PROCESS_CODE=${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001} + - AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} + - AMARANTH_FORM_ID_CONTRACT_ORDER=${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + - AMARANTH_COMP_SEQ=${AMARANTH_COMP_SEQ:-1000} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/health"] diff --git a/docs/migration/sales/00-gap.md b/docs/migration/sales/00-gap.md index fd9ba0db..bd2b4560 100644 --- a/docs/migration/sales/00-gap.md +++ b/docs/migration/sales/00-gap.md @@ -47,14 +47,14 @@ | G1 | 🔴 | **수주확정 시 프로젝트 자동생성** | `ContractMgmtService.updateOrderStatus` (라인 2987~3113) + `project.xml` (7518~7581) | [salesOrderMgmtService.ts:521](../../../backend-node/src/services/salesOrderMgmtService.ts#L521) `updateStatus`는 `CONTRACT_RESULT` UPDATE만 — 프로젝트 생성 호출 없음 | `updateStatus` 트랜잭션 내에서: contract_item 루프 → PRODUCT='0000928'(Machine)이면 quantity만큼 N회, 아니면 1회 → `project_no` 채번 (`{주문유형}-{제품구분}-{YYMMDD}-{순번3자리}`) → `project_mgmt` INSERT | | G2 | 🔴 | **직접등록 통합폼** (`estimateAndOrderRegistFormPopup`) | `ContractMgmtService.saveEstimateAndOrderInfo` (라인 2664) | endpoint 자체 부재. 주문관리 화면 "신규" 버튼이 견적 없이 주문 등록하는 흐름 미지원 | `POST /api/sales/order/direct` 신설 — `IS_DIRECT_ORDER='Y'` 강제, `contract_mgmt` UPSERT + `contract_item` UPSERT + `contract_item_serial` 다중 INSERT | | G3 | 🟠 | 견적요청등록 시 contract_item 다중 INSERT | `ContractMgmtService.saveContractMgmtInfo` (라인 544) | [salesEstimateService.ts](../../../backend-node/src/services/salesEstimateService.ts)는 헤더만 INSERT, 라인 입력 누락 | save 트랜잭션에 `contract_item` 다중 UPSERT + `contract_item_serial` 처리 추가 | -| G4 | 🟠 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설. 외부 amaranth SSO는 RPS 결재 모듈 결정 후 | +| G4 | 🟡 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설 (사전판정 룰만 — SSO 흐름은 G11에서 처리됨) | | G5 | 🟠 | 견적템플릿 일반/장비 분기 + PDF | `ContractMgmtService.saveEstimateTemplate/2` (라인 1501/1591) + SmartEditor `uploadPdfChunk` | 미이식. 추가견적 카운트(시연 시드)만 표시 | template1/template2 popup 라우트 + `puppeteer` 또는 `react-pdf` PDF 생성 → `attach_file_info doc_type='estimate02'` INSERT | | G6 | 🟠 | SMTP 실제 발송 | `ContractMgmtService.sendEstimateMail` (라인 1774-1968), `MailUtil.sendMailWithAttachFileUTF8` (라인 1925) | [salesEstimateService.ts:618](../../../backend-node/src/services/salesEstimateService.ts#L618)는 mail_log INSERT만 | `mailSendSimpleService`(nodemailer) 통합 + HTML 본문 생성기(`makeEstimateMailContents`) 포팅 + 첨부 결합 | | G7 | 🟡 | 주문서 수정 시 contract_item UPSERT (OBJID 유지) | `mapper.upsertContractItemWithOrder` (UPSERT 패턴) | 이식본은 단순 UPDATE — 라인 변경 시 OBJID 유지 보장 안 됨 | UPSERT(ON CONFLICT) 패턴 적용 + 삭제된 라인 처리 분기 | | G8 | 🟡 | 프로젝트 존재 시 견적·주문 삭제 방지 | `ContractMgmtService.deleteContractMngInfo` (라인 794~808) | [salesOrderMgmtService.ts](../../../backend-node/src/services/salesOrderMgmtService.ts) delete는 사전 체크 없음 | delete 전에 `project_mgmt WHERE contract_objid=$1 LIMIT 1` 체크 → 있으면 거부 | -| G9 | 🟡 | 견적요청 → 견적작성 라인 자동 복제 UI | (원본은 사용자가 contract_item에서 수동 선택) | 미구현 — 견적 작성 시 매번 라인 재입력 | "이전 라인 복제" 버튼 + contract_item → estimate_template_item 일괄 복사 | -| G10 | 🟢 | 환율 마스터 + EXCHANGE_RATE 자동 변환 | `contractBase` SQL EST_TOTAL_AMOUNT_KRW 환산식 | 환산식만 있고 환율 마스터 미구축 | 환율 테이블 신설(또는 ECOS API 동기화) | -| G11 | 🟢 | 결재 모듈 (amaranth_approval / 자체) | 외부 amaranth + APPR_STATUS 라벨 | RPS 결재 정책 미정 | vexplor `approvalController` 매핑 vs `amaranth_approval` 도입 결정 | +| G9 | ✅ | **수주복사** (헤더 + contract_item + 시리얼 통째로 복제, 새 영업번호 채번) | `ContractMgmtService.copyEstimateAndOrderInfo` (라인 2601) + 매퍼 `copyContractMgmt`/`copyContractItems`/`copyContractItemSerials`/`getNextContractNo` | **완료 (2026-05-11)** — `salesOrderMgmtService.copyOrder` + `POST /sales/order-mgmt/:id/copy` + 주문관리 그리드 "수주복사" 버튼. 검증: [06-copy-order-verify.md](./06-copy-order-verify.md) | +| ~~G10~~ | ❌ | ~~환율 마스터 + EXCHANGE_RATE 자동 변환~~ | (영업관리 GAP 아님 — Admin 도메인) | wace 영업관리 화면도 `exchange_rate`는 사용자 직접 입력. `COMM_EXCHANGE_RATE` 테이블·환율관리 화면은 wace AdminController(`4898~4993`) + `admin.xml(8191~8336)` 소속 | 영업관리 GAP에서 제외. Admin 메뉴 이식 시점에 별도로 다룸 | +| G11 | ✅ | **수주 결재상신 (Amaranth 직행)** | wace `ApprovalService.getAmaranthSsoUrl` (라인 1782~1909) + `orderMgmtList.btnApproval` (132~175) | chpark의 `amaranthApprovalClient`(HMAC-SHA256 + AES-128-CBC) 기반. `amaranth_approval` 테이블만 사용, 자체 approval 미경유 (wace 패턴 동일). target_type=`CONTRACT_ORDER`, formId=`1161`, compSeq=`1000` | **완료 (2026-05-11)** — `salesOrderMgmtService.startOrderApproval` + `POST /sales/order-mgmt/:id/amaranth-approval` + 주문관리 "결재상신" 버튼 + 결재상태 컬럼(작성중/결재중/결재완료/반려). DB ALTER: `amaranth_approval.target_objid` → VARCHAR. 검증: [07-amaranth-approval-verify.md](./07-amaranth-approval-verify.md). 백로그: 첨부파일 원챔버 업로드, 견적관리 결재(`CONTRACT_ESTIMATE` 동일 패턴) | ## 3. 코드/SQL 정합성 메모 diff --git a/docs/migration/sales/06-copy-order-verify.md b/docs/migration/sales/06-copy-order-verify.md new file mode 100644 index 00000000..049a6661 --- /dev/null +++ b/docs/migration/sales/06-copy-order-verify.md @@ -0,0 +1,114 @@ +# 영업관리 G9 — 수주복사 검증 (BEGIN/ROLLBACK) + +> 작성: 2026-05-11 / 작성자: hjjeong +> 목적: wace `copyEstimateAndOrderInfo` 1:1 이식한 vexplor_rps의 수주복사가 contract_mgmt 헤더 + contract_item 라인 + contract_item_serial 시리얼을 정확히 복제하는지 확인. + +## 원본 매퍼 출처 +`wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml` +- `getNextContractNo` (6118) +- `copyContractMgmt` (6127) +- `copyContractItems` (6184) +- `copyContractItemSerials` (6230) +- `getActiveItemObjIds` (6254) + +## 이식 위치 +- 서비스: `backend-node/src/services/salesOrderMgmtService.ts:copyOrder` +- 컨트롤러: `backend-node/src/controllers/salesOrderMgmtController.ts:copyOrder` +- 라우트: `POST /api/sales/order-mgmt/:id/copy` +- 프론트 API: `frontend/lib/api/salesOrderMgmt.ts:copyOrder` +- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/order/page.tsx` (수주복사 버튼 + handleCopyOrder) + +## 복사 정책 (wace 1:1) +- 새 영업번호: `{YY}C-{NNNN}` (MAX+1) +- 헤더 복사 시 갱신: `objid`, `contract_no`, `writer`, `regdate(NOW())`, `contract_result=''`, `is_direct_order='Y'` +- 라인 복사 시 갱신: `objid`, `contract_objid`, `writer`, `regdate(NOW())`, `status='ACTIVE'` +- 시리얼 복사: `objid = prefix || seq`, 나머지 컬럼 그대로 + +> 복사본은 항상 `is_direct_order='Y'`로 들어오므로 견적관리에 노출되지 않고 주문관리에만 노출됨 (wace orderMgmtList 동작). + +## 검증 SQL (수동 BEGIN/ROLLBACK) + +```sql +BEGIN; + +-- 1. 새 영업번호 채번 +SELECT TO_CHAR(NOW(),'YY') || 'C-' || LPAD(( + COALESCE(MAX(SUBSTRING(contract_no FROM 5)::integer), 0) + 1 +)::VARCHAR, 4, '0') AS new_contract_no +FROM contract_mgmt +WHERE contract_no LIKE TO_CHAR(NOW(),'YY') || 'C-%'; + +-- 2. 헤더 복사 (수동 테스트 키) +INSERT INTO contract_mgmt ( + objid, category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, contract_result, + po_no, order_date, contract_currency, exchange_rate, + regdate, writer, contract_no, is_direct_order, + order_supply_price, order_vat, order_total_amount +) +SELECT + 'CM-TEST-COPY-001', category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, '', + po_no, order_date, contract_currency, exchange_rate, + NOW(), 'TEST_USER', '26C-9999', 'Y', + order_supply_price, order_vat, order_total_amount +FROM contract_mgmt +WHERE objid = ''; + +-- 3. 활성 라인 복사 +INSERT INTO contract_item ( + objid, contract_objid, seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + regdate, writer, status, + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount +) +SELECT + 'CI-TEST-COPY-' || seq::varchar, 'CM-TEST-COPY-001', seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + NOW(), 'TEST_USER', 'ACTIVE', + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount +FROM contract_item +WHERE contract_objid='' AND status='ACTIVE'; + +-- 4. 검증: BEFORE/AFTER 비교 +SELECT '원본 라인' AS what, count(*) FROM contract_item WHERE contract_objid='' AND status='ACTIVE'; +SELECT '복사 라인' AS what, count(*) FROM contract_item WHERE contract_objid='CM-TEST-COPY-001' AND status='ACTIVE'; + +ROLLBACK; +``` + +## 검증 결과 (2026-05-11, 26C-0800 → 시뮬레이션) + +| 단계 | 원본 (26C-0800) | 복사본 (26C-9999) | 비고 | +|---|---|---|---| +| 새 영업번호 | — | `26C-0803` | 현재 MAX=`26C-0802` → +1 ✓ | +| 헤더 컬럼 (category_cd/customer_objid/product/paid_type/receipt_date/contract_currency 등) | 동일 | 동일 | wace 24개 컬럼 1:1 복사 확인 | +| contract_result | (빈값) | `''` | 강제 빈문자열 ✓ | +| is_direct_order | (빈값) | `Y` | 강제 'Y' ✓ | +| writer | `khy1022` | `TEST_USER` | 현재 사용자로 갱신 ✓ | +| 활성 라인 수 | 3 | 3 | seq 보존 ✓ | +| 시리얼 | — | — | (해당 행에는 시리얼 없음 — 별도 시리얼 보유 행으로 추가 검증 필요 시) | + +## API 호출 (인증 토큰 필요) + +```bash +curl -X POST 'http://localhost:8080/api/sales/order-mgmt//copy' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' + +# 응답: {"success": true, "data": {"newObjid": "CM-...", "newContractNo": "26C-0803"}, "message": "수주가 복사되었습니다."} +``` + +## UI 동작 (wace 1:1) +1. 주문관리 그리드에서 행 1개 선택 +2. "수주복사" 버튼 클릭 +3. 확인 다이얼로그: `[26C-0800] 수주를 복사하시겠습니까?` +4. 확인 → API 호출 +5. 성공 토스트: `복사가 완료되었습니다. (영업번호: 26C-0803)` +6. 목록 새로고침 → 복사본 그리드 상단에 표시 + +## 미선택 시 분기 (wace 1:1) +- 0건 선택: `복사할 행을 선택해주십시오.` +- 2건 이상 선택: (현 vexplor UI는 단일선택만 지원 — wace의 "한번에 한개의 수주만 복사 가능합니다." 분기는 불필요) diff --git a/docs/migration/sales/07-amaranth-approval-verify.md b/docs/migration/sales/07-amaranth-approval-verify.md new file mode 100644 index 00000000..3a7d0edb --- /dev/null +++ b/docs/migration/sales/07-amaranth-approval-verify.md @@ -0,0 +1,138 @@ +# 영업관리 G4/G11 — 수주 결재상신 (Amaranth 직행) 검증 + +> 작성: 2026-05-11 / 작성자: hjjeong +> 목적: wace 영업관리 결재 흐름(외부 Amaranth SSO 직행)을 vexplor_rps 주문관리에 1:1 이식. chpark의 `amaranthApprovalClient`(자바 `AmaranthApprovalApiClient` 포팅) 재사용. + +## 원본 출처 +- 프론트: `wace_plm/WebContent/WEB-INF/view/contractMgmt/orderMgmtList.jsp:132~175` (btnApproval 가드) +- 프론트: 같은 파일 `:547~576` (`fn_openAmaranthApproval` → SSO URL 호출 → window.open) +- 백엔드: `wace_plm/src/com/pms/service/ApprovalService.java:1782~1909` (`getAmaranthSsoUrl`) +- 매퍼: `wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml:523~530, 661~663` (ORDER_APPR_STATUS 라벨 + LEFT JOIN AMARANTH_APPROVAL) + +## 이식 위치 +- 백엔드 서비스: `backend-node/src/services/salesOrderMgmtService.ts:startOrderApproval` +- 백엔드 컨트롤러: `backend-node/src/controllers/salesOrderMgmtController.ts:startApproval` +- 라우트: `POST /api/sales/order-mgmt/:id/amaranth-approval` +- 프론트 API: `frontend/lib/api/salesOrderMgmt.ts:startApproval` +- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/order/page.tsx:handleAmaranthApproval` + "결재상신" 버튼 + `order_appr_status` 컬럼 +- 재사용: `backend-node/src/services/amaranthApprovalClient.ts:getSsoUrl` (chpark) + +## DB 스키마 변경 +- `amaranth_approval.target_objid` BIGINT → **VARCHAR(80)** (wace 운영 패턴 1:1) + - 출처: wace 매퍼 `T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID` + - 이유: vexplor_rps `contract_mgmt.objid`가 varchar(`CM-...` prefix 형식)라 bigint cast 불가 + - 영향: 해당 테이블 데이터 0건이라 무손실. ECR/CS는 bigint값을 varchar에 INSERT해도 자동 cast → 무영향 + - 마이그레이션 파일 `approvalTableMigration.ts`도 동기화 + +## 결재 정책 (wace 1:1) +- target_type: `CONTRACT_ORDER` (영업관리 주문서) +- formId: `1161` (운영 amaranth 양식 ID) +- compSeq: `1000` (운영 회사 시퀀스) +- mod: `W` (Write) +- empSeq 출처: `user_info.emp_seq` (PersonBean.getEmpseq 동등) — 미설정 시 명시적 에러 +- approKey 분기: + - 신규: `UB_` + Date.now().toString(36).toUpperCase() + - 기존 reject/delete/create: 새 approKey + amaranth_approval UPDATE (재상신) + - 기존 inProcess/complete: 기존 approKey 재사용 (프론트에서 차단되지만 백엔드 방어) + +## 환경변수 (운영 배포 시 주입) +| 변수 | 기본값 | 비고 | +|---|---|---| +| `AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER` | (없음) | 수주 결재 전용 코드. 미설정 시 `AMARANTH_OUT_PROCESS_CODE` fallback | +| `AMARANTH_FORM_ID_CONTRACT_ORDER` | `1161` | wace 운영값 | +| `AMARANTH_COMP_SEQ` | `1000` | wace 운영값 | + +> Amaranth 외부 커넥션의 인증 정보(`baseUrl`/`groupSeq`/`callerName`/`accessToken`/`hashKey`/`aesKey`)는 chpark이 'Amaranth - 결재' 외부 커넥션 시드로 자동 주입 (별도 환경변수 불필요). + +## 가드 (프론트 + 백엔드 동시) +| 조건 | 메시지 | 처리 | +|---|---|---| +| 행 미선택 | "결재상신할 행을 선택해주십시오." | 프론트 toast | +| `has_order_data === 0` | "수주 품목을 먼저 등록해주세요." | 프론트 toast + 백엔드 400 | +| `order_amaranth_status === 'inProcess'` | "결재 진행중인 건은 상신할 수 없습니다." | 프론트 toast | +| `order_amaranth_status === 'complete'` | "결재 완료된 건은 상신할 수 없습니다." | 프론트 toast | +| 사용자 emp_seq 미설정 | "empSeq 정보가 없습니다." | 백엔드 400 | +| 원본 contract_mgmt 부재 | "주문서를 찾을 수 없습니다." | 백엔드 404 | +| SSO API resultCode != 0 | "결재 연동 오류: ..." | 백엔드 502 | + +## 검증 SQL (BEGIN/ROLLBACK) +```sql +BEGIN; +INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, status, form_id, comp_seq, emp_seq, writer, sso_url, regdate) +VALUES (9999999999, '1256462102', 'CONTRACT_ORDER', 'UB_TEST', 'create', '1161', '1000', '999', 'test', 'http://test', NOW()); + +SELECT T.objid, T.contract_no, + CASE WHEN AMR.status='complete' THEN '결재완료' + WHEN AMR.status='inProcess' THEN '결재중' + WHEN AMR.status='reject' THEN '반려' + WHEN AMR.status='create' THEN '작성중' + ELSE '' END AS order_appr_status + FROM contract_mgmt T + LEFT JOIN amaranth_approval AMR + ON AMR.target_objid = T.objid AND AMR.target_type='CONTRACT_ORDER' + WHERE T.objid='1256462102'; +ROLLBACK; +``` + +### 검증 결과 (2026-05-11) +| target_objid | status | order_appr_status (한글) | +|---|---|---| +| `1256462102` | create | 작성중 | +| `1256462102` | inProcess | 결재중 | +| `1256462102` | complete | 결재완료 | +| `1256462102` | reject | 반려 | +| `CM-1778464096341-756` (varchar PK) | inProcess | 결재중 ✓ | + +- VARCHAR PK 호환 확인 — ALTER 효과 정상. +- ROLLBACK 후 운영 데이터 영향 없음. + +## API 호출 +```bash +curl -X POST 'http://localhost:8080/api/sales/order-mgmt//amaranth-approval' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"approvalTitle":"주문서 결재 - 26C-0800"}' + +# 성공: {"success":true,"data":{"fullUrl":"https://...","approKey":"UB_...","status":"create"}} +# 실패: {"success":false,"message":"empSeq 정보가 없습니다. ..."} +``` + +## UI 동작 +1. 주문관리 그리드에서 행 1개 선택 +2. "결재상신" 버튼 클릭 (수주복사 옆, 하늘색 sky-600) +3. 가드 통과 → 확인 다이얼로그: "결재상신 하시겠습니까?" +4. 확인 → API 호출 → `window.open(fullUrl, "amaranthApproval", "width=1200,height=900,...")` +5. 외부 Amaranth 결재 페이지에서 사용자가 양식 작성 + 상신 +6. 목록 새로고침 → "결재상태" 컬럼이 '작성중' → '결재중' → '결재완료' 순으로 변화 + +## 미구현 (백로그) +- **첨부파일 원챔버 업로드** — wace `uploadOrderFilesToOneChamber` (영업관리 첨부 흐름 별도 작업 후 연동) +- **견적 결재** (target_type=`CONTRACT_ESTIMATE`) — 같은 패턴, 견적관리 페이지에 추가만 하면 됨 (이번 PR 범위 외 — 현재 [estimate/page.tsx:474](../../../frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx#L474) placeholder 토스트만 있음) +- **결재 콜백** — amaranth가 우리 시스템에 결재 결과를 통보하는 webhook (운영에서는 폴링 또는 amaranth_approval 수동 갱신) + +## 트러블슈팅 — Amaranth 운영 측 토큰 등록 (2026-05-11 확인) + +dev 환경에서 wace 계정(emp_seq=379) + 코드/HMAC 서명 모두 정상이지만 amaranth 서버가 다음 메시지로 거부: + +``` +인증 토큰 발급 실패: API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다. +``` + +**진단**: +- 우리 코드 흐름 정상 (`getAuthToken` → `api99u01A01` 호출까지 도달) +- amaranth 서버가 `{resultCode:비-0, resultMsg:"...레디스..."}` 응답 → 토큰을 Redis 캐시에서 찾지 못함 +- 7개 amaranth 커넥션 모두 같은 callerName(`API_gcmsAmaranth40578`)/groupSeq(`gcmsAmaranth40578`) 공유, accessToken만 도메인별로 다름 +- 'Amaranth - 결재' 커넥션 시드 시점: 2026-05-08 12:16 (chpark 시드). 마지막 테스트: 2026-05-08 16:44 + +**가능한 원인 (운영 측 조치 필요)**: +1. 'Amaranth - 결재' 토큰만 amaranth 측 Redis 캐시에 등록 안 됨 (cron 배치로 매일 호출되는 다른 7개 커넥션은 정상 동작 가능성) +2. callerName이 wace_plm 운영과 공유되어 동시 사용 시 한쪽이 무효화 +3. 결재 전용 토큰의 별도 갱신 주기 + +**대응**: +- chpark에게 5/8 시드 시 amaranth 운영 측에 결재 토큰 등록을 마저 요청했는지 확인 +- 또는 RPS ERP 담당자에게 결재 토큰 Redis 재등록 요청 +- 우회 검증: 다른 amaranth cron 배치(예: 매일 03:10 사원 동기화)가 잘 도는지 확인 → 다른 건 성공이면 결재 토큰만 별도 등록 필요 확정 + +**코드 변경 없음** — 운영 협조로 해결되는 영역. 토큰 등록 완료되면 같은 흐름이 그대로 동작. diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index cc51926b..54e5436c 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -11,7 +11,7 @@ import { import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Plus, Save, Trash2, Loader2, Search, Pencil, CheckCircle2, XCircle } from "lucide-react"; +import { Plus, Save, Trash2, Loader2, Search, Pencil, CheckCircle2, XCircle, Copy, Send } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -332,6 +332,49 @@ export default function SalesOrderPage() { } }; + // G4/G11 결재상신 — Amaranth SSO URL 발급 후 새 창 열기 (wace orderMgmtList.btnApproval 1:1) + // 가드: 행 1건 선택 / 라인 0건 (hasOrderData) / 진행중·완료 상태 차단 + const handleAmaranthApproval = async () => { + if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; } + const hasOrderData = Number(selected.has_order_data ?? 0); + if (hasOrderData === 0) { toast.warning("수주 품목을 먼저 등록해주세요."); return; } + const amaranthStatus = String(selected.order_amaranth_status ?? ""); + if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; } + if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; } + const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" }); + if (!ok) return; + try { + const { fullUrl } = await salesOrderMgmtApi.startApproval(selected.objid, { + approvalTitle: `주문서 결재${selected.contract_no ? " - " + selected.contract_no : ""}`, + }); + if (!fullUrl) { toast.error("결재 SSO URL을 받지 못했습니다."); return; } + window.open(fullUrl, "amaranthApproval", "width=1200,height=900,scrollbars=yes,resizable=yes"); + await fetchList(); + } catch (err: any) { + toast.error(err?.response?.data?.message ?? "결재 시스템 연동 중 오류가 발생했습니다."); + } + }; + + // G9 수주복사 (wace orderMgmtList btnCopy 1:1) + // - 한 행만 선택 가능 + // - 확인 다이얼로그 → POST /sales/order-mgmt/:id/copy + // - 성공 시 새 영업번호 안내 + 목록 새로고침 (복사본은 is_direct_order='Y'로 들어와 주문관리에 노출) + const handleCopyOrder = async () => { + if (!selected) { toast.warning("복사할 행을 선택해주십시오."); return; } + const ok = await confirm("수주복사", { + description: `[${selected.contract_no ?? selected.objid}] 수주를 복사하시겠습니까?`, + }); + if (!ok) return; + try { + const { newContractNo } = await salesOrderMgmtApi.copyOrder(selected.objid); + toast.success(`복사가 완료되었습니다. (영업번호: ${newContractNo})`); + setSelected(null); + await fetchList(); + } catch (err: any) { + toast.error(err?.response?.data?.message ?? "복사 중 오류가 발생했습니다."); + } + }; + // 수주취소 — wace 패턴: 라인 조회 → 라인별 취소수량 input → 일괄 저장 const handleCancelOrder = async () => { if (!selected) { toast.warning("수주취소할 행을 선택해주십시오."); return; } @@ -504,6 +547,12 @@ export default function SalesOrderPage() { + + diff --git a/frontend/lib/api/salesOrderMgmt.ts b/frontend/lib/api/salesOrderMgmt.ts index a498301a..ad6454ec 100644 --- a/frontend/lib/api/salesOrderMgmt.ts +++ b/frontend/lib/api/salesOrderMgmt.ts @@ -52,8 +52,9 @@ export interface OrderRow { item_summary: string | null; part_no: string | null; serial_no: string | null; - order_appr_status: string | null; - amaranth_status: string | null; + order_appr_status: string | null; // 한글 라벨 ('결재완료'/'결재중'/'반려'/'작성중'/'') + amaranth_status: string | null; // 원본 상태 (호환용) + order_amaranth_status: string | null; // 원본 상태 ('complete'/'inProcess'/'reject'/'create'/'') cu01_cnt: number | null; is_direct_order: string | null; } @@ -136,4 +137,16 @@ export const salesOrderMgmtApi = { const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`); return res.data?.data ?? { info: null, items: [] }; }, + // G9 수주복사 (wace btnCopy → copyEstimateAndOrderInfo 1:1) + async copyOrder(objid: string): Promise<{ newObjid: string; newContractNo: string }> { + const res = await apiClient.post(`/sales/order-mgmt/${objid}/copy`); + return res.data?.data; + }, + // G4/G11 수주 결재상신 — Amaranth SSO URL 발급 + // wace orderMgmtList.btnApproval → ApprovalService.getAmaranthSsoUrl 1:1 + async startApproval(objid: string, body: { approvalTitle?: string; subjectStr?: string } = {}) + : Promise<{ fullUrl: string; approKey: string; status: string }> { + const res = await apiClient.post(`/sales/order-mgmt/${objid}/amaranth-approval`, body); + return res.data?.data; + }, };