From b17d7b063dfab31651fbd8709224137ddb20f1bc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Mon, 11 May 2026 18:16:43 +0900 Subject: [PATCH] =?UTF-8?q?PR-D=20G11=20=EA=B2=AC=EC=A0=81=20=EA=B2=B0?= =?UTF-8?q?=EC=9E=AC=EC=83=81=EC=8B=A0=20=E2=80=94=20Amaranth=20=EC=A7=81?= =?UTF-8?q?=ED=96=89=20(wace=20estimateList=5Fnew.jsp=20btnApproval=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G11 수주 결재상신(905d5c09)과 동일 패턴을 견적관리에 확장. target_type='CONTRACT_ESTIMATE', target_objid=estimate_template.objid(최신 차수), formId='1162' (수주 1161과 별도 양식). - 백엔드: salesEstimateService.startEstimateApproval + POST /sales/estimate/:id/amaranth-approval - 견적 list SQL: LEFT JOIN amaranth_approval(CONTRACT_ESTIMATE) + APPR_STATUS 4단계 한글 라벨 + approval_required='N' fallback (wace contractMgmt.xml:513~522 1:1) - 프론트: 견적관리 placeholder 토스트 → handleAmaranthApproval 핸들러 + sky-600 Send 버튼 (수주 페이지와 통일) - docker-compose 3개: AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE + AMARANTH_FORM_ID_CONTRACT_ESTIMATE=1162 추가 - 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess+complete / notRequired+approval_required='N' - 사전판정(checkApprovalRequired)은 G4 영역으로 분리 — 이번 PR은 단순 SSO 흐름만 검증: BEGIN/ROLLBACK으로 26C-0712(est_objid=-452406811) 4단계 상태(create→inProcess→complete→reject) + amaranth row 삭제 시 approval_required='N' fallback 모두 한글 라벨 정상. 문서 08-estimate-approval-verify.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/salesEstimateController.ts | 17 ++ .../src/routes/salesEstimateRoutes.ts | 3 + .../src/services/salesEstimateService.ts | 174 +++++++++++++++++- deploy/onpremise/docker-compose.yml | 2 + docker/deploy/docker-compose.yml | 2 + docker/prod/docker-compose.backend.prod.yml | 2 + docs/migration/sales/00-gap.md | 3 +- .../sales/08-estimate-approval-verify.md | 162 ++++++++++++++++ .../(main)/COMPANY_16/sales/estimate/page.tsx | 35 +++- frontend/lib/api/salesEstimate.ts | 8 + 10 files changed, 397 insertions(+), 11 deletions(-) create mode 100644 docs/migration/sales/08-estimate-approval-verify.md diff --git a/backend-node/src/controllers/salesEstimateController.ts b/backend-node/src/controllers/salesEstimateController.ts index a28df17f..33d9b1a4 100644 --- a/backend-node/src/controllers/salesEstimateController.ts +++ b/backend-node/src/controllers/salesEstimateController.ts @@ -177,3 +177,20 @@ export async function listTemplates(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } + +// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급 + amaranth_approval 매핑) +// wace estimateList_new.jsp:887 fn_openAmaranthApproval + 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 salesEstimateService.startEstimateApproval(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 }); + } +} diff --git a/backend-node/src/routes/salesEstimateRoutes.ts b/backend-node/src/routes/salesEstimateRoutes.ts index 5a69e52b..276704dc 100644 --- a/backend-node/src/routes/salesEstimateRoutes.ts +++ b/backend-node/src/routes/salesEstimateRoutes.ts @@ -19,6 +19,9 @@ router.post("/template2", salesEstimateController.saveTemplate2); router.get("/template/:templateObjid", salesEstimateController.getTemplate); router.get("/templates/:contractObjid", salesEstimateController.listTemplates); +// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급) — /:id 라우트보다 위에 +router.post("/:id/amaranth-approval", salesEstimateController.startApproval); + router.get("/:id", salesEstimateController.getById); router.post("/", salesEstimateController.create); router.put("/:id", salesEstimateController.update); diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index 0d1376a8..4a6ea99b 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -10,6 +10,8 @@ import { PDFDocument } from "pdf-lib"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { sendMailUTF8 } from "../utils/mailUtil"; +import { AppError } from "../middleware/errorHandler"; +import * as amaranth from "./amaranthApprovalClient"; import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService"; // ─── 타입 ───────────────────────────────────────────────────── @@ -162,9 +164,15 @@ export async function getList(filter: EstimateListFilter) { // 견적관리는 통합 직발(IS_DIRECT_ORDER='Y') 제외 conditions.push(`COALESCE(T.IS_DIRECT_ORDER, 'N') != 'Y'`); - // 결재상태 필터 (한글 라벨 매칭) + // 결재상태 필터 (한글 라벨 매칭) — SELECT 절 APPR_STATUS와 동일 CASE. + // AMR은 동일 LEFT JOIN이 WHERE 단계 전에 평가되므로 직접 참조 가능. if (filter.appr_status) { conditions.push(`(CASE + WHEN AMR.status = 'complete' THEN '결재완료' + WHEN AMR.status = 'inProcess' THEN '결재중' + WHEN AMR.status = 'reject' THEN '반려' + WHEN AMR.status = 'create' THEN '작성중' + WHEN AMR.status = 'notRequired' THEN '결재불필요' WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요' ELSE '' END) = $${idx++}`); @@ -248,15 +256,19 @@ export async function getList(filter: EstimateListFilter) { /* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */ ,ML.mail_send_status AS MAIL_SEND_STATUS ,ML.mail_send_date AS MAIL_SEND_DATE - /* 결재상태 (vexplor_rps에 amaranth_approval 미도입 — approval_required='N'만 우선 처리) */ + /* 결재상태 (wace contractMgmt.xml:513~522 1:1 — CONTRACT_ESTIMATE 매핑) */ ,CASE + WHEN AMR.status = 'complete' THEN '결재완료' + WHEN AMR.status = 'inProcess' THEN '결재중' + WHEN AMR.status = 'reject' THEN '반려' + WHEN AMR.status = 'create' THEN '작성중' + WHEN AMR.status = 'notRequired' THEN '결재불필요' WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요' ELSE '' END AS APPR_STATUS - ,CASE - WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' - ELSE '' - END AS AMARANTH_STATUS + ,COALESCE(AMR.status, + CASE WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' ELSE '' END + ) AS AMARANTH_STATUS /* 추가견적/주문서 첨부 카운트 (attach_file_info DOC_TYPE 기반) */ ,COALESCE(AF.add_est_cnt, 0) AS ADD_EST_CNT ,COALESCE(AF.cu01_cnt, 0) AS CU01_CNT @@ -284,6 +296,10 @@ export async function getList(filter: EstimateListFilter) { ORDER BY regdate DESC LIMIT 1 ) ET ON true + /* 아마란스 결재 — 견적 (target_objid = 최신 차수 estimate_template.objid) */ + LEFT JOIN amaranth_approval AMR + ON AMR.target_objid = ET.objid::VARCHAR + AND AMR.target_type = 'CONTRACT_ESTIMATE' /* 견적 라인 수량 합계 */ LEFT JOIN LATERAL ( SELECT COALESCE(SUM(NULLIF(replace(quantity, ',', ''), '')::numeric), 0) AS estimate_quantity @@ -1170,6 +1186,152 @@ export async function getTemplateById(templateObjid: string) { return { ...header, items: itemsRes.rows }; } +// ─── 결재상신 ──────────────────────────────────────────────── +// +// G4/G11 동일 패턴 — 견적관리 결재상신 (Amaranth SSO). +// wace estimateList_new.jsp:887 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl +// target_type = 'CONTRACT_ESTIMATE' +// target_objid = 최신 차수 estimate_template.objid (수주는 contract_mgmt.objid와 달리 차수 단위) +// formId = '1162' (수주는 '1161') +// compSeq = '1000' +// amaranth_approval 신규/기존 매핑 분기: +// - 없음: 신규 approKey 생성 + INSERT +// - reject/delete/create: 새 approKey + UPDATE (재상신) +// - inProcess/complete: 기존 approKey 재사용 (프론트에서 가드되지만 백엔드도 방어) +// 첨부파일 원챔버 업로드(wace uploadEstimateFilesToOneChamber)는 미구현 — 영업 첨부 흐름 정리 후 연동. +export async function startEstimateApproval( + userId: string, + contractObjid: string, + opts: { approvalTitle?: string; subjectStr?: string } = {}, +): Promise<{ fullUrl: string; approKey: string; status: string; estObjid: 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 헤더 + 최신 차수 estimate_template 조회 + // (wace estimateList_new.jsp:169 — estObjId 없으면 "견적서를 먼저 작성해주세요" 가드) + const cmRes = await client.query( + `SELECT cm.objid, cm.contract_no, + (SELECT objid FROM estimate_template + WHERE contract_objid = cm.objid + ORDER BY regdate DESC LIMIT 1) AS est_objid + FROM contract_mgmt cm + WHERE cm.objid = $1`, + [contractObjid], + ); + if (cmRes.rowCount === 0) throw new AppError("견적을 찾을 수 없습니다.", 404); + const { contract_no, est_objid } = cmRes.rows[0]; + if (!est_objid) { + throw new AppError("견적서를 먼저 작성해주세요.", 400); + } + const targetObjid = String(est_objid); + + const targetType = "CONTRACT_ESTIMATE"; + const approvalTitle = opts.approvalTitle || `견적서 결재${contract_no ? " - " + contract_no : ""}`; + const outProcessCode = + process.env.AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE || + process.env.AMARANTH_OUT_PROCESS_CODE || ""; + const formId = process.env.AMARANTH_FORM_ID_CONTRACT_ESTIMATE || "1162"; + 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, targetObjid], + ); + + 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, targetObjid, 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, estObjid: targetObjid, approKey, mode }); + return { fullUrl, approKey, status: "create", estObjid: targetObjid }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + // 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들) export async function listTemplatesByContract(contractObjid: string) { const pool = getPool(); diff --git a/deploy/onpremise/docker-compose.yml b/deploy/onpremise/docker-compose.yml index 0a9395f9..df209f85 100644 --- a/deploy/onpremise/docker-compose.yml +++ b/deploy/onpremise/docker-compose.yml @@ -69,7 +69,9 @@ services: # 도메인별 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_OUT_PROCESS_CODE_CONTRACT_ESTIMATE: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-} AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + AMARANTH_FORM_ID_CONTRACT_ESTIMATE: ${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162} AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000} volumes: - backend_uploads:/app/uploads diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 497a6aef..4e47ddc1 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -38,7 +38,9 @@ services: # 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_OUT_PROCESS_CODE_CONTRACT_ESTIMATE: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-} AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + AMARANTH_FORM_ID_CONTRACT_ESTIMATE: ${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162} AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000} volumes: - backend_uploads:/app/uploads diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 2eb0efd7..aabfae5b 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -42,7 +42,9 @@ services: # 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_OUT_PROCESS_CODE_CONTRACT_ESTIMATE=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-} - AMARANTH_FORM_ID_CONTRACT_ORDER=${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + - AMARANTH_FORM_ID_CONTRACT_ESTIMATE=${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162} - AMARANTH_COMP_SEQ=${AMARANTH_COMP_SEQ:-1000} restart: unless-stopped healthcheck: diff --git a/docs/migration/sales/00-gap.md b/docs/migration/sales/00-gap.md index bd2b4560..39f647bd 100644 --- a/docs/migration/sales/00-gap.md +++ b/docs/migration/sales/00-gap.md @@ -54,7 +54,8 @@ | G8 | 🟡 | 프로젝트 존재 시 견적·주문 삭제 방지 | `ContractMgmtService.deleteContractMngInfo` (라인 794~808) | [salesOrderMgmtService.ts](../../../backend-node/src/services/salesOrderMgmtService.ts) delete는 사전 체크 없음 | delete 전에 `project_mgmt WHERE contract_objid=$1 LIMIT 1` 체크 → 있으면 거부 | | 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` 동일 패턴) | +| 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). 백로그: 첨부파일 원챔버 업로드 | +| G11E | ✅ | **견적 결재상신 (Amaranth 직행, G11 동일 패턴)** | wace `estimateList_new.jsp:154~270, 868~916` + `ApprovalService.getAmaranthSsoUrl` CONTRACT_ESTIMATE 분기 | G11과 동일 흐름. 차이점: target_type=`CONTRACT_ESTIMATE`, target_objid=`estimate_template.objid`(최신 차수), formId=`1162`. 사전판정(`checkApprovalRequired`)은 G4 영역으로 분리 | **완료 (2026-05-11)** — `salesEstimateService.startEstimateApproval` + `POST /sales/estimate/:id/amaranth-approval` + 견적관리 "결재상신" 버튼 + 견적 list SQL `LEFT JOIN amaranth_approval` + 결재상태 4단계 라벨 + `notRequired`/`approval_required='N'` fallback. 검증: [08-estimate-approval-verify.md](./08-estimate-approval-verify.md). 백로그: 첨부파일 원챔버 업로드, 사전판정(G4) | ## 3. 코드/SQL 정합성 메모 diff --git a/docs/migration/sales/08-estimate-approval-verify.md b/docs/migration/sales/08-estimate-approval-verify.md new file mode 100644 index 00000000..3bdbffcb --- /dev/null +++ b/docs/migration/sales/08-estimate-approval-verify.md @@ -0,0 +1,162 @@ +# 영업관리 G4/G11 — 견적 결재상신 (Amaranth 직행) 검증 + +> 작성: 2026-05-11 / 작성자: hjjeong +> 목적: wace 영업관리 견적 결재 흐름(외부 Amaranth SSO 직행)을 vexplor_rps 견적관리에 1:1 이식. G11 수주 결재상신과 동일 패턴, `target_type='CONTRACT_ESTIMATE'` + `target_objid=estimate_template.objid` 차이. + +## 원본 출처 +- 프론트: `wace_plm/WebContent/WEB-INF/view/contractMgmt/estimateList_new.jsp:154~270` (btnApproval 가드 + checkApprovalRequired 사전판정) +- 프론트: 같은 파일 `:868~916` (`fn_showApprovalConfirmSimple` + `fn_openAmaranthApproval` → SSO URL 호출 → window.open) +- 백엔드: `wace_plm/src/com/pms/service/ApprovalService.java:1782~1909` (`getAmaranthSsoUrl` — CONTRACT_ESTIMATE 분기는 1853~1854) +- 매퍼: `wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml:513~522, 656~659` (APPR_STATUS 라벨 + LEFT JOIN AMARANTH_APPROVAL CONTRACT_ESTIMATE) + +## G11(수주) 대비 차이점 + +| 항목 | 수주 (G11) | 견적 (이번 작업) | +|---|---|---| +| target_type | `CONTRACT_ORDER` | `CONTRACT_ESTIMATE` | +| target_objid | `contract_mgmt.objid` (헤더 1개) | `estimate_template.objid` (최신 차수) | +| formId | `1161` | `1162` | +| 가드 | `has_order_data === 0` | `est_objid` 없음(견적서 미작성) | +| 사전판정 | 없음 | wace는 `checkApprovalRequired` 분기 — **G4 영역**이라 이번 작업에서 제외 | + +견적은 차수마다 별도 결재. 같은 영업번호의 차수1 결재 후 차수2 만들면 차수2는 다시 신규 amaranth_approval 매핑. + +## 이식 위치 +- 백엔드 서비스: `backend-node/src/services/salesEstimateService.ts:startEstimateApproval` +- 백엔드 컨트롤러: `backend-node/src/controllers/salesEstimateController.ts:startApproval` +- 라우트: `POST /api/sales/estimate/:id/amaranth-approval` +- 프론트 API: `frontend/lib/api/salesEstimate.ts:startApproval` +- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx:handleAmaranthApproval` + "결재상신" 버튼 +- 견적 list SQL 보강: 같은 파일 `salesEstimateService.getList` — `LEFT JOIN amaranth_approval AMR ON ET.objid::VARCHAR=AMR.target_objid AND AMR.target_type='CONTRACT_ESTIMATE'` + APPR_STATUS/AMARANTH_STATUS CASE 분기 +- 재사용: `backend-node/src/services/amaranthApprovalClient.ts:getSsoUrl` (chpark, G11에서 도입) + +## 결재 정책 (wace 1:1) +- target_type: `CONTRACT_ESTIMATE` +- formId: `1162` (운영 amaranth 견적 양식 ID — 수주 1161과 별도) +- compSeq: `1000` +- mod: `W` +- empSeq 출처: `user_info.emp_seq` +- approKey 분기 (G11 동일): + - 신규: `UB_` + Date.now().toString(36).toUpperCase() + - 기존 reject/delete/create: 새 approKey + amaranth_approval UPDATE (재상신) + - 기존 inProcess/complete: 기존 approKey 재사용 (프론트에서 차단되지만 백엔드 방어) + +## 환경변수 (운영 배포 시 주입) +| 변수 | 기본값 | 비고 | +|---|---|---| +| `AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE` | (없음) | 견적 결재 전용 코드. 미설정 시 `AMARANTH_OUT_PROCESS_CODE` fallback | +| `AMARANTH_FORM_ID_CONTRACT_ESTIMATE` | `1162` | wace 운영값 | +| `AMARANTH_COMP_SEQ` | `1000` | wace 운영값 (수주와 공유) | + +3개 docker-compose(`deploy/onpremise`, `docker/deploy`, `docker/prod/docker-compose.backend.prod.yml`) 모두 `${VAR:-default}` 형식으로 매핑됨 — 호스트 .env에 아무것도 안 넣어도 wace 운영값 그대로 동작. + +## 가드 (프론트 + 백엔드 동시) +| 조건 | 메시지 | 처리 | +|---|---|---| +| 행 미선택 | "결재상신할 행을 선택해주십시오." | 프론트 toast | +| `est_objid` 없음 | "견적서를 먼저 작성해주세요." | 프론트 toast + 백엔드 400 | +| `amaranth_status === 'inProcess'` | "결재 진행중인 건은 상신할 수 없습니다." | 프론트 toast | +| `amaranth_status === 'complete'` | "결재 완료된 건은 상신할 수 없습니다." | 프론트 toast | +| `amaranth_status === 'notRequired'` 또는 `approval_required === 'N'` | "결재불필요로 처리된 건입니다." | 프론트 toast | +| 사용자 emp_seq 미설정 | "empSeq 정보가 없습니다." | 백엔드 400 | +| 원본 contract_mgmt 부재 | "견적을 찾을 수 없습니다." | 백엔드 404 | +| SSO API resultCode != 0 | "결재 연동 오류: ..." | 백엔드 502 | + +## 검증 SQL (BEGIN/ROLLBACK) + +```sql +BEGIN; + +-- 가짜 amaranth_approval 매핑 INSERT (CONTRACT_ESTIMATE, target_objid = est_objid) +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 + (9999999998, '-452406811', 'CONTRACT_ESTIMATE', 'UB_TEST_EST', 'RPSPLM_00001', '1162', + 'create', '999', '1000', '', 'test', 'http://test', NOW()); + +-- 4단계 상태 라벨 확인 (UPDATE를 반복) +SELECT CASE + WHEN AMR.status = 'complete' THEN '결재완료' + WHEN AMR.status = 'inProcess' THEN '결재중' + WHEN AMR.status = 'reject' THEN '반려' + WHEN AMR.status = 'create' THEN '작성중' + WHEN AMR.status = 'notRequired' THEN '결재불필요' + WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요' + ELSE '' END AS appr_status, AMR.status, T.contract_no + FROM contract_mgmt T + LEFT JOIN LATERAL ( + SELECT objid FROM estimate_template + WHERE contract_objid = T.objid + ORDER BY regdate DESC LIMIT 1 + ) ET ON true + LEFT JOIN amaranth_approval AMR + ON AMR.target_objid = ET.objid::VARCHAR + AND AMR.target_type = 'CONTRACT_ESTIMATE' + WHERE T.objid = '-912974684'; + +UPDATE amaranth_approval SET status='inProcess' WHERE objid=9999999998; +-- 위 SELECT 재실행 → '결재중' +UPDATE amaranth_approval SET status='complete' WHERE objid=9999999998; +-- → '결재완료' +UPDATE amaranth_approval SET status='reject' WHERE objid=9999999998; +-- → '반려' +DELETE FROM amaranth_approval WHERE objid=9999999998; +-- → '결재불필요' (approval_required='N' fallback) + +ROLLBACK; +``` + +### 검증 결과 (2026-05-11) + +샘플 contract: `26C-0712` (contract_objid=`-912974684`, est_objid=`-452406811`, approval_required='N') + +| AMR.status | appr_status (한글) | +|---|---| +| `create` | 작성중 | +| `inProcess` | 결재중 | +| `complete` | 결재완료 | +| `reject` | 반려 | +| (AMR row 삭제, approval_required='N') | 결재불필요 | + +- AMR row가 있으면 status 우선, 없으면 approval_required fallback — wace 1:1. +- ROLLBACK 후 운영 데이터 영향 없음. +- 견적 list SQL의 LEFT JOIN AMR 동작 확인 — 차수가 늘어나도 최신 차수만 매핑. + +## API 호출 + +```bash +curl -X POST 'http://localhost:8080/api/sales/estimate//amaranth-approval' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"approvalTitle":"견적서 결재 - 26C-0712"}' + +# 성공: {"success":true,"data":{"fullUrl":"https://...","approKey":"UB_...","status":"create","estObjid":"-452406811"}} +# 실패: {"success":false,"message":"견적서를 먼저 작성해주세요."} +``` + +## UI 동작 + +1. 견적관리 그리드에서 행 1개 선택 +2. "결재상신" 버튼 클릭 (메일발송 옆, sky-600) +3. 가드 통과 → 확인 다이얼로그: "결재상신 하시겠습니까?" +4. 확인 → API 호출 → `window.open(fullUrl, "amaranthApproval", "width=1200,height=900,...")` +5. 외부 Amaranth 결재 페이지에서 사용자가 양식 작성 + 상신 +6. 목록 새로고침 → "결재상태" 컬럼이 '작성중' → '결재중' → '결재완료' 순으로 변화 + +## 미구현 (백로그) +- **첨부파일 원챔버 업로드** — wace `uploadEstimateFilesToOneChamber` (영업관리 첨부 흐름 별도 작업 후 연동) +- **사전판정 (`checkApprovalRequired`)** — G4 영역. wace는 결재상신 클릭 → 재오더/신규수주/가격인하 룰 판정 → 재오더면 "결재불필요" 자동 처리, 신규수주/가격인하면 사유 안내 후 결재상신. 이번 작업은 G11 동일 흐름(단순 SSO)만. +- **결재 콜백** — amaranth가 우리 시스템에 결재 결과를 통보하는 webhook (운영에서는 폴링 또는 amaranth_approval 수동 갱신) + +## 운영 트러블슈팅 (G11과 공유) + +dev에서 wace 계정으로 결재상신 클릭 시 amaranth가 다음 메시지로 거부 가능: + +``` +인증 토큰 발급 실패: API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다. +``` + +원인은 'Amaranth - 결재' 커넥션 accessToken이 amaranth 측 Redis 캐시에 등록 안 된 상태(7개 amaranth 커넥션 중 결재 토큰만 별도). G11 작업 시 동일 현상 확인됨. 대응 = chpark 또는 RPS ERP 담당자에게 결재 토큰 Redis 등록 요청 (자세한 진단: `07-amaranth-approval-verify.md` 트러블슈팅 섹션). + +**코드 변경 없음** — 운영 협조로 해결되는 영역. diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 070d4c67..45a4caed 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/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 } from "lucide-react"; +import { Plus, Save, Trash2, Loader2, Search, Pencil, Send } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -353,6 +353,33 @@ export default function SalesEstimatePage() { } }; + // ─── 결재상신 (G4/G11 동일 패턴 — 견적, target_type='CONTRACT_ESTIMATE') ───── + // wace estimateList_new.jsp:155 btnApproval + :887 fn_openAmaranthApproval 1:1. + // 단순 SSO 흐름만 — 사전판정(checkApprovalRequired) 분기는 G4 영역으로 분리. + // 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess·complete / 결재불필요 + const handleAmaranthApproval = async () => { + if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; } + if (!selected.est_objid) { toast.warning("견적서를 먼저 작성해주세요."); return; } + const amaranthStatus = String(selected.amaranth_status ?? ""); + if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; } + if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; } + if (amaranthStatus === "notRequired" || selected.approval_required === "N") { + toast.warning("결재불필요로 처리된 건입니다."); return; + } + const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" }); + if (!ok) return; + try { + const { fullUrl } = await salesEstimateApi.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 ?? "결재 시스템 연동 중 오류가 발생했습니다."); + } + }; + // ─── 메일 발송 ────────────────────────────────────────────── // 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송. // PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가 @@ -470,9 +497,9 @@ export default function SalesEstimatePage() { -