Files
wace_rps/backend-node/src/services/mbomRequirementService.ts
T
hjjeong b38f5957f2 구매관리 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>
2026-05-14 17:31:12 +09:00

260 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 생산관리 > 반제품소요량 + 원자재소요량 — 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;
}