영업관리 구매요청 2메뉴 액션 완성 + SmartSelect 키보드 네비

- sales_request_part DDL 추출(운영 11133)→RPS(11134) 마이그레이션
- 백엔드 6 엔드포인트: 프로젝트 자동채움/M-BOM 품목/저장/품의서생성/SSO
  · 품의서 결재상신 Amaranth SSO (target_type=PROPOSAL, formId=1163)
- 프론트 다이얼로그 2개 (구매요청서작성 / 품의서생성 확인)
  · 프로젝트 선택→주문유형·제품구분·국내외·고객사·유무상 자동 채움
  · 행추가 시 M-BOM 품번 셀렉트→품명/공급업체/단가 자동 셋팅
- 공용 SmartSelect: ↑↓·Enter·Esc·Home·End·PageUp·Down 키보드 네비
- 그리드 delivery_request_date . → - 형식 정규화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-15 14:01:26 +09:00
parent 3db55d9fd9
commit 75f4ca8127
11 changed files with 1494 additions and 29 deletions
@@ -1,8 +1,14 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 라우트
// app.ts: app.use("/api/sales", salesPurchaseRequestRoutes)
// GET /api/sales/purchase-request — DOC_TYPE='PURCHASE_REG' 그리드
// GET /api/sales/purchase-proposal — DOC_TYPE='PURCHASE_REG_PROPOSAL' 그리드
// GET /api/sales/purchase-request — 구매요청서 그리드
// GET /api/sales/purchase-request/mbom-parts — 프로젝트별 M-BOM 품목 목록 (다이얼로그용)
// GET /api/sales/purchase-request/:objid — 단건 + 라인
// GET /api/sales/purchase-request/:objid/proposal-targets — 품의서 대상 품목
// POST /api/sales/purchase-request — 저장(UPSERT master + 라인 재생성)
// POST /api/sales/purchase-request/:objid/proposal — 품의서 생성
// GET /api/sales/purchase-proposal — 영업>품의서 그리드
// POST /api/sales/purchase-proposal/:objid/approval — Amaranth SSO 결재상신
// ============================================================
import { Router, Response } from "express";
@@ -10,6 +16,7 @@ import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/salesPurchaseRequestService";
import { logger } from "../utils/logger";
import { AppError } from "../middleware/errorHandler";
const router = Router();
router.use(authenticateToken);
@@ -36,7 +43,99 @@ async function run(
}
}
function handleError(res: Response, e: any, label: string) {
if (e instanceof AppError) {
return res.status(e.statusCode).json({ success: false, message: e.message });
}
logger.error(`${label} 실패`, { error: e?.message });
return res.status(500).json({ success: false, message: e?.message ?? `${label} 실패` });
}
router.get("/purchase-request", (req, res) => run(svc.listPurchaseRequestReg, req as AuthenticatedRequest, res, "구매요청서관리"));
router.get("/purchase-proposal", (req, res) => run(svc.listPurchaseRegProposal, req as AuthenticatedRequest, res, "영업>품의서관리"));
// 프로젝트 자동채움 정보 (주문유형/제품구분/국내외/고객사/유무상 + mbom_header_objid)
router.get("/purchase-request/project-info/:projectObjid", async (req, res) => {
try {
const info = await svc.getProjectAutoFillInfo(req.params.projectObjid);
return res.json({ success: true, data: info });
} catch (e: any) {
return handleError(res, e, "프로젝트 자동채움 정보");
}
});
// 프로젝트별 M-BOM 품목 (행추가 시 품번 셀렉트 옵션)
router.get("/purchase-request/mbom-parts", async (req, res) => {
try {
const projectObjid = String(req.query.project_objid ?? "");
if (!projectObjid) return res.json({ success: true, data: [] });
const rows = await svc.listMbomPartsForProject(projectObjid);
return res.json({ success: true, data: rows });
} catch (e: any) {
return handleError(res, e, "M-BOM 품목 조회");
}
});
// 단건 + 라인
router.get("/purchase-request/:objid", async (req, res) => {
try {
const detail = await svc.getPurchaseRequestDetail(req.params.objid);
return res.json({ success: true, data: detail });
} catch (e: any) {
return handleError(res, e, "구매요청서 상세");
}
});
// 품의서 대상 품목
router.get("/purchase-request/:objid/proposal-targets", async (req, res) => {
try {
const data = await svc.getProposalTargetParts(req.params.objid);
return res.json({ success: true, data });
} catch (e: any) {
return handleError(res, e, "품의서 대상 품목");
}
});
// 저장 (신규/수정 UPSERT)
router.post("/purchase-request", async (req, res) => {
const ar = req as AuthenticatedRequest;
try {
const userId = ar.user?.userId;
if (!userId) return res.status(401).json({ success: false, message: "인증 필요" });
const out = await svc.savePurchaseRequest(userId, req.body);
return res.json({ success: true, data: out });
} catch (e: any) {
return handleError(res, e, "구매요청서 저장");
}
});
// 품의서 생성
router.post("/purchase-request/:objid/proposal", async (req, res) => {
const ar = req as AuthenticatedRequest;
try {
const userId = ar.user?.userId;
if (!userId) return res.status(401).json({ success: false, message: "인증 필요" });
const out = await svc.createProposalFromPurchaseReg(userId, req.params.objid);
return res.json({ success: true, data: out });
} catch (e: any) {
return handleError(res, e, "품의서 생성");
}
});
// 결재상신 (Amaranth SSO)
router.post("/purchase-proposal/:objid/approval", async (req, res) => {
const ar = req as AuthenticatedRequest;
try {
const userId = ar.user?.userId;
if (!userId) return res.status(401).json({ success: false, message: "인증 필요" });
const out = await svc.startProposalApproval(userId, req.params.objid, {
approvalTitle: req.body?.approvalTitle,
subjectStr: req.body?.subjectStr,
});
return res.json({ success: true, data: out });
} catch (e: any) {
return handleError(res, e, "품의서 결재상신");
}
});
export default router;
@@ -17,6 +17,9 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
import { AppError } from "../middleware/errorHandler";
import * as amaranth from "./amaranthApprovalClient";
export interface SalesPurchaseRequestFilter {
project_no?: string;
@@ -151,7 +154,8 @@ export async function listPurchaseRequestReg(
COALESCE(user_name(COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER)),
COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER), '')
AS request_user_name,
SRM.DELIVERY_REQUEST_DATE AS delivery_request_date,
REPLACE(COALESCE(SRM.DELIVERY_REQUEST_DATE, ''), '.', '-')
AS delivery_request_date,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
@@ -294,3 +298,537 @@ export async function listPurchaseRegProposal(
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 3) 구매요청서 단건 + 라인 조회 ─────────────────────────────
// wace: getSalesRequestMasterInfo + getSalesRequestTargetList
export async function getPurchaseRequestDetail(srmObjid: string) {
const pool = getPool();
const headRes = await pool.query(
`SELECT
SRM.OBJID, SRM.REQUEST_MNG_NO, SRM.PROJECT_NO, SRM.MBOM_HEADER_OBJID,
SRM.PURCHASE_TYPE, SRM.ORDER_TYPE, SRM.PRODUCT_NAME, SRM.AREA_CD,
SRM.CUSTOMER_OBJID, SRM.PAID_TYPE, SRM.DELIVERY_REQUEST_DATE,
SRM.REQUEST_USER_ID, SRM.WRITER, SRM.STATUS, SRM.DOC_TYPE,
PM.PROJECT_NO AS PROJECT_NUMBER, PM.PROJECT_NAME,
PM.CATEGORY_CD, PM.CONTRACT_OBJID
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
WHERE SRM.OBJID = $1`,
[srmObjid],
);
if (headRes.rowCount === 0) throw new AppError("구매요청서를 찾을 수 없습니다.", 404);
const partRes = await pool.query(
`SELECT SRP.OBJID, SRP.PART_OBJID,
COALESCE(PM.PART_NO, '') AS PART_NO,
COALESCE(PM.PART_NAME, '') AS PART_NAME,
COALESCE(SRP.QTY, '0') AS QTY,
SRP.ORG_QTY, SRP.PARTNER_OBJID,
COALESCE(SRP.PARTNER_PRICE, '') AS PARTNER_PRICE,
COALESCE(SRP.UNIT_PRICE, 0) AS UNIT_PRICE,
COALESCE(SRP.VENDOR_PM, '') AS VENDOR_PM,
SRP.DELIVERY_REQUEST_DATE, SRP.STATUS, SRP.PROPOSAL_DATE
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM ON PM.OBJID::VARCHAR = SRP.PART_OBJID::VARCHAR
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
ORDER BY SRP.REGDATE`,
[srmObjid],
);
return { header: headRes.rows[0], parts: partRes.rows };
}
// ─── 3-1) 프로젝트 자동채움 정보 (wace purchaseOrderAdminSupplyInfo 1:1) ─
// 프로젝트 선택 시 주문유형(CATEGORY_CD) · 제품구분(PRODUCT) · 국내/해외(AREA_CD) ·
// 고객사(CUSTOMER_OBJID) · 유/무상(PAID_TYPE) 자동 채움 + M-BOM 헤더.
export async function getProjectAutoFillInfo(projectObjid: string) {
const pool = getPool();
const sql = `
SELECT
PM.OBJID,
PM.PROJECT_NO,
PM.PROJECT_NAME,
PM.CATEGORY_CD,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.CATEGORY_CD LIMIT 1) AS CATEGORY_NAME,
PM.CUSTOMER_OBJID,
CASE
WHEN PM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = PM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = PM.CUSTOMER_OBJID::VARCHAR LIMIT 1)
END AS CUSTOMER_NAME,
PM.PRODUCT,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.PRODUCT LIMIT 1) AS PRODUCT_NAME,
PM.AREA_CD,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.AREA_CD LIMIT 1) AS AREA_NAME,
CM.PAID_TYPE,
CM.OBJID AS CONTRACT_OBJID,
(SELECT MH.OBJID FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID::VARCHAR = PM.OBJID::VARCHAR
ORDER BY MH.REGDATE DESC LIMIT 1) AS MBOM_HEADER_OBJID
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID::VARCHAR
WHERE PM.OBJID::VARCHAR = $1
LIMIT 1
`;
try {
const r = await pool.query(sql, [projectObjid]);
return r.rows[0] || null;
} catch (e: any) {
logger.error("getProjectAutoFillInfo 실패", { error: e.message, projectObjid });
return null;
}
}
// ─── 4) 프로젝트별 M-BOM 품목 (구매요청서 신규 작성용) ─────────
// wace: salesMng.SalesBomPartListByProjectUnit (mbom_detail → part_mng)
export async function listMbomPartsForProject(projectObjid: string) {
const pool = getPool();
const sql = `
SELECT
MD.OBJID AS mbom_detail_objid,
MD.PART_OBJID AS part_objid,
MH.OBJID AS mbom_header_objid,
COALESCE(PM.PART_NO, '') AS part_no,
COALESCE(PM.PART_NAME, '') AS part_name,
COALESCE(MD.UNIT, '') AS unit,
COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0) AS qty,
COALESCE(MD.UNIT_PRICE, 0) AS unit_price,
COALESCE(MD.VENDOR, '') AS vendor_objid,
CASE
WHEN MD.VENDOR IS NULL OR MD.VENDOR = '' THEN ''
WHEN MD.VENDOR LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = MD.VENDOR LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = MD.VENDOR LIMIT 1)
END AS vendor_name
FROM MBOM_HEADER MH
JOIN MBOM_DETAIL MD ON MD.MBOM_HEADER_OBJID = MH.OBJID
LEFT JOIN PART_MNG PM ON PM.OBJID::VARCHAR = MD.PART_OBJID::VARCHAR
WHERE MH.PROJECT_OBJID::VARCHAR = $1
AND COALESCE(MD.USE_YN, 'Y') = 'Y'
ORDER BY MD.REGDATE
`;
try {
const r = await pool.query(sql, [projectObjid]);
return r.rows;
} catch (e: any) {
logger.error("listMbomPartsForProject 실패", { error: e.message, projectObjid });
return [];
}
}
// ─── 5) 구매요청서 저장 (UPSERT master + 라인 재생성) ────────────
// wace: saveSalesRequestInfo → mergeSalesRequestMasterInfo + initSalesRequestPart + mergeSalesRequestPartInfo
// doc_type='PURCHASE_REG' 명시 (구매요청서관리 신규 메뉴 흐름).
// request_mng_no 채번: R + YYYYMMDD + - + 3자리 (wace mergeSalesRequestMasterInfo 1:1)
export interface SavePurchaseRequestPayload {
objid?: string;
project_no?: string;
mbom_header_objid?: string;
purchase_type?: string;
order_type?: string;
product_name?: string;
area_cd?: string;
customer_objid?: string;
paid_type?: string;
delivery_request_date?: string;
parts: Array<{
objid?: string;
part_objid: string;
part_name?: string;
qty?: string | number;
org_qty?: string | number;
partner_objid?: string;
partner_price?: string | number;
delivery_request_date?: string;
status?: string;
}>;
}
export async function savePurchaseRequest(userId: string, payload: SavePurchaseRequestPayload) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let srmObjid = payload.objid;
const isNew = !srmObjid;
if (isNew) srmObjid = createObjId();
// 채번 — wace mergeSalesRequestMasterInfo 의 SELECT 절 1:1
const nextNoSql = `
SELECT 'R'||TO_CHAR(NOW(),'YYYYMMDD')||'-'||
LPAD((COALESCE(MAX(SUBSTR(REQUEST_MNG_NO,11,13)),'0')::INTEGER+1)::TEXT,3,'0') AS no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PURCHASE_REQUEST','PURCHASE_REG') OR DOC_TYPE IS NULL
`;
let requestMngNo: string | null = null;
if (isNew) {
const noRes = await client.query(nextNoSql);
requestMngNo = noRes.rows[0]?.no ?? null;
}
if (isNew) {
await client.query(
`INSERT INTO SALES_REQUEST_MASTER
(OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID,
PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, AREA_CD,
CUSTOMER_OBJID, PAID_TYPE, DELIVERY_REQUEST_DATE,
REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'create',$12,NOW(),'PURCHASE_REG')`,
[
srmObjid, requestMngNo, payload.project_no, payload.mbom_header_objid,
payload.purchase_type, payload.order_type, payload.product_name, payload.area_cd,
payload.customer_objid, payload.paid_type, payload.delivery_request_date || null,
userId,
],
);
} else {
await client.query(
`UPDATE SALES_REQUEST_MASTER
SET PROJECT_NO=$2, MBOM_HEADER_OBJID=$3,
PURCHASE_TYPE=$4, ORDER_TYPE=$5, PRODUCT_NAME=$6, AREA_CD=$7,
CUSTOMER_OBJID=$8, PAID_TYPE=$9, DELIVERY_REQUEST_DATE=$10
WHERE OBJID=$1`,
[
srmObjid, payload.project_no, payload.mbom_header_objid,
payload.purchase_type, payload.order_type, payload.product_name, payload.area_cd,
payload.customer_objid, payload.paid_type, payload.delivery_request_date || null,
],
);
}
// 라인 재생성 (wace initSalesRequestPart + mergeSalesRequestPartInfo)
await client.query(`DELETE FROM SALES_REQUEST_PART WHERE SALES_REQUEST_MASTER_OBJID=$1`, [srmObjid]);
for (const p of payload.parts || []) {
const partObjid = p.objid || createObjId();
const qtyVal = p.qty == null ? "0" : String(p.qty);
const orgQtyVal = p.org_qty == null ? "0" : String(p.org_qty);
await client.query(
`INSERT INTO SALES_REQUEST_PART
(OBJID, SALES_REQUEST_MASTER_OBJID, PART_OBJID, PART_NAME,
QTY, ORG_QTY, PARTNER_OBJID, PARTNER_PRICE,
DELIVERY_REQUEST_DATE, WRITER, REGDATE, STATUS)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`,
[
partObjid, srmObjid, p.part_objid, p.part_name || null,
qtyVal, orgQtyVal, p.partner_objid || null,
p.partner_price == null ? null : String(p.partner_price),
p.delivery_request_date || null,
userId, p.status || "create",
],
);
}
await client.query("COMMIT");
return { objid: srmObjid, request_mng_no: requestMngNo, isNew };
} catch (e: any) {
await client.query("ROLLBACK");
logger.error("savePurchaseRequest 실패", { error: e.message });
throw e;
} finally {
client.release();
}
}
// ─── 6) 품의서생성 대상 품목 조회 ──────────────────────────────
// wace: getProposalTargetPartsFromPurchaseReg
// 조건: 단가(UNIT_PRICE 또는 PARTNER_PRICE) > 0 AND 공급업체(VENDOR_PM 또는 PARTNER_OBJID) 입력 AND PROPOSAL_DATE 미입력
export async function getProposalTargetParts(srmObjid: string) {
const pool = getPool();
const targetSql = `
SELECT
SRP.OBJID, SRP.PART_OBJID,
COALESCE(PM.PART_NO, '') AS PART_NO,
COALESCE(PM.PART_NAME, '') AS PART_NAME,
COALESCE(SRP.QTY, '0') AS QTY,
COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0) AS UNIT_PRICE,
SRP.TOTAL_PRICE,
COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID, '') AS VENDOR_PM,
CASE
WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) IS NULL THEN ''
WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIMIT 1)
END AS VENDOR_NAME
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
AND (
(SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0)
OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0)
)
AND (
(SRP.VENDOR_PM IS NOT NULL AND SRP.VENDOR_PM <> '')
OR (SRP.PARTNER_OBJID IS NOT NULL AND SRP.PARTNER_OBJID <> '')
)
AND SRP.PROPOSAL_DATE IS NULL
ORDER BY SRP.REGDATE
`;
const excludedSql = `
SELECT
SRP.OBJID, SRP.PART_OBJID,
COALESCE(PM.PART_NO,'') AS PART_NO,
COALESCE(PM.PART_NAME,'') AS PART_NAME,
COALESCE(SRP.QTY,'0') AS QTY
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
AND (
(SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0)
OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0)
)
AND (SRP.VENDOR_PM IS NULL OR SRP.VENDOR_PM = '')
AND (SRP.PARTNER_OBJID IS NULL OR SRP.PARTNER_OBJID = '')
AND SRP.PROPOSAL_DATE IS NULL
ORDER BY SRP.REGDATE
`;
const [t, x] = await Promise.all([pool.query(targetSql, [srmObjid]), pool.query(excludedSql, [srmObjid])]);
return { targets: t.rows, excluded: x.rows };
}
// ─── 7) 품의서 생성 (PURCHASE_REG → PURCHASE_REG_PROPOSAL) ─────
// wace: createProposalFromPurchaseReg
// 1) request_mng_no 채번 (P + YYYYMMDD + - + 3자리)
// 2) sales_request_master INSERT (DOC_TYPE='PURCHASE_REG_PROPOSAL', PROJECT_NO=원본 OBJID, STATUS='create')
// 3) 선택된 SRP 행을 새 master 로 복사 (UNIT_PRICE/VENDOR_PM 보정, PROPOSAL_DATE=NOW)
// 4) 원본 SRP.PROPOSAL_DATE = NOW (재생성 방지)
export async function createProposalFromPurchaseReg(userId: string, srmObjid: string) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1) 원본 master 정보 조회
const masterRes = await client.query(
`SELECT OBJID, PROJECT_NO, PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, AREA_CD,
CUSTOMER_OBJID, PAID_TYPE, DELIVERY_REQUEST_DATE, MBOM_HEADER_OBJID, DOC_TYPE
FROM SALES_REQUEST_MASTER WHERE OBJID=$1`,
[srmObjid],
);
if (masterRes.rowCount === 0) throw new AppError("구매요청서를 찾을 수 없습니다.", 404);
const m = masterRes.rows[0];
if (m.doc_type && m.doc_type !== "PURCHASE_REG" && m.doc_type !== "PURCHASE_REQUEST") {
throw new AppError("구매요청서만 품의서를 생성할 수 있습니다.", 400);
}
// 2) 대상 품목 조회 (위 getProposalTargetParts 와 동일 SQL — 트랜잭션 내 client 재사용)
const partsRes = await client.query(
`SELECT SRP.OBJID, SRP.PART_OBJID, SRP.QTY, SRP.UNIT_PRICE, SRP.TOTAL_PRICE,
SRP.PARTNER_PRICE, SRP.VENDOR_PM, SRP.PARTNER_OBJID, SRP.NET_QTY, SRP.USE_YN
FROM SALES_REQUEST_PART SRP
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
AND (
(SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0)
OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0)
)
AND (
(SRP.VENDOR_PM IS NOT NULL AND SRP.VENDOR_PM <> '')
OR (SRP.PARTNER_OBJID IS NOT NULL AND SRP.PARTNER_OBJID <> '')
)
AND SRP.PROPOSAL_DATE IS NULL
ORDER BY SRP.REGDATE`,
[srmObjid],
);
if (partsRes.rowCount === 0) {
throw new AppError("품의서 생성 대상 품목이 없습니다. (단가+공급업체 입력 + 품의서 미생성 품목만 대상)", 400);
}
// 3) 품의서 채번
const proposalNoRes = await client.query(
`SELECT 'P'||TO_CHAR(NOW(),'YYYYMMDD')||'-'||
LPAD((COALESCE(MAX(SUBSTR(REQUEST_MNG_NO,11,3))::INTEGER, 0)+1)::TEXT,3,'0') AS no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PROPOSAL','PURCHASE_REG_PROPOSAL')
AND REQUEST_MNG_NO LIKE 'P'||TO_CHAR(NOW(),'YYYYMMDD')||'%'`,
);
const proposalNo = proposalNoRes.rows[0]?.no;
// 4) 품의서 master INSERT (PROJECT_NO=원본 OBJID — 자식 관계 추적)
const proposalObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_MASTER
(OBJID, REQUEST_MNG_NO, PROJECT_NO, PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME,
AREA_CD, CUSTOMER_OBJID, PAID_TYPE, REQUEST_USER_ID, DELIVERY_REQUEST_DATE,
STATUS, WRITER, REGDATE, DOC_TYPE)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'create',$10,NOW(),'PURCHASE_REG_PROPOSAL')`,
[
proposalObjid, proposalNo, m.objid,
m.purchase_type, m.order_type, m.product_name,
m.area_cd, m.customer_objid, m.paid_type, userId,
m.delivery_request_date,
],
);
// 5) 품의서 part INSERT (원본 SRP 복사, PROPOSAL_DATE=NOW)
const sourceObjids: string[] = [];
for (const p of partsRes.rows) {
sourceObjids.push(p.objid);
const newPartObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_PART
(OBJID, SALES_REQUEST_MASTER_OBJID, PART_OBJID, QTY, UNIT_PRICE, TOTAL_PRICE,
VENDOR_PM, NET_QTY, PO_QTY, USE_YN, PROPOSAL_DATE, WRITER, REGDATE)
VALUES ($1, $2, $3, $4,
CASE
WHEN $5::NUMERIC IS NOT NULL AND $5::NUMERIC > 0 THEN $5::NUMERIC
WHEN $6 IS NOT NULL AND $6 <> '' THEN $6::NUMERIC
ELSE 0
END,
CASE
WHEN $7::NUMERIC IS NOT NULL AND $7::NUMERIC > 0 THEN $7::NUMERIC
ELSE COALESCE(NULLIF($4,'')::NUMERIC,0) *
CASE
WHEN $5::NUMERIC IS NOT NULL AND $5::NUMERIC > 0 THEN $5::NUMERIC
WHEN $6 IS NOT NULL AND $6 <> '' THEN $6::NUMERIC
ELSE 0
END
END,
COALESCE(NULLIF($8,''), $9),
$10,
COALESCE(NULLIF($4,'')::NUMERIC, 0),
$11,
NOW(), $12, NOW())`,
[
newPartObjid, proposalObjid, p.part_objid, p.qty,
p.unit_price, p.partner_price, p.total_price,
p.vendor_pm, p.partner_objid, p.net_qty,
p.use_yn || "Y", userId,
],
);
}
// 6) 원본 SRP.PROPOSAL_DATE 업데이트 (재생성 방지)
await client.query(
`UPDATE SALES_REQUEST_PART SET PROPOSAL_DATE = NOW() WHERE OBJID = ANY($1::varchar[])`,
[sourceObjids],
);
await client.query("COMMIT");
logger.info("품의서 생성", { srmObjid, proposalObjid, proposalNo, partCount: sourceObjids.length });
return { proposal_objid: proposalObjid, proposal_no: proposalNo, part_count: sourceObjids.length };
} catch (e: any) {
await client.query("ROLLBACK");
if (e instanceof AppError) throw e;
logger.error("createProposalFromPurchaseReg 실패", { error: e.message });
throw new AppError(`품의서 생성 실패: ${e.message}`, 500);
} finally {
client.release();
}
}
// ─── 8) 품의서 결재상신 (Amaranth SSO) ──────────────────────────
// wace: purchaseRegProposalMngList.jsp:75~99 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl
// target_type='PROPOSAL', formId='1163', compSeq='1000'
// 재상신/재사용 로직은 G11(견적) 동일 패턴.
export async function startProposalApproval(
userId: string,
proposalSrmObjid: string,
opts: { approvalTitle?: string; subjectStr?: string } = {},
): Promise<{ fullUrl: string; approKey: string; status: string; proposalObjid: string }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
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(u.emp_seq ?? "").trim();
if (!empSeq) throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400);
const headRes = await client.query(
`SELECT OBJID, REQUEST_MNG_NO, DOC_TYPE, STATUS
FROM SALES_REQUEST_MASTER WHERE OBJID=$1`,
[proposalSrmObjid],
);
if (headRes.rowCount === 0) throw new AppError("품의서를 찾을 수 없습니다.", 404);
const head = headRes.rows[0];
if (head.doc_type !== "PURCHASE_REG_PROPOSAL" && head.doc_type !== "PROPOSAL") {
throw new AppError("품의서만 결재상신할 수 있습니다.", 400);
}
const targetType = "PROPOSAL";
const targetObjid = String(proposalSrmObjid);
const approvalTitle = opts.approvalTitle || `품의서 결재${head.request_mng_no ? " - " + head.request_mng_no : ""}`;
const outProcessCode =
process.env.AMARANTH_OUT_PROCESS_CODE_PROPOSAL ||
process.env.AMARANTH_OUT_PROCESS_CODE || "";
const formId = process.env.AMARANTH_FORM_ID_PROPOSAL || "1163";
const compSeq = process.env.AMARANTH_COMP_SEQ || "1000";
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 = "UB_" + Date.now().toString(36).toUpperCase();
mode = "update_resubmit";
} else {
approKey = String(existRes.rows[0].appro_key || "");
mode = "update_reuse";
}
}
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);
}
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 {
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("품의서 결재상신", { proposalSrmObjid, approKey, mode });
return { fullUrl, approKey, status: "create", proposalObjid: targetObjid };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}