구매관리 발주서관리 통합 + 폼 GET API + PageHeader 컨벤션

발주서관리 리스트:
- /purchase/order-wace 임시 라우트 → /purchase/order 통합 (기존 vexplor
  변형판 대체). order-wace 폴더 삭제.
- 백엔드 라우트 /order-wace → /order-list, 함수 listPurchaseOrderWace →
  listPurchaseOrderList, API 클라이언트 listOrderWace → listOrder.

발주서 폼 (general 양식) GET API:
- services/purchaseOrderFormService.ts 신규 (getPurchaseOrderFormInit,
  getPurchaseOrderForm). 품의서 자동채움 = salesMng.getProposalPartList
  매퍼 1:1 → 발주 그리드 형식 변환. 발주번호 채번 RPS{YY}-{MMDD}-{NN}.
- 컨트롤러/라우트: GET /api/purchase/order-form/init?proposal_objid=...
  + /api/purchase/order-form/:objid.
- RPS는 OBJID가 varchar라 wace numeric 캐스트 모두 제거.

PageHeader 컨벤션 일괄 변경:
- 자동매칭이 매칭된 menu의 parent_obj_id로 부모를 찾아
  "{부모}_{자식}" 형식 표기 (wace 컨벤션). 부모가 루트 그룹이면 자식만.
- description prop과 렌더링 완전 제거 (사용처 없음 확인).
- 모든 메뉴 페이지에 일괄 적용.

DB(별도): menu_info 9857401373575 + rel_menu_auth 3건 제거.
저장/삭제 API + 프론트 다이얼로그는 다음 세션.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-19 11:47:38 +09:00
parent aacbb62ad8
commit 6f73631c7c
8 changed files with 511 additions and 1549 deletions
@@ -5,6 +5,7 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import * as formSvc from "../services/purchaseOrderFormService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
@@ -36,7 +37,41 @@ export const getInbound = (req: AuthenticatedRequest, res: Response)
export const getInboundByItem = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByItem, req, res, "품목별 입고관리");
export const getInboundByDate = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByDate, req, res, "입고일별 입고관리");
export const getProjectStatus = (req: AuthenticatedRequest, res: Response) => runList(svc.listProjectStatus, req, res, "프로젝트별 발주/입고 현황");
export const getPurchaseOrderWace = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseOrderWace, req, res, "발주서관리");
export const getPurchaseOrderList = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseOrderList, req, res, "발주서관리");
// ─── 발주서 폼 (general 양식) ─────────────────────────────────
/**
* GET /api/purchase/order-form/init?proposal_objid=...
* 품의서에서 발주서 등록 폼 데이터 자동 채움.
*/
export async function getPurchaseOrderFormInit(req: AuthenticatedRequest, res: Response) {
try {
const proposalObjid = String(req.query.proposal_objid ?? "").trim();
const data = await formSvc.getPurchaseOrderFormInit(proposalObjid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("발주서 폼 초기화 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/**
* GET /api/purchase/order-form/:objid
* 발주서 마스터 + 파트 조회 (수정/조회 모드).
*/
export async function getPurchaseOrderForm(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "objid required" });
const data = await formSvc.getPurchaseOrderForm(objid);
if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" });
return res.json({ success: true, data });
} catch (e: any) {
logger.error("발주서 폼 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
+5 -1
View File
@@ -18,7 +18,11 @@ router.get("/inbound", ctrl.getInbound); // 입고관리
router.get("/inbound-by-item", ctrl.getInboundByItem); // 품목별 입고관리
router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입고관리
router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황
router.get("/order-wace", ctrl.getPurchaseOrderWace); // 발주서관리 (wace 1:1, 기존 /order 보존)
router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1)
// 발주서 폼 (general 양식, wace purchaseOrderFormPopup_general.do 1:1)
router.get("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움
router.get("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
// 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers);
@@ -0,0 +1,240 @@
// ============================================================
// 발주서관리 — 등록/수정 폼 (general 양식) 서비스
//
// wace_plm 1:1 이식 베이스:
// - controller: purchaseOrder/purchaseOrderFormPopup_general.do
// purchaseOrder/purchaseOrderFormPopup_generalSave.do
// - service: PurchaseOrderService.savePurchaseOrder_new (1472-1817)
// - mapper: purchaseOrder.xml mergePurchaseOrderMaster (530-714) +
// mergePurchaseOrderPartInfo (1205-1325) +
// getPurchaseOrderMasterInfo (1343-1556) +
// getPURCHASE_ORDER_PART
// salesMng.xml getProposalPartList (5012-5125) +
// getProposalInfo (4919-)
//
// 운영 핵심 흐름: 품의서(/purchase/proposal)에서 "발주서생성" → general 다이얼로그 →
// 품의서 품목 자동 채움 → 마스터 입력 → 저장.
//
// 본 모듈은 form-init / form-get 두 GET 엔드포인트를 제공.
// (save / delete 는 다음 단계)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface OrderFormInitResult {
master: Record<string, any>;
parts: Record<string, any>[];
}
/**
* GET /api/purchase/order-form/init?proposal_objid=...
*
* 품의서(sales_request_master)에서 발주서 등록 폼을 채울 데이터를 반환.
* wace controller `purchaseOrderFormPopup_general.do` 의 신규 등록 분기와 1:1.
*
* master 기본값:
* - PROPOSAL_OBJID / SALES_REQUEST_OBJID = proposal_objid
* - CONTRACT_MGMT_OBJID = proposal.PROJECT_NO
* - PURCHASE_ORDER_NO = "RPS{YY}-{MMDD}-{NN}" (NN: 당일 발주 카운트+1)
* - PURCHASE_DATE / ORDER_DATE = 오늘
* - STATUS = "create" / FORM_TYPE = "general"
* parts: salesMng.getProposalPartList SQL 1:1 → 발주서 그리드 형식 변환
* (ORDER_QTY=QTY, PARTNER_PRICE=UNIT_PRICE, SUPPLY_UNIT_PRICE=QTY*UNIT_PRICE 등)
*/
export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<OrderFormInitResult> {
const pool = getPool();
// 1) 품의서 마스터 정보 (PROJECT_NO 등)
let proposal: Record<string, any> | null = null;
if (proposalObjid) {
try {
const r = await pool.query(
`SELECT OBJID, PROJECT_NO, MBOM_HEADER_OBJID, TITLE, REQUEST_USER_ID, PURCHASE_TYPE
FROM SALES_REQUEST_MASTER WHERE OBJID = $1`,
[proposalObjid],
);
proposal = r.rows[0] ?? null;
} catch (e: any) {
logger.warn("getProposalInfo 실패", { error: e.message });
}
}
// 2) 발주번호 채번 (wace mergePurchaseOrderMaster INSERT 절 1:1)
let purchaseOrderNo = "";
try {
const r = await pool.query(
`SELECT 'RPS' || TO_CHAR(NOW(),'YY') || '-' || TO_CHAR(NOW(),'MMDD') || '-' ||
LPAD((COALESCE(MAX(CASE WHEN PURCHASE_ORDER_NO LIKE 'RPS' || TO_CHAR(NOW(),'YY-MMDD') || '-%'
THEN SPLIT_PART(PURCHASE_ORDER_NO, '-', 3) ELSE '0' END)::INTEGER, 0) + 1)::TEXT, 2, '0')
AS po_no
FROM PURCHASE_ORDER_MASTER`,
);
purchaseOrderNo = r.rows[0]?.po_no ?? "";
} catch (e: any) {
logger.warn("발주번호 채번 실패", { error: e.message });
}
const todayIso = new Date().toISOString().slice(0, 10);
const master: Record<string, any> = {
objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번
purchase_order_no: purchaseOrderNo,
purchase_date: todayIso, // 발주일
order_date_kor: formatKorDate(new Date()), // 발주일자 한글 표기 (wace ORDER_DATE)
status: "create",
form_type: "general",
sales_request_objid: proposalObjid || "",
proposal_objid: proposalObjid || "",
contract_mgmt_objid: proposal?.project_no ?? "",
title: proposal?.title ?? "",
// wace controller _general 기본 담당자 (RPS 운영 고정값)
manager_name: "안동윤",
manager_position: "팀장",
manager_phone: "010-2313-2702",
manager_email: "ady1225@rps-korea.com",
manager_name2: "서동민",
manager_position2: "주임",
manager_phone2: "010-9538-9513",
manager_email2: "sdm0927@rps-korea.com",
};
// 3) 품의서 품목 → 발주서 파트 변환 (salesMng.getProposalPartList 1:1)
const parts: Record<string, any>[] = [];
if (proposalObjid) {
try {
const r = await pool.query(
`SELECT
ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum,
SRP.OBJID AS srp_objid,
SRP.PART_OBJID AS part_objid,
PM.PART_NO AS part_no,
PM.PART_NAME AS part_name,
PM.SPEC AS spec,
PM.MATERIAL AS material,
COALESCE(NULLIF(SRP.UNIT, ''), PM.UNIT_DC) AS unit,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = NULLIF(SRP.UNIT, '')),
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = PM.UNIT_DC)
) AS unit_title,
SRP.QTY AS qty,
COALESCE(SRP.UNIT_PRICE, 0) AS unit_price,
CASE WHEN COALESCE(SRP.TOTAL_PRICE::NUMERIC, 0) > 0
THEN SRP.TOTAL_PRICE::NUMERIC
ELSE COALESCE(SRP.QTY::NUMERIC, 0) * COALESCE(SRP.UNIT_PRICE::NUMERIC, 0)
END AS total_price,
SRP.VENDOR_PM AS vendor_pm,
(SELECT CLIENT_NM FROM CLIENT_MNG
WHERE OBJID::VARCHAR = SRP.VENDOR_PM) AS vendor_name,
SRP.REMARK AS remark,
SRP.DELIVERY_REQUEST_DATE AS delivery_request_date,
COALESCE(SRP.MATERIAL_YN, 'N') AS material_yn,
(SELECT PJ.PART_NAME FROM PROJECT_MGMT PJ
WHERE PJ.OBJID::VARCHAR = SRM.PROJECT_NO) AS project_product_name,
PM.PART_NAME AS component_part_name,
SRP.CURRENCY AS currency,
(SELECT CC.CODE_NAME FROM COMM_CODE CC
WHERE CC.CODE_ID = NULLIF(SRP.CURRENCY, '')) AS currency_name
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM
ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
LEFT JOIN SALES_REQUEST_MASTER SRM
ON SRP.SALES_REQUEST_MASTER_OBJID = SRM.OBJID
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
ORDER BY SRP.REGDATE`,
[proposalObjid],
);
for (const row of r.rows) {
const qty = toNum(row.qty);
const unitPrice = toNum(row.unit_price);
const projectName = (row.project_product_name ?? "").toString().trim();
const componentName = (row.component_part_name ?? "").toString().trim();
const remark = projectName && componentName ? `${projectName} / ${componentName}`
: projectName || componentName || (row.remark ?? "");
parts.push({
// 발주서 그리드 형식 (wace controller _general 변환 1:1)
objid: "", // 신규 row
part_objid: row.part_objid ?? "",
row_num: row.rnum,
part_no: row.part_no ?? "",
part_name: row.part_name ?? "",
spec: row.spec ?? "",
material: row.material ?? "",
order_qty: qty,
unit: row.unit || "0001400", // wace 기본값 EA
unit_title: row.unit_title ?? "",
part_delivery_place: "RPS",
partner_price: unitPrice,
supply_unit_price: qty * unitPrice,
remark,
delivery_request_date: row.delivery_request_date ?? "",
currency: row.currency ?? "",
currency_name: row.currency_name ?? "",
// 추적용 (저장 시 신규 row 임을 구분)
_src: "proposal",
_src_objid: row.srp_objid,
});
}
} catch (e: any) {
logger.error("getProposalPartList 실패", { error: e.message });
}
}
return { master, parts };
}
/**
* GET /api/purchase/order-form/:objid
*
* 발주서 마스터 + 파트 조회 (수정/조회 모드).
* wace `getPurchaseOrderMasterInfo` (1343-1556) + `getPURCHASE_ORDER_PART` 의 RPS 압축판.
*/
export async function getPurchaseOrderForm(objid: string): Promise<OrderFormInitResult | null> {
const pool = getPool();
try {
const m = await pool.query(
`SELECT POM.*,
(SELECT CLIENT_NM FROM CLIENT_MNG
WHERE OBJID::VARCHAR = POM.PARTNER_OBJID) AS partner_name,
(SELECT USER_NAME FROM USER_INFO
WHERE USER_ID = POM.SALES_MNG_USER_ID) AS sales_mng_user_name,
(SELECT USER_NAME FROM USER_INFO
WHERE USER_ID = POM.WRITER) AS writer_name,
CM.PROJECT_NO AS project_no,
SRM.REQUEST_MNG_NO AS proposal_no
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID
LEFT JOIN SALES_REQUEST_MASTER SRM ON POM.SALES_REQUEST_OBJID = SRM.OBJID
WHERE POM.OBJID = $1`,
[objid],
);
if (m.rows.length === 0) return null;
const p = await pool.query(
`SELECT POP.*,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.CURRENCY) AS currency_name
FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1
ORDER BY POP.REGDATE`,
[objid],
);
return { master: m.rows[0], parts: p.rows };
} catch (e: any) {
logger.error("getPurchaseOrderForm 실패", { error: e.message });
return null;
}
}
function toNum(v: any): number {
if (v == null || v === "") return 0;
const s = String(v).replace(/,/g, "");
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
function formatKorDate(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
}
+3 -3
View File
@@ -873,13 +873,13 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise<Lis
//
// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:3295-3589
// 화면: wace_plm/.../purchaseOrder/purchaseOrderList_new.jsp (1085 lines)
// 라우트: /api/purchase-wace/order-list (기존 /purchase/order page.tsx 보존 + 새 라우트)
// 라우트: GET /api/purchase/order-list
//
// 컬럼: 품의서No · 발주서No · 프로젝트번호 · 구매유형 · 주문유형 · 제품구분 ·
// 품번 · 품명 · 공급업체 · 환종 · 총액 · 메일발송 · 발주일 · 구매담당자 · 작성일
// 검색: 년도/고객사/프로젝트(CSV)/발주No/공급업체/품번/품명/입고요청일/발주일/
// 주문유형/제품구분/구매유형/구매담당자/메일발송
export async function listPurchaseOrderWace(filter: PurchaseListFilter): Promise<ListResult<any>> {
export async function listPurchaseOrderList(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
@@ -1054,7 +1054,7 @@ export async function listPurchaseOrderWace(filter: PurchaseListFilter): Promise
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listPurchaseOrderWace 실패", { error: e.message });
logger.error("listPurchaseOrderList 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}