구매관리 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
@@ -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;
}