영업관리 구매요청 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user