PR-D G9 수주복사 + G11 Amaranth 수주 결재상신 (wace 1:1)
[G9 수주복사 — wace copyEstimateAndOrderInfo 1:1]
- salesOrderMgmtService.copyOrder: 새 영업번호({YY}C-{NNNN}) 채번 +
contract_mgmt 23컬럼 INSERT-SELECT(contract_result='', is_direct_order='Y' 강제) +
contract_item + contract_item_serial 통째 복제
- POST /api/sales/order-mgmt/:id/copy 라우트
- 주문관리 그리드 "수주복사" 버튼 (Copy 아이콘) + handleCopyOrder
[G11 Amaranth 수주 결재상신 — wace ApprovalService.getAmaranthSsoUrl 1:1]
- chpark의 amaranthApprovalClient(HMAC-SHA256 + AES-128-CBC) 재사용
- amaranth_approval만 사용(자체 approval 미경유, wace 운영 패턴 동일)
- target_type='CONTRACT_ORDER', formId='1161', compSeq='1000'
- approKey 분기: 신규 / reject·delete·create는 새 approKey UPDATE / 그 외 재사용
- salesOrderMgmtService.startOrderApproval: user_info.emp_seq 조회 →
라인 0건 가드 → 매핑 분기 → SSO URL 발급 → INSERT/UPDATE → fullUrl 반환
- POST /api/sales/order-mgmt/:id/amaranth-approval 라우트
- 주문관리 그리드 "결재상신" 버튼 (Send 아이콘, sky-600) + handleAmaranthApproval
- getList SQL에 LEFT JOIN amaranth_approval AMR_ORDER 추가 +
order_appr_status(작성중/결재중/결재완료/반려 한글) + order_amaranth_status 노출
[DB 스키마]
- amaranth_approval.target_objid BIGINT → VARCHAR(80) (wace 운영 1:1)
· 출처: wace 매퍼 T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID
· 사유: contract_mgmt.objid가 'CM-' prefix varchar라 bigint cast 불가
· 데이터 0건 무손실, ECR/CS는 bigint→varchar 자동 cast로 무영향
- approvalTableMigration.ts 동기화
[운영 배포 환경변수 — wace Constants 1:1 default 박힘]
- AMARANTH_OUT_PROCESS_CODE=RPSPLM_00001 (wace Constants.java:81)
- AMARANTH_FORM_ID_CONTRACT_ORDER=1161 (wace orderMgmtList.jsp:558)
- AMARANTH_COMP_SEQ=1000 (wace orderMgmtList.jsp:559)
- 3개 docker-compose(deploy/onpremise, docker/deploy, docker/prod) 모두
${VAR:-default} 형식으로 매핑 — 호스트 .env 미설정 시 default 동작
[검증]
- G9: BEGIN/ROLLBACK으로 26C-0800(라인 3건) 복사 시뮬레이션 — 헤더 23컬럼 1:1,
채번 26C-0803, 라인 3→3건 + seq 보존
- G11: 4단계 상태 라벨(create→inProcess→complete→reject) 모두 정상,
VARCHAR PK(CM-... prefix) JOIN도 정상
- 문서: docs/migration/sales/06-copy-order-verify.md, 07-amaranth-approval-verify.md
- GAP: G9 ✅, G10 ❌(영업 GAP 아님 — Admin 도메인), G11 ✅
[운영 트러블슈팅 노트 — 07-verify.md 트러블슈팅 섹션]
dev에서 amaranth 측이 "API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다"로
거부. 우리 코드는 정상 — 'Amaranth - 결재' accessToken을 amaranth 서버 측
Redis에 등록받아야 동작. chpark/RPS ERP 담당자 협조 영역(코드 변경 없음).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }); }
|
} 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 이식)
|
// 수주취소: 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식)
|
||||||
export async function saveCancelQty(req: AuthenticatedRequest, res: Response) {
|
export async function saveCancelQty(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ router.put("/:id", ctrl.update);
|
|||||||
router.delete("/:id", ctrl.remove);
|
router.delete("/:id", ctrl.remove);
|
||||||
router.patch("/:id/status", ctrl.updateStatus);
|
router.patch("/:id/status", ctrl.updateStatus);
|
||||||
router.post("/:id/cancel-qty", ctrl.saveCancelQty);
|
router.post("/:id/cancel-qty", ctrl.saveCancelQty);
|
||||||
|
router.post("/:id/copy", ctrl.copyOrder);
|
||||||
|
router.post("/:id/amaranth-approval", ctrl.startApproval);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -89,10 +89,12 @@ const STATEMENTS: string[] = [
|
|||||||
|
|
||||||
// ── AMARANTH_APPROVAL : Wehago/Amaranth SSO 매핑 ────────────────
|
// ── AMARANTH_APPROVAL : Wehago/Amaranth SSO 매핑 ────────────────
|
||||||
// 우리 시스템 결재(approval.objid) ↔ 아마란스 docId / approKey 연결
|
// 우리 시스템 결재(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 (
|
`CREATE TABLE IF NOT EXISTS amaranth_approval (
|
||||||
objid BIGINT PRIMARY KEY,
|
objid BIGINT PRIMARY KEY,
|
||||||
approval_objid BIGINT,
|
approval_objid BIGINT,
|
||||||
target_objid BIGINT,
|
target_objid VARCHAR(80),
|
||||||
target_type VARCHAR(50),
|
target_type VARCHAR(50),
|
||||||
appro_key VARCHAR(120), -- 외부시스템 연동키 ('UB_' + UUID)
|
appro_key VARCHAR(120), -- 외부시스템 연동키 ('UB_' + UUID)
|
||||||
out_process_code VARCHAR(80), -- 아마란스 결재연동코드
|
out_process_code VARCHAR(80), -- 아마란스 결재연동코드
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { AppError } from "../middleware/errorHandler";
|
import { AppError } from "../middleware/errorHandler";
|
||||||
|
import * as amaranth from "./amaranthApprovalClient";
|
||||||
|
|
||||||
export interface OrderListFilter {
|
export interface OrderListFilter {
|
||||||
category_cd?: string;
|
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) || '건'
|
WHEN COALESCE(SER_AGG.serial_count, 0) > 1 THEN SER_AGG.first_serial_no || ' 외 ' || (SER_AGG.serial_count - 1) || '건'
|
||||||
ELSE T.SERIAL_NO
|
ELSE T.SERIAL_NO
|
||||||
END AS SERIAL_NO
|
END AS SERIAL_NO
|
||||||
,NULL::text AS ORDER_APPR_STATUS
|
/* G4/G11 — 수주 결재상태 (wace contractMgmt.xml:523~530 1:1)
|
||||||
,NULL::text AS AMARANTH_STATUS
|
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
|
,0 AS CU01_CNT
|
||||||
,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER
|
,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER
|
||||||
FROM contract_mgmt T
|
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 != ''
|
WHERE CI.status='ACTIVE' AND CIS.serial_no IS NOT NULL AND CIS.serial_no != ''
|
||||||
GROUP BY CI.contract_objid
|
GROUP BY CI.contract_objid
|
||||||
) SER_AGG ON SER_AGG.contract_objid = T.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}
|
${where}
|
||||||
ORDER BY T.regdate DESC
|
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) {
|
export async function updateStatus(userId: string, objid: string, contractResult: string) {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ services:
|
|||||||
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
||||||
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
||||||
SMTP_PW_PURCHASE: ${SMTP_PW_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:
|
volumes:
|
||||||
- backend_uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ services:
|
|||||||
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
||||||
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
||||||
SMTP_PW_PURCHASE: ${SMTP_PW_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:
|
volumes:
|
||||||
- backend_uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ services:
|
|||||||
- SMTP_PW_SALES=${SMTP_PW_SALES:-}
|
- SMTP_PW_SALES=${SMTP_PW_SALES:-}
|
||||||
- SMTP_USER_PURCHASE=${SMTP_USER_PURCHASE:-}
|
- SMTP_USER_PURCHASE=${SMTP_USER_PURCHASE:-}
|
||||||
- SMTP_PW_PURCHASE=${SMTP_PW_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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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` 처리 추가 |
|
| 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 |
|
| 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`) 포팅 + 첨부 결합 |
|
| 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) 패턴 적용 + 삭제된 라인 처리 분기 |
|
| 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` 체크 → 있으면 거부 |
|
| 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 일괄 복사 |
|
| 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 자동 변환 | `contractBase` SQL EST_TOTAL_AMOUNT_KRW 환산식 | 환산식만 있고 환율 마스터 미구축 | 환율 테이블 신설(또는 ECOS API 동기화) |
|
| ~~G10~~ | ❌ | ~~환율 마스터 + EXCHANGE_RATE 자동 변환~~ | (영업관리 GAP 아님 — Admin 도메인) | wace 영업관리 화면도 `exchange_rate`는 사용자 직접 입력. `COMM_EXCHANGE_RATE` 테이블·환율관리 화면은 wace AdminController(`4898~4993`) + `admin.xml(8191~8336)` 소속 | 영업관리 GAP에서 제외. Admin 메뉴 이식 시점에 별도로 다룸 |
|
||||||
| G11 | 🟢 | 결재 모듈 (amaranth_approval / 자체) | 외부 amaranth + APPR_STATUS 라벨 | RPS 결재 정책 미정 | vexplor `approvalController` 매핑 vs `amaranth_approval` 도입 결정 |
|
| 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 정합성 메모
|
## 3. 코드/SQL 정합성 메모
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '<SOURCE_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='<SOURCE_OBJID>' AND status='ACTIVE';
|
||||||
|
|
||||||
|
-- 4. 검증: BEFORE/AFTER 비교
|
||||||
|
SELECT '원본 라인' AS what, count(*) FROM contract_item WHERE contract_objid='<SOURCE_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/<SOURCE_OBJID>/copy' \
|
||||||
|
-H 'Authorization: Bearer <TOKEN>' \
|
||||||
|
-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의 "한번에 한개의 수주만 복사 가능합니다." 분기는 불필요)
|
||||||
@@ -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/<CONTRACT_OBJID>/amaranth-approval' \
|
||||||
|
-H 'Authorization: Bearer <TOKEN>' \
|
||||||
|
-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 사원 동기화)가 잘 도는지 확인 → 다른 건 성공이면 결재 토큰만 별도 등록 필요 확정
|
||||||
|
|
||||||
|
**코드 변경 없음** — 운영 협조로 해결되는 영역. 토큰 등록 완료되면 같은 흐름이 그대로 동작.
|
||||||
@@ -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, CheckCircle2, XCircle } from "lucide-react";
|
import { Plus, Save, Trash2, Loader2, Search, Pencil, CheckCircle2, XCircle, Copy, 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";
|
||||||
@@ -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 → 일괄 저장
|
// 수주취소 — wace 패턴: 라인 조회 → 라인별 취소수량 input → 일괄 저장
|
||||||
const handleCancelOrder = async () => {
|
const handleCancelOrder = async () => {
|
||||||
if (!selected) { toast.warning("수주취소할 행을 선택해주십시오."); return; }
|
if (!selected) { toast.warning("수주취소할 행을 선택해주십시오."); return; }
|
||||||
@@ -504,6 +547,12 @@ export default function SalesOrderPage() {
|
|||||||
<Button size="sm" className="bg-rose-600 hover:bg-rose-700 text-white" onClick={handleCancelOrder} disabled={!selected}>
|
<Button size="sm" className="bg-rose-600 hover:bg-rose-700 text-white" onClick={handleCancelOrder} disabled={!selected}>
|
||||||
<XCircle className="w-4 h-4 mr-1" />수주취소
|
<XCircle className="w-4 h-4 mr-1" />수주취소
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleCopyOrder} disabled={!selected}>
|
||||||
|
<Copy className="w-4 h-4 mr-1" />수주복사
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white" onClick={handleAmaranthApproval} disabled={!selected}>
|
||||||
|
<Send className="w-4 h-4 mr-1" />결재상신
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
|
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />삭제
|
<Trash2 className="w-4 h-4 mr-1" />삭제
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export interface OrderRow {
|
|||||||
item_summary: string | null;
|
item_summary: string | null;
|
||||||
part_no: string | null;
|
part_no: string | null;
|
||||||
serial_no: string | null;
|
serial_no: string | null;
|
||||||
order_appr_status: string | null;
|
order_appr_status: string | null; // 한글 라벨 ('결재완료'/'결재중'/'반려'/'작성중'/'')
|
||||||
amaranth_status: string | null;
|
amaranth_status: string | null; // 원본 상태 (호환용)
|
||||||
|
order_amaranth_status: string | null; // 원본 상태 ('complete'/'inProcess'/'reject'/'create'/'')
|
||||||
cu01_cnt: number | null;
|
cu01_cnt: number | null;
|
||||||
is_direct_order: string | null;
|
is_direct_order: string | null;
|
||||||
}
|
}
|
||||||
@@ -136,4 +137,16 @@ export const salesOrderMgmtApi = {
|
|||||||
const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`);
|
const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`);
|
||||||
return res.data?.data ?? { info: null, items: [] };
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user