영업관리 구매요청서관리·품의서관리 신규 2메뉴 (wace_plm 1:1)

backend
- services/salesPurchaseRequestService.ts: listPurchaseRequestReg(DOC_TYPE='PURCHASE_REG') + listPurchaseRegProposal(DOC_TYPE='PURCHASE_REG_PROPOSAL')
  · 구매요청서 상태 CASE: PURCHASE_REG_PROPOSAL 자식 존재 시 '품의서생성' → '확정'/'작성중' (wace 매퍼 1:1)
  · 품의서 결재상태: amaranth_approval(target_type='PROPOSAL') LEFT JOIN 우선순위
  · sales_request_part 누락 → MBOM_DETAIL+PART_MNG fallback (구매관리 패턴 동일)
- routes/salesPurchaseRequestRoutes.ts + app.ts: /api/sales/purchase-request, /api/sales/purchase-proposal

frontend
- lib/api/salesPurchaseRequest.ts
- sales/purchase-request/page.tsx — 14컬럼, 구매요청서작성/품의서생성 액션 (placeholder 토스트)
- sales/purchase-proposal/page.tsx — 10컬럼, 결재상신 액션 (placeholder 토스트)
- PageHeader+CompactFilterBar+SmartSelect+DataGrid logicstudio 6종 패턴 일관 적용

구매관리>품의서관리 vs 영업관리>품의서관리 차이
- 구매관리: DOC_TYPE in ('PROPOSAL', 'PURCHASE_REG_PROPOSAL'(결재완료만)) → 발주서 생성 풀
- 영업관리: DOC_TYPE='PURCHASE_REG_PROPOSAL' 전용 → 결재상신 화면 (결재완료 시 구매관리로 자동 노출)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-15 10:04:37 +09:00
parent e785cd8a98
commit 7e7c6a0ac0
6 changed files with 804 additions and 0 deletions
+2
View File
@@ -179,6 +179,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
import salesPurchaseRequestRoutes from "./routes/salesPurchaseRequestRoutes"; // 영업관리>구매요청서관리·품의서관리 (wace_plm)
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식)
@@ -433,6 +434,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
app.use("/api/sales", salesPurchaseRequestRoutes); // 영업관리>구매요청서관리·품의서관리 (wace_plm)
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인)
@@ -0,0 +1,42 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 라우트
// 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' 그리드
// ============================================================
import { Router, Response } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/salesPurchaseRequestService";
import { logger } from "../utils/logger";
const router = Router();
router.use(authenticateToken);
function parseFilter(q: Record<string, any>): svc.SalesPurchaseRequestFilter {
const f: svc.SalesPurchaseRequestFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
async function run(
fn: (f: svc.SalesPurchaseRequestFilter) => Promise<any>,
req: AuthenticatedRequest,
res: Response,
name: string,
) {
try {
const data = await fn(parseFilter(req.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error(`${name} 실패`, { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
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, "영업>품의서관리"));
export default router;
@@ -0,0 +1,296 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 — wace_plm 1:1
//
// wace 매핑:
// /salesMng/purchaseRequestRegList.do
// → salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG')
// /salesMng/purchaseRegProposalMngList.do
// → salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL')
//
// 데이터 소스(RPS 존재 테이블 기준):
// ✓ sales_request_master, project_mgmt, contract_mgmt
// ✓ comm_code, client_mng, supply_mng
// ✓ mbom_header / mbom_detail / part_mng (품번/품명 fallback)
// ✓ amaranth_approval (품의서 결재상태)
// ✗ sales_request_part (운영DB 추출 후 신설 필요 — PART_NO fallback: MBOM_DETAIL)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface SalesPurchaseRequestFilter {
project_no?: string;
part_no?: string;
part_name?: string;
purchase_type?: string;
writer?: string;
part_type?: string;
search_status?: string;
proposal_no?: string;
regdate_start?: string;
regdate_end?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function clampPaging(f: SalesPurchaseRequestFilter) {
const page = Math.max(1, Number(f.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50)));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 1) 구매요청서관리 ───────────────────────────────────────────
// wace: salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG')
//
// 상태: 품의서생성/확정/작성중 (PURCHASE_REG_PROPOSAL 자식 존재 → '품의서생성')
// wace CASE: PROPOSAL 타입 SRM.PROJECT_NO=SRM.OBJID 존재 시 '품의서생성'
// RPS 보정: 동일 부모-자식 연결을 PURCHASE_REG_PROPOSAL.PROJECT_NO 로 추적
export async function listPurchaseRequestReg(
filter: SalesPurchaseRequestFilter,
): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [`SRM.DOC_TYPE = 'PURCHASE_REG'`];
const params: any[] = [];
const p = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`);
if (filter.writer) {
const ph = p(filter.writer);
where.push(`(SRM.REQUEST_USER_ID = ${ph} OR SRM.WRITER = ${ph})`);
}
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`);
if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NO ILIKE ${p(`%${filter.part_no}%`)})`);
if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NAME ILIKE ${p(`%${filter.part_name}%`)})`);
const whereSql = `WHERE ${where.join(" AND ")}`;
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS request_mng_no,
SRM.STATUS AS status,
SRM.PURCHASE_TYPE AS purchase_type,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '')
AS purchase_type_name,
-- 주문유형: 프로젝트.CATEGORY_CD 우선
COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1),
''
) AS order_type_name,
-- 제품구분: 프로젝트→계약 PRODUCT 우선
COALESCE(
(SELECT CC.CODE_NAME
FROM CONTRACT_MGMT CM
LEFT JOIN COMM_CODE CC ON CC.CODE_ID = CM.PRODUCT
WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1),
''
) AS product_name_full,
-- 고객사: 프로젝트→계약 CUSTOMER 우선
COALESCE(
(SELECT CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1)
END
FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
''
) AS customer_name,
-- 유/무상
CASE COALESCE(
(SELECT CM.PAID_TYPE FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
SRM.PAID_TYPE)
WHEN 'paid' THEN '유상'
WHEN 'free' THEN '무상'
ELSE ''
END AS paid_type_name,
PM.PROJECT_NO AS project_number,
-- 품번/품명 (MBOM 우선)
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
GREATEST(
(SELECT COUNT(DISTINCT PP.PART_NO)::int
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) - 1,
0
) AS part_extra_count,
-- 구매요청서 작성여부 (MBOM_HEADER 존재 시 Y)
CASE WHEN SRM.MBOM_HEADER_OBJID IS NOT NULL THEN 'Y' ELSE 'N' END
AS has_purchase_request,
-- 상태 라벨 (wace CASE 1:1)
CASE
WHEN EXISTS (
SELECT 1 FROM SALES_REQUEST_MASTER P
WHERE P.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'
AND P.PROJECT_NO = SRM.OBJID::VARCHAR
) THEN '품의서생성'
WHEN SRM.STATUS = 'confirmed' THEN '확정'
WHEN SRM.STATUS = 'create' THEN '작성중'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER) AS request_user_id,
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,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${p(limit)} OFFSET ${p(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listPurchaseRequestReg 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 2) 영업관리 > 품의서관리 (구매요청서 → 품의서) ─────────────
// wace: salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL')
//
// 결재상태 우선순위: AMR.STATUS > 내부 결재 > SRM.STATUS('create'→'등록중')
export async function listPurchaseRegProposal(
filter: SalesPurchaseRequestFilter,
): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`SRM.STATUS IN ('create','approvalRequest','approvalComplete','reject')`,
`SRM.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'`,
];
const params: any[] = [];
const p = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${p(`%${filter.proposal_no}%`)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`);
if (filter.writer) where.push(`SRM.WRITER = ${p(filter.writer)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`);
if (filter.search_status) {
// amaranth/내부 결재 종합 status = SEARCH_STATUS
where.push(`(
CASE
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
WHEN AMR.STATUS = 'reject' THEN 'reject'
ELSE SRM.STATUS
END
) = ${p(filter.search_status)}`);
}
const whereSql = `WHERE ${where.join(" AND ")}`;
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS proposal_no,
PM.PROJECT_NO AS project_number,
SRM.PURCHASE_TYPE AS purchase_type,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '')
AS purchase_type_name,
COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1),
''
) AS order_type_name,
SRM.PRODUCT_NAME AS product_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_title,
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
GREATEST(
(SELECT COUNT(DISTINCT PP.PART_NO)::int
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) - 1,
0
) AS part_extra_count,
CASE
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
WHEN AMR.STATUS = 'reject' THEN 'reject'
ELSE SRM.STATUS
END AS status,
CASE
WHEN AMR.STATUS = 'complete' THEN '결재완료'
WHEN AMR.STATUS = 'inProcess' THEN '결재 상신중'
WHEN AMR.STATUS = 'reject' THEN '반려'
ELSE '등록중'
END AS status_title,
COALESCE(AMR.STATUS, '') AS amaranth_status,
SRM.WRITER AS writer,
COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN AMARANTH_APPROVAL AMR
ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID
AND AMR.TARGET_TYPE = 'PROPOSAL'
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${p(limit)} OFFSET ${p(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN AMARANTH_APPROVAL AMR
ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID
AND AMR.TARGET_TYPE = 'PROPOSAL'
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listPurchaseRegProposal 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}