PR-D G11 견적 결재상신 — Amaranth 직행 (wace estimateList_new.jsp btnApproval 1:1)

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) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-11 18:16:43 +09:00
parent 905d5c0976
commit b17d7b063d
10 changed files with 397 additions and 11 deletions
@@ -177,3 +177,20 @@ export async function listTemplates(req: AuthenticatedRequest, res: Response) {
return res.status(500).json({ success: false, message: error.message }); 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 });
}
}
@@ -19,6 +19,9 @@ router.post("/template2", salesEstimateController.saveTemplate2);
router.get("/template/:templateObjid", salesEstimateController.getTemplate); router.get("/template/:templateObjid", salesEstimateController.getTemplate);
router.get("/templates/:contractObjid", salesEstimateController.listTemplates); 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.get("/:id", salesEstimateController.getById);
router.post("/", salesEstimateController.create); router.post("/", salesEstimateController.create);
router.put("/:id", salesEstimateController.update); router.put("/:id", salesEstimateController.update);
@@ -10,6 +10,8 @@ import { PDFDocument } from "pdf-lib";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { sendMailUTF8 } from "../utils/mailUtil"; import { sendMailUTF8 } from "../utils/mailUtil";
import { AppError } from "../middleware/errorHandler";
import * as amaranth from "./amaranthApprovalClient";
import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService"; import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService";
// ─── 타입 ───────────────────────────────────────────────────── // ─── 타입 ─────────────────────────────────────────────────────
@@ -162,9 +164,15 @@ export async function getList(filter: EstimateListFilter) {
// 견적관리는 통합 직발(IS_DIRECT_ORDER='Y') 제외 // 견적관리는 통합 직발(IS_DIRECT_ORDER='Y') 제외
conditions.push(`COALESCE(T.IS_DIRECT_ORDER, 'N') != 'Y'`); conditions.push(`COALESCE(T.IS_DIRECT_ORDER, 'N') != 'Y'`);
// 결재상태 필터 (한글 라벨 매칭) // 결재상태 필터 (한글 라벨 매칭) — SELECT 절 APPR_STATUS와 동일 CASE.
// AMR은 동일 LEFT JOIN이 WHERE 단계 전에 평가되므로 직접 참조 가능.
if (filter.appr_status) { if (filter.appr_status) {
conditions.push(`(CASE 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 '결재불필요' WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요'
ELSE '' ELSE ''
END) = $${idx++}`); END) = $${idx++}`);
@@ -248,15 +256,19 @@ export async function getList(filter: EstimateListFilter) {
/* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */ /* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */
,ML.mail_send_status AS MAIL_SEND_STATUS ,ML.mail_send_status AS MAIL_SEND_STATUS
,ML.mail_send_date AS MAIL_SEND_DATE ,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 ,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 '결재불필요' WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요'
ELSE '' ELSE ''
END AS APPR_STATUS END AS APPR_STATUS
,CASE ,COALESCE(AMR.status,
WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' CASE WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' ELSE '' END
ELSE '' ) AS AMARANTH_STATUS
END AS AMARANTH_STATUS
/* 추가견적/주문서 첨부 카운트 (attach_file_info DOC_TYPE 기반) */ /* 추가견적/주문서 첨부 카운트 (attach_file_info DOC_TYPE 기반) */
,COALESCE(AF.add_est_cnt, 0) AS ADD_EST_CNT ,COALESCE(AF.add_est_cnt, 0) AS ADD_EST_CNT
,COALESCE(AF.cu01_cnt, 0) AS CU01_CNT ,COALESCE(AF.cu01_cnt, 0) AS CU01_CNT
@@ -284,6 +296,10 @@ export async function getList(filter: EstimateListFilter) {
ORDER BY regdate DESC ORDER BY regdate DESC
LIMIT 1 LIMIT 1
) ET ON true ) 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 ( LEFT JOIN LATERAL (
SELECT COALESCE(SUM(NULLIF(replace(quantity, ',', ''), '')::numeric), 0) AS estimate_quantity 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 }; 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별 차수들) // 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들)
export async function listTemplatesByContract(contractObjid: string) { export async function listTemplatesByContract(contractObjid: string) {
const pool = getPool(); const pool = getPool();
+2
View File
@@ -69,7 +69,9 @@ services:
# 도메인별 fallback: AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER → AMARANTH_OUT_PROCESS_CODE # 도메인별 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: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001}
AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} 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_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} AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000}
volumes: volumes:
- backend_uploads:/app/uploads - backend_uploads:/app/uploads
+2
View File
@@ -38,7 +38,9 @@ services:
# Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 # 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: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001}
AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} 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_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} AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000}
volumes: volumes:
- backend_uploads:/app/uploads - backend_uploads:/app/uploads
@@ -42,7 +42,9 @@ services:
# Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 # 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=${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001}
- AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} - 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_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} - AMARANTH_COMP_SEQ=${AMARANTH_COMP_SEQ:-1000}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
+2 -1
View File
@@ -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` 체크 → 있으면 거부 | | 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) | | 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 메뉴 이식 시점에 별도로 다룸 | | ~~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 정합성 메모 ## 3. 코드/SQL 정합성 메모
@@ -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/<CONTRACT_OBJID>/amaranth-approval' \
-H 'Authorization: Bearer <TOKEN>' \
-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` 트러블슈팅 섹션).
**코드 변경 없음** — 운영 협조로 해결되는 영역.
@@ -11,7 +11,7 @@ import {
import { import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"; } 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 { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; 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)으로 발송. // 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송.
// PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가 // PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가
@@ -470,9 +497,9 @@ export default function SalesEstimatePage() {
<Button size="sm" variant="outline" onClick={openTemplateChoice} disabled={!selected}> <Button size="sm" variant="outline" onClick={openTemplateChoice} disabled={!selected}>
<Pencil className="w-4 h-4 mr-1" /> <Pencil className="w-4 h-4 mr-1" />
</Button> </Button>
<Button size="sm" variant="outline" disabled={!selected} <Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white"
onClick={() => toast.info("결재 모듈 연동 예정 — approval_required='N'은 자동 '결재불필요'로 표시됩니다.")}> onClick={handleAmaranthApproval} disabled={!selected}>
<Send className="w-4 h-4 mr-1" />
</Button> </Button>
<Button size="sm" variant="outline" disabled={!selected} <Button size="sm" variant="outline" disabled={!selected}
onClick={openMailDialog}> onClick={openMailDialog}>
+8
View File
@@ -192,6 +192,14 @@ export const salesEstimateApi = {
const res = await apiClient.get(`/sales/estimate/templates/${contractObjid}`); const res = await apiClient.get(`/sales/estimate/templates/${contractObjid}`);
return (res.data?.data ?? []) as EstimateTemplateRow[]; return (res.data?.data ?? []) as EstimateTemplateRow[];
}, },
// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급)
// wace estimateList_new.jsp:887 fn_openAmaranthApproval 1:1
async startApproval(objid: string, body: { approvalTitle?: string; subjectStr?: string } = {})
: Promise<{ fullUrl: string; approKey: string; status: string; estObjid: string }> {
const res = await apiClient.post(`/sales/estimate/${objid}/amaranth-approval`, body);
return res.data?.data;
},
}; };
// ─── G5 견적작성 타입 ─────────────────────────────────────────── // ─── G5 견적작성 타입 ───────────────────────────────────────────