구매관리 7메뉴 신규 + M-BOM PR-B3·B5 + 발주관리 DataGrid 통일 + 생산계획&실적 라우트

구매관리 (wace 1:1)
- backend: services/purchaseService.ts (7 list + 옵션 3종) + controllers/purchaseController.ts + routes/purchaseRoutes.ts (/api/purchase 마운트)
- frontend: lib/api/purchase.ts + 7 page.tsx (list/quote-request/proposal/inbound/inbound-by-item/inbound-by-date/project-status)
- 영업관리 4메뉴 DataGrid 패턴 통일 — pageSizeOptions=[10,15,20,50,100], emptyMessage, showColumnSettings/summaryStats/onRefresh/onDownload/showChart
- 마스터단독 데이터(sales_request_master, project_mgmt+mbom_detail) 노출, detail/part 누락 테이블 의존은 빈 그리드 + UI

발주관리 (purchase/order/page.tsx)
- EDataTable → DataGrid 교체 + logicstudio 6종 props + 날짜/숫자 pre-format

M-BOM PR-B3 — 구매리스트 생성 (wace createPurchaseListFromMBom.do 1:1)
- mbomService.createSalesRequest + controller + route POST /api/production/mbom/sales-request
- 단건 체크 + 1:1 강제 + R-YYYYMMDD-NNN 채번 + sales_request_master 단건 INSERT
- production/mbom/page.tsx 에 [구매리스트 생성] 버튼

M-BOM PR-B5 — BOM 할당 (mBomEbomSelectPopup.do)
- mbomService.searchAssignableEboms/assignBom + controller + routes
- MbomAssignDialog 신규, MbomDetailDialog 통합

생산관리 4메뉴 라우트 (생산계획&실적, 소요량)
- prodPlanResultService/Controller + productionPlanResultRoutes (planResult/mbomReq)
- mbomRequirementService + 4 page.tsx (prod-plan-result, prod-plan-result-equip, raw-material-requirement, semi-product-requirement)
- lib/api/prodPlanResult.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-14 17:31:12 +09:00
parent dee03f6024
commit b38f5957f2
29 changed files with 4470 additions and 40 deletions
+5
View File
@@ -120,6 +120,8 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인)
import { planResultRouter as productionPlanResultRoutes, mbomReqRouter as productionMbomRequirementRoutes } from "./routes/productionPlanResultRoutes"; // 생산관리 4개 메뉴 (생산계획&실적, 소요량)
import purchaseRoutes from "./routes/purchaseRoutes"; // 구매관리 7개 메뉴 (wace_plm 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
@@ -382,6 +384,9 @@ app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관리 (wace_plm 도메인)
app.use("/api/production/plan-result", productionPlanResultRoutes); // 생산계획&실적관리 (메뉴1+2)
app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes); // 반제품/원자재 소요량 (메뉴3+4)
app.use("/api/purchase", purchaseRoutes); // 구매관리 7개 메뉴 (wace_plm 도메인)
app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징)
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
app.use("/api/material-status", materialStatusRoutes); // 자재현황
@@ -53,6 +53,44 @@ export async function getTree(req: AuthenticatedRequest, res: Response) {
}
}
// PR-B5 — 할당 가능한 E-BOM 검색 (운영 mBomEbomSelectPopup.do getEbomList 1:1)
export async function searchAssignableEboms(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.searchAssignableEboms({
search_part_no: String(q.search_part_no ?? "").trim() || undefined,
search_part_name: String(q.search_part_name ?? "").trim() || undefined,
search_material: String(q.search_material ?? "").trim() || undefined,
search_supplier: String(q.search_supplier ?? "").trim() || undefined,
limit: q.limit ? Number(q.limit) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("할당 가능 E-BOM 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1)
export async function assignBom(req: AuthenticatedRequest, res: Response) {
try {
const projectObjId = String(req.body?.project_obj_id ?? req.body?.projectObjId ?? "").trim();
const sourceBomType = String(req.body?.source_bom_type ?? req.body?.sourceBomType ?? "").trim().toUpperCase();
const sourceBomObjId = String(req.body?.source_bom_obj_id ?? req.body?.sourceBomObjId ?? "").trim();
if (!projectObjId) return res.status(400).json({ success: false, message: "project_obj_id 누락" });
if (sourceBomType !== "EBOM" && sourceBomType !== "MBOM") {
return res.status(400).json({ success: false, message: "source_bom_type 은 EBOM 또는 MBOM" });
}
if (!sourceBomObjId) return res.status(400).json({ success: false, message: "source_bom_obj_id 누락" });
const userId = req.user?.userId ?? "system";
const data = await svc.assignBom(projectObjId, sourceBomType as svc.AssignSourceType, sourceBomObjId, userId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("BOM 할당 실패", { error: e.message });
return res.status(400).json({ success: false, message: e.message });
}
}
// PR-B4 — 변경이력 조회 (운영 getMbomHistory.do 1:1)
export async function getHistory(req: AuthenticatedRequest, res: Response) {
try {
@@ -66,6 +104,30 @@ export async function getHistory(req: AuthenticatedRequest, res: Response) {
}
}
// PR-B3 — 구매리스트(SALES_REQUEST_MASTER) 생성 (운영 createPurchaseListFromMBom.do 1:1)
export async function createSalesRequest(req: AuthenticatedRequest, res: Response) {
try {
const mbomHeaderObjid = String(
req.body?.mbom_header_objid ?? req.body?.MBOM_HEADER_OBJID ?? "",
).trim();
const projectMgmtObjid = String(
req.body?.project_mgmt_objid ?? req.body?.PROJECT_MGMT_OBJID ?? "",
).trim();
if (!mbomHeaderObjid) {
return res.status(400).json({ success: false, message: "mbom_header_objid 누락" });
}
if (!projectMgmtObjid) {
return res.status(400).json({ success: false, message: "project_mgmt_objid 누락" });
}
const userId = req.user?.userId ?? "system";
const data = await svc.createSalesRequest(mbomHeaderObjid, projectMgmtObjid, userId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("구매리스트 생성 실패", { error: e.message });
return res.status(400).json({ success: false, message: e.message });
}
}
// PR-B1 — 본 편집 저장 (운영 saveMbom.do 1:1)
export async function save(req: AuthenticatedRequest, res: Response) {
try {
@@ -0,0 +1,107 @@
// ============================================================
// 생산관리 > 생산계획&실적관리 (일반/장비) + 반제품·원자재 소요량.
// wace productionplanning.xml 1:1.
//
// 라우트:
// GET /api/production/plan-result/list (메뉴1 — 일반)
// GET /api/production/plan-result/list-equip (메뉴2 — 장비)
// GET /api/production/plan-result/project-options (검색 multiple — 프로젝트번호)
// GET /api/production/plan-result/writer-options (검색 — 등록자)
//
// GET /api/production/mbom-requirement/options (M-BOM 옵션 + 품명 매핑)
// POST /api/production/mbom-requirement/semi (메뉴3 — 반제품)
// POST /api/production/mbom-requirement/raw (메뉴4 — 원자재)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as ppr from "../services/prodPlanResultService";
import * as req from "../services/mbomRequirementService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): ppr.ProdPlanResultFilter {
const f: ppr.ProdPlanResultFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
// ─── 메뉴 1: 생산계획&실적관리 (일반) ─────────────────────────
export async function getList(reqMsg: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.listGeneral(parseFilter(reqMsg.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("생산계획&실적관리 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 메뉴 2: 생산계획&실적관리 (장비) ─────────────────────────
export async function getListEquip(reqMsg: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.listEquip(parseFilter(reqMsg.query as Record<string, any>));
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 getProjectOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.getProjectNoOptions();
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 getWriterOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.getWriterOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("등록자 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 메뉴 3 & 4: M-BOM 소요량 ─────────────────────────────────
export async function getMbomOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await req.getMbomOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("M-BOM 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getSemiRequirement(reqMsg: AuthenticatedRequest, res: Response) {
try {
const items = Array.isArray(reqMsg.body?.mbomItems) ? reqMsg.body.mbomItems : [];
const data = await req.getSemiRequirement(items);
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 getRawRequirement(reqMsg: AuthenticatedRequest, res: Response) {
try {
const items = Array.isArray(reqMsg.body?.mbomItems) ? reqMsg.body.mbomItems : [];
const data = await req.getRawRequirement(items);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("원자재 소요량 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,68 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드 컨트롤러 (purchaseService.ts orchestrator)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
const f: svc.PurchaseListFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
async function runList(
fn: (f: svc.PurchaseListFilter) => 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 });
}
}
export const getPurchaseRequest = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseRequest, req, res, "구매리스트관리");
export const getQuotationRequest = (req: AuthenticatedRequest, res: Response) => runList(svc.listQuotationRequest, req, res, "견적요청서관리");
export const getProposal = (req: AuthenticatedRequest, res: Response) => runList(svc.listProposal, req, res, "품의서관리");
export const getInbound = (req: AuthenticatedRequest, res: Response) => runList(svc.listInbound, req, res, "입고관리");
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 async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listSupplierOptions();
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 getUsers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listUserOptions();
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 getProjects(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listProjectOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("프로젝트 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -15,5 +15,8 @@ router.get("/detail/:objid", ctrl.getDetail);
router.get("/tree/:objid", ctrl.getTree);
router.post("/save", ctrl.save); // PR-B1 본 편집 저장
router.get("/history/:projectObjid", ctrl.getHistory); // PR-B4 변경이력 조회
router.post("/sales-request", ctrl.createSalesRequest); // PR-B3 구매리스트 생성
router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색
router.post("/assign", ctrl.assignBom); // PR-B5 BOM 할당
export default router;
@@ -0,0 +1,28 @@
// ============================================================
// 생산관리 > 생산계획&실적 + 소요량 라우트.
// app.ts:
// app.use("/api/production/plan-result", productionPlanResultRoutes)
// app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes)
// 각 라우트는 동일 컨트롤러를 공유하므로 한 파일에서 두 Router 를 export.
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/prodPlanResultController";
const planResultRouter = Router();
planResultRouter.use(authenticateToken);
planResultRouter.get("/list", ctrl.getList); // 메뉴1
planResultRouter.get("/list-equip", ctrl.getListEquip); // 메뉴2
planResultRouter.get("/project-options", ctrl.getProjectOptions); // 공용 검색 옵션
planResultRouter.get("/writer-options", ctrl.getWriterOptions); // 메뉴1 등록자
const mbomReqRouter = Router();
mbomReqRouter.use(authenticateToken);
mbomReqRouter.get("/options", ctrl.getMbomOptions); // 메뉴3+4 공용 M-BOM 옵션
mbomReqRouter.post("/semi", ctrl.getSemiRequirement); // 메뉴3 반제품
mbomReqRouter.post("/raw", ctrl.getRawRequirement); // 메뉴4 원자재
export { planResultRouter, mbomReqRouter };
+27
View File
@@ -0,0 +1,27 @@
// ============================================================
// 구매관리 — 7개 메뉴 + 공통 옵션 라우트.
// app.ts: app.use("/api/purchase", purchaseRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/purchaseController";
const router = Router();
router.use(authenticateToken);
// 그리드 7종
router.get("/purchase-request", ctrl.getPurchaseRequest); // 구매리스트관리
router.get("/quotation-request", ctrl.getQuotationRequest); // 견적요청서관리
router.get("/proposal", ctrl.getProposal); // 품의서관리
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("/options/suppliers", ctrl.getSuppliers);
router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects);
export default router;
@@ -0,0 +1,259 @@
// ============================================================
// 생산관리 > 반제품소요량 + 원자재소요량 — wace productionplanning.xml 1:1.
//
// 매퍼 매핑:
// getMbomListWithPartName → getMbomOptions() (4017)
// getMbomSemiProductItems → getSemiRequirement() (5252)
// getMbomRawMaterialItems → getRawRequirement() (5273) 1차 — 구매품
// getMbomRawSourceItems → getRawRequirement() (5298) 2차 — 원소재
//
// 서비스 로직(ProductionPlanningService.java:2030~2270) 의 LinkedHashMap 합산을
// 자바스크립트 Map 으로 1:1 이식.
// ============================================================
import { getPool } from "../database/db";
// ─── 입력 / 출력 ────────────────────────────────────────────
export interface MbomRequirementInputItem {
mbomObjid: string;
qty: number | string;
}
export interface SemiRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number;
}
export interface RawRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string; // '구매품' | '원소재'
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number | string;
RAW_MATERIAL: string;
RAW_MATERIAL_SIZE: string;
MATERIAL_PART_NO: string;
MATERIAL_REQUIRED_QTY: number | string;
}
function toInt(v: any): number {
if (v == null) return 0;
if (typeof v === "number") return Math.trunc(v);
const n = Number(String(v));
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
function toNum(v: any): number {
if (v == null) return 0;
if (typeof v === "number") return v;
const n = Number(String(v));
return Number.isFinite(n) ? n : 0;
}
// ─── M-BOM 옵션 조회 (셀렉트박스용 + 품명 매핑) ────────────────
export async function getMbomOptions(): Promise<Array<{ objid: string; mbom_no: string; part_name: string }>> {
const pool = getPool();
const r = await pool.query(`
SELECT OBJID::VARCHAR AS objid,
COALESCE(MBOM_NO, '') AS mbom_no,
COALESCE(PART_NAME, '') AS part_name
FROM MBOM_HEADER
WHERE STATUS = 'Y'
ORDER BY REGDATE DESC, MBOM_NO
`);
return r.rows;
}
// ─── 메뉴 3: 반제품 소요량 ──────────────────────────────────
//
// 매퍼: getMbomSemiProductItems (5252~5270)
// 조건: MBOM_DETAIL × PART_MNG, PART_TYPE IN ('0001812', '0001813'), 1레벨 제외.
// 자바 서비스: 동일 PART_NO 합산 (입력수량 × 항목수량).
export async function getSemiRequirement(items: MbomRequirementInputItem[]): Promise<SemiRequirementRow[]> {
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
// PART_NO 기준 LinkedHashMap (자바 LinkedHashMap 동일 보장)
const partMap = new Map<string, SemiRequirementRow>();
for (const it of items) {
const mbomObjid = String(it?.mbomObjid ?? "").trim();
const inputQty = toInt(it?.qty);
if (!mbomObjid || inputQty <= 0) continue;
const r = await pool.query(
`
SELECT
MD.PART_NO,
MD.PART_NAME,
COALESCE(NULLIF(MD.QTY, '')::INTEGER, 1) AS ITEM_QTY,
P.PART_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = P.PART_TYPE LIMIT 1), '') AS CATEGORY_NAME,
COALESCE(P.UNIT, '') AS UNIT,
COALESCE(P.MATERIAL, '') AS MATERIAL,
COALESCE(P.SPEC, '') AS SPEC
FROM MBOM_DETAIL MD
INNER JOIN PART_MNG P ON P.OBJID::VARCHAR = MD.PART_OBJID
WHERE MD.MBOM_HEADER_OBJID = $1
AND MD.STATUS = 'ACTIVE'
AND (MD.PARENT_OBJID IS NOT NULL AND MD.PARENT_OBJID != '')
AND P.PART_TYPE IN ('0001812', '0001813')
ORDER BY P.PART_TYPE, MD.PART_NO
`,
[mbomObjid],
);
for (const row of r.rows) {
const partNo = String(row.part_no ?? "").trim();
if (!partNo) continue;
const itemQty = toInt(row.item_qty);
const required = inputQty * itemQty;
const existing = partMap.get(partNo);
if (existing) {
existing.REQUIRED_QTY += required;
} else {
partMap.set(partNo, {
PART_NO: partNo,
PART_NAME: row.part_name ?? "",
CATEGORY_NAME: row.category_name ?? "",
UNIT: row.unit ?? "",
MATERIAL: row.material ?? "",
SPEC: row.spec ?? "",
REQUIRED_QTY: required,
});
}
}
}
return Array.from(partMap.values());
}
// ─── 메뉴 4: 원자재 소요량 ──────────────────────────────────
//
// 매퍼: getMbomRawMaterialItems (5273~5295) + getMbomRawSourceItems (5298~5318)
// 자바 서비스: 구매품/원소재 두 LinkedHashMap. 원소재는 소수점 합산 후 올림.
export async function getRawRequirement(items: MbomRequirementInputItem[]): Promise<RawRequirementRow[]> {
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
// 운영판 1:1 — 두 갈래 LinkedHashMap (구매품 / 원소재)
const purchaseMap = new Map<string, RawRequirementRow>();
// 원소재는 소수점 합산을 위해 임시 number 보관 후 마지막에 올림 처리
const rawSourceMap = new Map<string, RawRequirementRow & { __rawSum: number }>();
for (const it of items) {
const mbomObjid = String(it?.mbomObjid ?? "").trim();
const inputQty = toInt(it?.qty);
if (!mbomObjid || inputQty <= 0) continue;
// 1) 구매품 (PART_TYPE = '0000063')
const r1 = await pool.query(
`
SELECT
MD.PART_NO,
MD.PART_NAME,
COALESCE(NULLIF(MD.QTY, '')::INTEGER, 1) AS ITEM_QTY,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = P.PART_TYPE LIMIT 1), '') AS CATEGORY_NAME,
COALESCE(P.UNIT, '') AS UNIT
FROM MBOM_DETAIL MD
INNER JOIN PART_MNG P ON P.OBJID::VARCHAR = MD.PART_OBJID
WHERE MD.MBOM_HEADER_OBJID = $1
AND MD.STATUS = 'ACTIVE'
AND (MD.PARENT_OBJID IS NOT NULL AND MD.PARENT_OBJID != '')
AND P.PART_TYPE = '0000063'
ORDER BY MD.PART_NO
`,
[mbomObjid],
);
for (const row of r1.rows) {
const partNo = String(row.part_no ?? "").trim();
if (!partNo) continue;
const itemQty = toInt(row.item_qty);
const required = inputQty * itemQty;
const existing = purchaseMap.get(partNo);
if (existing) {
existing.REQUIRED_QTY = toInt(existing.REQUIRED_QTY) + required;
} else {
purchaseMap.set(partNo, {
PART_NO: partNo,
PART_NAME: row.part_name ?? "",
CATEGORY_NAME: row.category_name ?? "",
UNIT: row.unit ?? "",
MATERIAL: "",
SPEC: "",
REQUIRED_QTY: required,
RAW_MATERIAL: "",
RAW_MATERIAL_SIZE: "",
MATERIAL_PART_NO: "",
MATERIAL_REQUIRED_QTY: "",
});
}
}
// 2) 원소재 (RAW_MATERIAL_PART_NO 가 있는 항목)
const r2 = await pool.query(
`
SELECT
MD.RAW_MATERIAL_PART_NO AS PART_NO,
MD.RAW_MATERIAL AS PART_NAME,
COALESCE(NULLIF(MD.REQUIRED_QTY, '')::NUMERIC, 0) AS ITEM_QTY,
MD.RAW_MATERIAL,
MD.RAW_MATERIAL_SIZE
FROM MBOM_DETAIL MD
WHERE MD.MBOM_HEADER_OBJID = $1
AND MD.STATUS = 'ACTIVE'
AND MD.RAW_MATERIAL_PART_NO IS NOT NULL
AND MD.RAW_MATERIAL_PART_NO != ''
ORDER BY MD.RAW_MATERIAL_PART_NO
`,
[mbomObjid],
);
for (const row of r2.rows) {
const materialPartNo = String(row.part_no ?? "").trim();
if (!materialPartNo) continue;
const itemQty = toNum(row.item_qty);
const required = inputQty * itemQty;
const existing = rawSourceMap.get(materialPartNo);
if (existing) {
existing.__rawSum += required;
} else {
rawSourceMap.set(materialPartNo, {
PART_NO: "",
PART_NAME: "",
CATEGORY_NAME: "원소재",
UNIT: "",
MATERIAL: row.raw_material ?? "",
SPEC: row.raw_material_size ?? "",
REQUIRED_QTY: "",
RAW_MATERIAL: row.raw_material ?? "",
RAW_MATERIAL_SIZE: row.raw_material_size ?? "",
MATERIAL_PART_NO: materialPartNo,
MATERIAL_REQUIRED_QTY: "",
__rawSum: required,
});
}
}
}
// 구매품 먼저, 원소재(올림) 뒤
const result: RawRequirementRow[] = [];
for (const v of purchaseMap.values()) result.push(v);
for (const v of rawSourceMap.values()) {
const ceilQty = Math.ceil(v.__rawSum);
const { __rawSum, ...rest } = v;
result.push({ ...rest, MATERIAL_REQUIRED_QTY: ceilQty });
}
return result;
}
+194
View File
@@ -1228,6 +1228,115 @@ export async function save(payload: MbomSavePayload, sessionUserId: string): Pro
}
}
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
//
// 매퍼 productionplanning.getEbomList (3221~3265) 1:1 — 할당 가능한 E-BOM 검색.
// 매퍼 productionplanning.saveBomAssignment (3545~3553) 1:1 — project_mgmt.source_bom_type/source_*_objid 업데이트.
//
// 운영판 흐름 (mBomEbomSelectPopup.do + assignEbomToMbom.do):
// 1) 사용자가 BOM 할당 다이얼로그 오픈 → E-BOM 검색 (part_bom_report)
// 2) 한 건 선택 → assign(projectObjid, 'EBOM', bomReportObjid) 호출
// 3) project_mgmt.source_bom_type='EBOM' + source_ebom_objid 저장
// 4) M-BOM 다이얼로그 재조회 → ASSIGNED_EBOM 트리 자동 표시
// (M-BOM 할당은 PR-B5 v2 — 우선 E-BOM 만)
export interface AssignableEbomFilter {
search_part_no?: string;
search_part_name?: string;
search_material?: string;
search_supplier?: string;
limit?: number;
}
export interface AssignableEbomRow {
objid: string;
product_cd: string | null;
product_name: string | null;
part_no: string | null;
part_name: string | null;
status: string | null;
revision: string | null;
reg_date: string | null;
writer_name: string | null;
dept_name: string | null;
material: string | null;
supplier: string | null;
}
export async function searchAssignableEboms(filter: AssignableEbomFilter): Promise<AssignableEbomRow[]> {
const pool = getPool();
const conds: string[] = [];
const params: any[] = [];
let idx = 1;
if (filter.search_part_no) {
conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_no);
}
if (filter.search_part_name) {
conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_name);
}
if (filter.search_material) {
conds.push(`UPPER(PM.MATERIAL) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_material);
}
if (filter.search_supplier) {
conds.push(`UPPER(PM.MAKER) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_supplier);
}
const limit = Math.min(500, Math.max(1, Number(filter.limit) || 100));
const sql = `
SELECT
T.OBJID::VARCHAR AS objid,
T.PRODUCT_CD AS product_cd,
COALESCE((SELECT CODE_NAME FROM COMM_CODE
WHERE CODE_ID = T.PRODUCT_CD LIMIT 1), '') AS product_name,
T.PART_NO AS part_no,
T.PART_NAME AS part_name,
T.STATUS AS status,
T.REVISION AS revision,
TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS reg_date,
UI.USER_NAME AS writer_name,
UI.DEPT_NAME AS dept_name,
COALESCE(PM.MATERIAL, '') AS material,
COALESCE(PM.MAKER, '') AS supplier
FROM PART_BOM_REPORT T
LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER
LEFT JOIN PART_MNG PM ON PM.PART_NO = T.PART_NO AND PM.STATUS = 'release'
WHERE 1=1
AND T.PART_NO IS NOT NULL AND TRIM(T.PART_NO) != ''
AND T.PART_NAME IS NOT NULL AND TRIM(T.PART_NAME) != ''
${conds.length ? "AND " + conds.join(" AND ") : ""}
ORDER BY T.REGDATE DESC
LIMIT ${limit}
`;
const r = await pool.query(sql, params);
return r.rows;
}
export type AssignSourceType = "EBOM" | "MBOM";
export async function assignBom(
projectObjId: string,
sourceBomType: AssignSourceType,
sourceBomObjId: string,
_userId: string,
): Promise<{ success: boolean; source_bom_type: string; source_obj_id: string }> {
const pool = getPool();
// 매퍼 saveBomAssignment 1:1.
// EBOM 이면 source_ebom_objid, MBOM 이면 source_mbom_objid 만 세팅 (다른 쪽 NULL).
const sql = `
UPDATE PROJECT_MGMT
SET SOURCE_BOM_TYPE = $1,
SOURCE_EBOM_OBJID = CASE WHEN $1 = 'EBOM' THEN $2 ELSE NULL END,
SOURCE_MBOM_OBJID = CASE WHEN $1 = 'MBOM' THEN $2 ELSE NULL END
WHERE OBJID::VARCHAR = $3
`;
const r = await pool.query(sql, [sourceBomType, sourceBomObjId, projectObjId]);
if (r.rowCount === 0) throw new Error("프로젝트를 찾을 수 없습니다");
return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId };
}
// ─── 변경이력 조회 (PR-B4) ──────────────────────────────────
//
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
@@ -1270,6 +1379,91 @@ export async function getHistory(projectObjid: string): Promise<MbomHistoryRow[]
return r.rows;
}
// ─── 구매리스트 생성 (PR-B3) ────────────────────────────────
//
// wace 매퍼 salesMng.xml `getNextRequestMngNo` + `insertSalesRequestMasterFromMBom` 1:1
// (서비스 SalesMngService.createPurchaseListFromMBom, 매퍼 라인 3960~3997).
//
// 검증: M-BOM 헤더 존재 + 동일 mbom_header_objid 로 이미 생성된 sales_request_master 없음.
// 입력: mbomHeaderObjid (mbom_header.objid), projectMgmtObjid (project_mgmt.objid).
// 처리: 단일 INSERT, BEGIN/COMMIT 트랜잭션. mbom_header 역링크 컬럼 없음 (운영 동일).
export interface CreateSalesRequestResult {
objid: string;
request_mng_no: string;
mbom_header_objid: string;
}
export async function createSalesRequest(
mbomHeaderObjid: string,
projectMgmtObjid: string,
sessionUserId: string,
): Promise<CreateSalesRequestResult> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const mbom = String(mbomHeaderObjid ?? "").trim();
const proj = String(projectMgmtObjid ?? "").trim();
if (!mbom) throw new Error("MBOM_HEADER_OBJID 누락");
if (!proj) throw new Error("PROJECT_MGMT_OBJID 누락");
const userId = String(sessionUserId ?? "").trim() || "system";
// 1) M-BOM 헤더 존재 확인
const mh = await client.query(
`SELECT OBJID FROM MBOM_HEADER WHERE OBJID::VARCHAR = $1 AND STATUS = 'Y' LIMIT 1`,
[mbom],
);
if (!mh.rows[0]) throw new Error("저장된 M-BOM 헤더를 찾을 수 없습니다");
// 2) 동일 MBOM_HEADER 로 이미 생성된 구매리스트 차단 (운영판 sweetalert 가드 1:1)
const dup = await client.query(
`SELECT OBJID, REQUEST_MNG_NO FROM SALES_REQUEST_MASTER
WHERE MBOM_HEADER_OBJID = $1 LIMIT 1`,
[mbom],
);
if (dup.rows[0]) {
throw new Error(`이미 생성된 구매리스트가 있습니다 (${dup.rows[0].request_mng_no})`);
}
// 3) 채번: R + YYYYMMDD + - + 3자리 (운영 getNextRequestMngNo 1:1)
const seqRes = await client.query(
`SELECT 'R' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' ||
LPAD(
(COALESCE(MAX(SUBSTR(REQUEST_MNG_NO, 11, 13)), '0')::INTEGER + 1)::TEXT,
3, '0'
) AS request_mng_no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PURCHASE_REQUEST', 'PURCHASE_REG') OR DOC_TYPE IS NULL`,
);
const requestMngNo: string = seqRes.rows[0]?.request_mng_no;
if (!requestMngNo) throw new Error("REQUEST_MNG_NO 채번 실패");
// 4) INSERT (운영 insertSalesRequestMasterFromMBom 1:1, PROJECT_NO 컬럼에 PROJECT_MGMT.OBJID 저장)
const newObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_MASTER (
OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID,
REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE
) VALUES ($1, $2, $3, $4, $5, 'create', $6, NOW(), 'PURCHASE_REQUEST')`,
[newObjid, requestMngNo, proj, mbom, userId, userId],
);
await client.query("COMMIT");
return {
objid: newObjid,
request_mng_no: requestMngNo,
mbom_header_objid: mbom,
};
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
async function insertHistory(
client: any,
mbomHeaderObjid: string,
@@ -0,0 +1,400 @@
// ============================================================
// 생산관리 > 생산계획&실적관리 + 생산계획&실적관리(장비) — wace productionplanning.xml 1:1.
//
// 매퍼 매핑:
// prodPlanResultMgmtGridList → listGeneral() (productionplanning.xml:4550)
// prodPlanResultMgmtEquipGridList → listEquip() (productionplanning.xml:4887)
//
// 두 메뉴는 동일 베이스(PROJECT_MGMT + CONTRACT_MGMT + CONTRACT_ITEM + PRODUCTION_PLAN +
// PRODUCTION_RESULT 합산) 위에서 PRODUCT 코드(0000928=장비) 기준으로 갈라진다.
// ============================================================
import { getPool } from "../database/db";
// ─── 필터 ────────────────────────────────────────────────────
export interface ProdPlanResultFilter {
search_project_nos?: string; // "OBJID,OBJID,..." (multiple select)
search_product_code?: string;
search_category_code?: string;
search_production_type?: string;
search_customer_objid?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
search_part_no?: string;
search_part_name?: string;
search_serial_no?: string;
search_writer?: string;
search_regdate_from?: string;
search_regdate_to?: string;
page?: number;
page_size?: number;
}
function paginate(filter: { page?: number; page_size?: number }) {
const page = Math.max(1, Number(filter.page) || 1);
const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 공통 WHERE 빌더 (매퍼 <if> 1:1) ──────────────────────────
//
// 매퍼: 4684~4741 / 4985~5022. 두 메뉴 동일.
// (Equip 은 search_writer / search_regdate 없지만, 빌더는 동일 — 안 쓰면 됨.)
function buildWhere(filter: ProdPlanResultFilter, startIdx: number, includeSerial: boolean) {
const params: any[] = [];
const conds: string[] = [];
let idx = startIdx;
if (filter.search_project_nos) {
const ids = filter.search_project_nos.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
const placeholders = ids.map(() => `$${idx++}`).join(", ");
conds.push(`T.OBJID::VARCHAR IN (${placeholders})`);
params.push(...ids);
}
}
if (filter.search_product_code) {
conds.push(`T.PRODUCT = $${idx++}`);
params.push(filter.search_product_code);
}
if (filter.search_category_code) {
conds.push(`T.CATEGORY_CODE = $${idx++}`);
params.push(filter.search_category_code);
}
if (filter.search_production_type) {
conds.push(`T.PRODUCTION_TYPE = $${idx++}`);
params.push(filter.search_production_type);
}
if (filter.search_customer_objid) {
conds.push(
`(T.CUSTOMER_OBJID = $${idx} OR T.CUSTOMER_OBJID = REPLACE($${idx}, 'C_', '') OR REPLACE(T.CUSTOMER_OBJID, 'C_', '') = REPLACE($${idx}, 'C_', ''))`
);
params.push(filter.search_customer_objid);
idx++;
}
if (filter.search_req_del_date_from) {
conds.push(`T.REQ_DEL_DATE >= $${idx++}`);
params.push(filter.search_req_del_date_from);
}
if (filter.search_req_del_date_to) {
conds.push(`T.REQ_DEL_DATE <= $${idx++}`);
params.push(filter.search_req_del_date_to);
}
if (filter.search_part_no) {
conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_no);
}
if (filter.search_part_name) {
conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_name);
}
if (filter.search_serial_no) {
// 일반: SERIAL_NO_LIST 전체 매칭 / 장비: SERIAL_NO 단일 (둘 다 동일 컬럼 alias 로 처리)
const col = includeSerial ? "T.SERIAL_NO_LIST" : "T.SERIAL_NO";
conds.push(`UPPER(${col}) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_serial_no);
}
if (filter.search_writer) {
conds.push(`T.WRITER = $${idx++}`);
params.push(filter.search_writer);
}
if (filter.search_regdate_from) {
conds.push(`T.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_regdate_from);
}
if (filter.search_regdate_to) {
conds.push(`T.REGDATE <= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_regdate_to);
}
return { sql: conds.length ? "AND " + conds.join(" AND ") : "", params };
}
// ─── 메뉴 1: 생산계획&실적관리 (일반) ─────────────────────────
//
// 매퍼 prodPlanResultMgmtGridList (4550~4743) 1:1.
// UNION ALL 두 갈래:
// 1) PROJECT_MGMT 기반 — PM.PRODUCT != '0000928' (장비 제외)
// 2) PRODUCTION_PLAN 단독 — PROJECT_OBJID 비어있는 독립 계획
const SQL_GENERAL_INNER = `
SELECT * FROM (
-- 1) 프로젝트 기반
SELECT
PM.OBJID::VARCHAR AS OBJID,
PM.PROJECT_NO,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.CATEGORY_CD AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), CM.CATEGORY_CD) AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
CM.CUSTOMER_OBJID,
COALESCE(
CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || 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,
''
) AS CUSTOMER_NAME,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
(SELECT CASE
WHEN COUNT(*) = 0 THEN ''
WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO)
ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL AND CIS.SERIAL_NO != '') AS SERIAL_NO,
(SELECT STRING_AGG(CIS.SERIAL_NO, ', ' ORDER BY CIS.SERIAL_NO)
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL AND CIS.SERIAL_NO != '') AS SERIAL_NO_LIST,
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0) AS QUANTITY,
COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS EXTRA_PROD_QTY,
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0)
+ COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS TOTAL_PROD_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'ASSEMBLY' AND PR.STATUS = 'active'), 0) AS ASSEMBLY_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'INSPECTION' AND PR.STATUS = 'active'), 0) AS INSPECTION_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'SHIP_WAIT' AND PR.STATUS = 'active'), 0) AS SHIP_WAIT_QTY,
PP.OBJID::VARCHAR AS PROD_PLAN_OBJID,
PM.REGDATE AS SORT_DATE,
PP.WRITER,
COALESCE(user_name(PP.WRITER), '') AS WRITER_NAME,
TO_CHAR(PP.REGDATE, 'YYYY-MM-DD') AS REGDATE_TITLE,
PP.REGDATE
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
) AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID::VARCHAR AND PP.STATUS = 'active'
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
AND (CM.PRODUCT IS NULL OR CM.PRODUCT != '0000928')
UNION ALL
-- 2) 프로젝트 없이 등록한 생산계획
SELECT
PP.OBJID::VARCHAR AS OBJID,
'' AS PROJECT_NO,
PP.PRODUCT_CODE AS PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCT_CODE LIMIT 1), '') AS PRODUCT_NAME,
PP.CATEGORY_CODE AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.CATEGORY_CODE LIMIT 1), '') AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
PP.CUSTOMER_OBJID,
COALESCE(
CASE WHEN PP.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || C.OBJID::VARCHAR = PP.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = PP.CUSTOMER_OBJID LIMIT 1) END,
''
) AS CUSTOMER_NAME,
PP.REQ_DEL_DATE,
COALESCE(PP.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(PP.PART_NO, '') AS PART_NO,
COALESCE(PP.PART_NAME, '') AS PART_NAME,
COALESCE(PP.SERIAL_NO, '') AS SERIAL_NO,
COALESCE(PP.SERIAL_NO, '') AS SERIAL_NO_LIST,
COALESCE(NULLIF(PP.ORDER_QTY, '')::numeric, 0) AS QUANTITY,
COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS EXTRA_PROD_QTY,
COALESCE(NULLIF(PP.TOTAL_PROD_QTY, '')::numeric, 0) AS TOTAL_PROD_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'ASSEMBLY' AND PR.STATUS = 'active'), 0) AS ASSEMBLY_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'INSPECTION' AND PR.STATUS = 'active'), 0) AS INSPECTION_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'SHIP_WAIT' AND PR.STATUS = 'active'), 0) AS SHIP_WAIT_QTY,
PP.OBJID::VARCHAR AS PROD_PLAN_OBJID,
PP.REGDATE AS SORT_DATE,
PP.WRITER,
COALESCE(user_name(PP.WRITER), '') AS WRITER_NAME,
TO_CHAR(PP.REGDATE, 'YYYY-MM-DD') AS REGDATE_TITLE,
PP.REGDATE
FROM PRODUCTION_PLAN PP
WHERE PP.STATUS = 'active'
AND (PP.PROJECT_OBJID IS NULL OR PP.PROJECT_OBJID = '')
) T
WHERE 1=1
`;
export async function listGeneral(filter: ProdPlanResultFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1, true);
const pool = getPool();
const dataSql = `
SELECT * FROM (${SQL_GENERAL_INNER} ${where.sql}) X
ORDER BY X.SORT_DATE DESC NULLS LAST, X.PROJECT_NO DESC
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM (${SQL_GENERAL_INNER} ${where.sql}) X`;
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, [...where.params, limit, offset]),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 메뉴 2: 생산계획&실적관리(장비) ────────────────────────
//
// 매퍼 prodPlanResultMgmtEquipGridList (4887~5024) 1:1.
// PM.PRODUCT = '0000928' (장비) 만. PMS_WBS_TASK 기반 진척율.
// (운영판 CONTRACT_ITEM 조인 중복 라인은 정리 — 동일 ON 조건이 2번 들어있음)
const SQL_EQUIP_INNER = `
SELECT * FROM (
SELECT
PM.OBJID::VARCHAR AS OBJID,
PM.PROJECT_NO,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.CATEGORY_CD AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), CM.CATEGORY_CD) AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
CM.CUSTOMER_OBJID,
COALESCE(
CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || 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,
''
) AS CUSTOMER_NAME,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
(SELECT CASE
WHEN COUNT(*) = 0 THEN ''
WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO)
ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID = CI.OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL) AS SERIAL_NO,
-- 생산WBS 건수 (WBS_TYPE='PRODUCE')
(SELECT COUNT(1)::int FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE') AS PROD_WBS_CNT,
-- 생산진척율: Level1 PROGRESS 평균
CASE
WHEN (SELECT COUNT(1) FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE'
AND O.TASK_LEVEL = '1') = 0 THEN 0
ELSE ROUND(
(SELECT COALESCE(AVG(CASE WHEN O.PROGRESS IS NOT NULL AND O.PROGRESS != ''
THEN CAST(O.PROGRESS AS numeric) ELSE 0 END), 0)
FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE'
AND O.TASK_LEVEL = '1'), 1)
END AS PROD_PROGRESS_RATE,
-- 납품WBS 건수 (WBS_TYPE='SHIP')
(SELECT COUNT(1)::int FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP') AS DELV_WBS_CNT,
-- 납품진척율: Level1 PROGRESS 평균
CASE
WHEN (SELECT COUNT(1) FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP'
AND O.TASK_LEVEL = '1') = 0 THEN 0
ELSE ROUND(
(SELECT COALESCE(AVG(CASE WHEN O.PROGRESS IS NOT NULL AND O.PROGRESS != ''
THEN CAST(O.PROGRESS AS numeric) ELSE 0 END), 0)
FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP'
AND O.TASK_LEVEL = '1'), 1)
END AS DELV_PROGRESS_RATE,
PM.REGDATE AS SORT_DATE
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
) AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID::VARCHAR AND PP.STATUS = 'active'
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
AND CM.PRODUCT = '0000928'
) T
WHERE 1=1
`;
export async function listEquip(filter: ProdPlanResultFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1, false);
const pool = getPool();
const dataSql = `
SELECT * FROM (${SQL_EQUIP_INNER} ${where.sql}) X
ORDER BY X.SORT_DATE DESC NULLS LAST, X.PROJECT_NO DESC
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM (${SQL_EQUIP_INNER} ${where.sql}) X`;
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, [...where.params, limit, offset]),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 검색 옵션 — 프로젝트번호 / 등록자 ──────────────────────
//
// 운영판 페이지 진입 시 모델에 담아주던 code_map.project_no / code_map.writer 대체.
export async function getProjectNoOptions() {
const pool = getPool();
const r = await pool.query(`
SELECT PM.OBJID::VARCHAR AS code, PM.PROJECT_NO AS label
FROM PROJECT_MGMT PM
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
ORDER BY PM.REGDATE DESC, PM.PROJECT_NO DESC
LIMIT 1000
`);
return r.rows;
}
export async function getWriterOptions() {
const pool = getPool();
// PRODUCTION_PLAN.WRITER 와 PROJECT_MGMT.REGISTER 모두 user_info 의 user_id.
// 4개 메뉴 중 메뉴1 의 search_writer 만 사용. 활성 등록자만.
const r = await pool.query(`
SELECT DISTINCT PP.WRITER AS code,
COALESCE(user_name(PP.WRITER), PP.WRITER) AS label
FROM PRODUCTION_PLAN PP
WHERE PP.WRITER IS NOT NULL AND PP.WRITER != ''
AND PP.STATUS = 'active'
ORDER BY label
`);
return r.rows;
}
@@ -0,0 +1,438 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드/옵션 서비스
//
// wace_plm 1:1 이식 베이스. 마스터 테이블만 RPS 에 존재 (2026-05-14 현재):
// ✓ sales_request_master / mbom_header / mbom_detail
// ✓ purchase_order_master (56 cols, 0 rows)
// ✓ client_mng / supply_mng / admin_supply_mng / part_mng / project_mgmt / contract_mgmt
//
// 누락 (운영DB 추출 후 신설 필요):
// ✗ sales_request_part / sales_request_detail
// ✗ quotation_request_master / quotation_received
// ✗ purchase_order_part
// ✗ arrival_plan
// ✗ inventory_mgmt / inventory_mgmt_in
// ✗ incoming_inspection / incoming_inspection_detail
//
// 정책: 누락 테이블 의존 SELECT 는 빈 그리드 + 콘솔 warning 으로 처리.
// 마스터 단독 데이터는 정상 노출 (구매리스트관리 / 품의서관리 / 발주서관리).
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface PurchaseListFilter {
year?: string;
customer_objid?: string;
customer_cd?: string;
project_no?: string;
part_no?: string;
part_name?: string;
part_spec?: string;
partner_objid?: string;
purchase_order_no?: string;
proposal_no?: string;
search_status?: string;
writer?: string;
request_user?: string;
purchase_type?: string;
part_type?: string;
product_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
close_status?: string;
sales_mng_user_id?: string;
regdate_start?: string;
regdate_end?: string;
receipt_date_start?: string;
receipt_date_end?: string;
delivery_start_date?: string;
delivery_end_date?: string;
reg_start_date?: string;
reg_end_date?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function clampPaging(filter: PurchaseListFilter): { limit: number; offset: number; page: number; pageSize: number } {
const page = Math.max(1, Number(filter.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(filter.page_size ?? 50)));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 1) 구매리스트관리 (wace salesMng.xml salesRequestMngRegList 매퍼 1:1 베이스) ──
//
// sales_request_master + (sales_request_part 누락 → PART_NO/PART_NAME 빈값) +
// project_mgmt + contract_mgmt + comm_code + client_mng (customer 분기).
//
// WHERE: doc_type = 'PURCHASE_REQUEST' (또는 NULL) + 동적 필터.
// ORDER: regdate DESC.
export async function listPurchaseRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [`(SRM.DOC_TYPE = 'PURCHASE_REQUEST' OR SRM.DOC_TYPE IS NULL)`];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.customer_cd) where.push(`SRM.CUSTOMER_OBJID = ${addParam(filter.customer_cd)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.request_user) where.push(`SRM.REQUEST_USER_ID = ${addParam(filter.request_user)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${addParam(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${addParam(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${addParam(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 ${addParam(`%${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 ${addParam(`%${filter.part_name}%`)})`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS request_mng_no,
SRM.DOC_TYPE AS doc_type,
SRM.STATUS AS status,
CASE SRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'approvalRequest' THEN '결재중'
WHEN 'approvalComplete' THEN '결재완료'
WHEN 'reject' THEN '반려'
WHEN 'release' THEN '진행중'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.ORDER_TYPE LIMIT 1), ''
) AS order_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_full,
CASE WHEN SRM.PAID_TYPE = 'paid' THEN '유상'
WHEN SRM.PAID_TYPE = 'free' THEN '무상'
ELSE COALESCE(SRM.PAID_TYPE, '')
END AS paid_type_name,
PM.PROJECT_NO AS project_number,
-- 고객사 (wace 동일 — 프로젝트.contract_mgmt.customer_objid 우선)
COALESCE(
(SELECT CM2.CLIENT_NM FROM CLIENT_MNG CM2
WHERE 'C_' || CM2.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
(SELECT SUPPLY_NAME FROM SUPPLY_MNG SM
WHERE SM.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
''
) AS customer_name,
-- 품번/품명 (MBOM_DETAIL → PART_MNG, 다중이면 "외 N건")
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,
(SELECT COUNT(DISTINCT PP.PART_NO)::int - 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) AS part_extra_count,
-- 견적요청서 존재여부 (quotation_request_master 누락 → 일괄 'N')
'N' AS has_quotation_request,
SRM.REQUEST_USER_ID AS request_user,
COALESCE(user_name(SRM.REQUEST_USER_ID), SRM.REQUEST_USER_ID, '') 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
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(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 CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${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("listPurchaseRequest 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 2) 견적요청서관리 (wace salesMng.xml quotationRequestList) ──
// quotation_request_master 누락 → 빈 그리드.
export async function listQuotationRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listQuotationRequest: quotation_request_master 테이블 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 3) 품의서관리 (wace salesMng.xml proposalMngList) ──
// sales_request_master.doc_type='PROPOSAL'.
// 결재 우선순위: AMR.STATUS > APPROVAL.APPR_STATUS > SRM.STATUS('create'→'등록중')
export async function listProposal(filter: PurchaseListFilter): 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 = 'PROPOSAL' OR SRM.DOC_TYPE = 'PURCHASE_REG_PROPOSAL')`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${addParam(`%${filter.proposal_no}%`)}`);
if (filter.project_no) where.push(`EXISTS (SELECT 1 FROM PROJECT_MGMT PMX WHERE PMX.OBJID::VARCHAR = SRM.PROJECT_NO AND PMX.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)})`);
if (filter.search_status) where.push(`SRM.STATUS = ${addParam(filter.search_status)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${addParam(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${addParam(filter.regdate_end)}::DATE`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${addParam(filter.purchase_type)}`);
if (filter.writer) where.push(`SRM.WRITER = ${addParam(filter.writer)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${addParam(filter.part_type)}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS proposal_no,
SRM.STATUS AS status,
-- AMARANTH_STATUS 컬럼 RPS 미존재 → NULL (wace 1순위 결재상태 우선순위 향후 보완)
NULL::text AS amaranth_status,
PM.PROJECT_NO AS project_number,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.ORDER_TYPE LIMIT 1), ''
) AS order_type_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,
(SELECT COUNT(DISTINCT PP.PART_NO)::int - 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) AS part_extra_count,
CASE SRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'approvalRequest' THEN '결재중'
WHEN 'approvalComplete' THEN '결재완료'
WHEN 'reject' THEN '반려'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.WRITER AS writer,
COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name,
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 ${addParam(limit)} OFFSET ${addParam(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("listProposal 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 4) 입고관리 (wace purchaseOrder.xml deliveryMngAcceptanceList) ──
// purchase_order_master + purchase_order_part(누락) + arrival_plan(누락).
// 누락 의존 — purchase_order_master 단독으로 빈 그리드 처리.
export async function listInbound(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInbound: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 5) 품목별 입고관리 (wace deliveryMngPartList) ──
export async function listInboundByItem(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByItem: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 6) 입고일별 입고관리 (wace purchaseCloseList) ──
export async function listInboundByDate(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByDate: arrival_plan / purchase_order_part 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 7) 프로젝트별 발주/입고 현황 (wace projectPurchaseDeliveryStatus) ──
// contract_mgmt + mbom_header/detail (전체수량/품목수) + purchase_order_master/part (발주현황) +
// arrival_plan (입고현황). 발주/입고는 누락 테이블 의존 → 0 표시.
export async function listProjectStatus(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
// wace 운영판 WHERE CTR.MAIL_SEND_DATE IS NOT NULL — RPS contract_mgmt 미존재 컬럼이라 생략 (전체 노출)
const where: string[] = [];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.customer_objid) where.push(`CTR.CUSTOMER_OBJID = ${addParam(filter.customer_objid)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.product_cd) where.push(`CTR.PRODUCT = ${addParam(filter.product_cd)}`);
if (filter.part_no) where.push(`PM.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`PM.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`);
if (filter.year) where.push(`EXTRACT(YEAR FROM PM.REGDATE) = ${addParam(Number(filter.year))}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
PM.OBJID AS objid,
PM.PROJECT_NO AS project_no,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CTR.PRODUCT LIMIT 1), ''
) AS product_name,
PM.PART_NO AS part_no,
PM.PART_NAME AS part_name,
COALESCE(
(SELECT CLIENT_NM FROM CLIENT_MNG CL
WHERE 'C_' || CL.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
(SELECT SUPPLY_NAME FROM SUPPLY_MNG SM
WHERE SM.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
''
) AS customer_name,
-- BOM 기준 (mbom_detail)
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_qty,
-- 발주/입고/미발주/미입고 — purchase_order_part / arrival_plan 누락이라 모두 0
0::int AS po_item_cnt,
0::numeric AS po_qty,
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_qty,
0::int AS dlv_item_cnt,
0::numeric AS dlv_qty,
0::int AS non_dlv_item_cnt,
0::numeric AS non_dlv_qty
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
ORDER BY PM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${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("listProjectStatus 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
export async function listSupplierOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, SUPPLY_NAME AS label
FROM SUPPLY_MNG
WHERE COALESCE(STATUS, 'active') IN ('active', '활성')
AND SUPPLY_NAME IS NOT NULL AND SUPPLY_NAME <> ''
ORDER BY SUPPLY_NAME`,
);
return r.rows;
} catch {
return [];
}
}
export async function listUserOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT USER_ID AS code,
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label
FROM USER_INFO
WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE')
AND USER_NAME IS NOT NULL AND USER_NAME <> ''
ORDER BY USER_NAME`,
);
return r.rows;
} catch {
return [];
}
}
export async function listProjectOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, PROJECT_NO AS label
FROM PROJECT_MGMT
WHERE PROJECT_NO IS NOT NULL AND PROJECT_NO <> ''
ORDER BY PROJECT_NO DESC
LIMIT 500`,
);
return r.rows;
} catch {
return [];
}
}