Enhance Work Instruction Functionality with Material Overrides
- Added validation for `materialOverrides` in the `save` function of the work instruction controller, ensuring proper structure and required fields. - Implemented logic to handle the insertion and deletion of material input details based on the provided `materialOverrides`, maintaining data integrity during edits. - Introduced new routes for retrieving BOM tree and material overrides, enhancing the work instruction management process. - Updated the frontend to support new material mapping features, including a structured approach for handling BOM substitutes and material inputs. (TASK: ERP-node-090)
This commit is contained in:
@@ -280,12 +280,42 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
await ensureDetailRoutingColumn();
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId } = req.body;
|
||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides } = req.body;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
}
|
||||
|
||||
// ── materialOverrides 사전 검증 (옵셔널) ──
|
||||
// 형식: [{ itemNumber, routingVersionId, materials: [{ bomItemId, bomItemName?, bomQty, bomUnit?, routingDetailId, content?, isOverride?, originalBomItemId? }] }]
|
||||
// 비어있거나 materials 빈 배열이면 wi_* INSERT 자체를 건너뛰어 기존 마스터 폴백 동작 유지.
|
||||
if (materialOverrides != null && !Array.isArray(materialOverrides)) {
|
||||
return res.status(400).json({ success: false, message: "materialOverrides는 배열이어야 합니다" });
|
||||
}
|
||||
if (Array.isArray(materialOverrides)) {
|
||||
for (const ov of materialOverrides) {
|
||||
if (!ov || typeof ov !== "object") {
|
||||
return res.status(400).json({ success: false, message: "materialOverrides 항목 형식 오류" });
|
||||
}
|
||||
if (!Array.isArray(ov.materials)) continue; // materials 없으면 스킵 (해당 품목 건너뛴 효과)
|
||||
for (const m of ov.materials) {
|
||||
if (!m || typeof m !== "object") {
|
||||
return res.status(400).json({ success: false, message: "materialOverrides.materials 항목 형식 오류" });
|
||||
}
|
||||
if (!m.bomItemId || String(m.bomItemId).trim() === "") {
|
||||
return res.status(400).json({ success: false, message: "자재 품목(bomItemId)을 선택해주세요" });
|
||||
}
|
||||
if (!m.routingDetailId || String(m.routingDetailId).trim() === "") {
|
||||
return res.status(400).json({ success: false, message: "자재 투입 공정(routingDetailId)을 선택해주세요" });
|
||||
}
|
||||
const qtyNum = Number(m.bomQty);
|
||||
if (!Number.isFinite(qtyNum) || qtyNum <= 0) {
|
||||
return res.status(400).json({ success: false, message: "수량은 0보다 커야 합니다" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -356,6 +386,137 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
[String(totalQty), effectiveRouting, wiId]
|
||||
);
|
||||
|
||||
// ── materialOverrides 적용: wi_process_work_item + wi_process_work_item_detail INSERT ──
|
||||
// 정책:
|
||||
// - 수정(editId) 진입 시 이 작업지시의 기존 wi_* 자재(material_input) 데이터를 모두 삭제 후 재구축.
|
||||
// - materialOverrides가 빈 배열/없음이면 wi_* INSERT 자체를 안 함 → 마스터 process_work_item 폴백 그대로.
|
||||
// - 같은 (routingDetailId) 묶음마다 wi_process_work_item 1행을 만들고, 자재들은 detail_type='material_input'으로 묶음.
|
||||
if (Array.isArray(materialOverrides) && materialOverrides.length > 0) {
|
||||
// 1) 편집 모드: 기존 wi_* (material_input 한정) 삭제 — 마스터 체크리스트(검사/조건 등) 보존 위해 detail_type 한정 삭제 후, 빈 work_item은 따로 cleanup
|
||||
if (editId) {
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item_detail
|
||||
WHERE company_code = $1
|
||||
AND detail_type = 'material_input'
|
||||
AND wi_work_item_id IN (
|
||||
SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1
|
||||
)`,
|
||||
[companyCode, wiNo]
|
||||
);
|
||||
}
|
||||
|
||||
// 2) routingDetailId 단위 묶음 — 동일 공정 자재는 한 work_item 아래로
|
||||
for (const ov of materialOverrides) {
|
||||
if (!ov || !Array.isArray(ov.materials) || ov.materials.length === 0) continue;
|
||||
const byRouting = new Map<string, any[]>();
|
||||
for (const m of ov.materials) {
|
||||
const rdId = String(m.routingDetailId);
|
||||
if (!byRouting.has(rdId)) byRouting.set(rdId, []);
|
||||
byRouting.get(rdId)!.push(m);
|
||||
}
|
||||
|
||||
for (const [routingDetailId, mats] of byRouting.entries()) {
|
||||
// 동일 (work_instruction_no, routing_detail_id)에 자재투입 전용 work_item이 이미 있으면 재사용, 없으면 신규 INSERT.
|
||||
// title은 식별용 고정 라벨 '자재투입' 사용. 사용자 화면(공정작업기준)과 충돌하지 않도록 별도 work_phase('material').
|
||||
const existing = await client.query(
|
||||
`SELECT id FROM wi_process_work_item
|
||||
WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND work_phase = 'material'
|
||||
LIMIT 1`,
|
||||
[wiNo, routingDetailId, companyCode]
|
||||
);
|
||||
let wiWorkItemId: string;
|
||||
if (existing.rowCount && existing.rowCount > 0) {
|
||||
wiWorkItemId = existing.rows[0].id;
|
||||
// 이 work_item의 기존 material_input detail 정리 (편집 모드 위 1차 삭제와 중복돼도 무해)
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 AND detail_type = 'material_input'`,
|
||||
[wiWorkItemId, companyCode]
|
||||
);
|
||||
} else {
|
||||
const ins = await client.query(
|
||||
`INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[companyCode, wiNo, routingDetailId, "material", "자재투입", "N", 0, "작업지시 등록 시 매핑된 자재투입", null, userId]
|
||||
);
|
||||
wiWorkItemId = ins.rows[0].id;
|
||||
}
|
||||
|
||||
// 3) 자재 detail INSERT
|
||||
let sortOrder = 0;
|
||||
for (const m of mats) {
|
||||
sortOrder++;
|
||||
const qtyStr = String(Number(m.bomQty));
|
||||
const isOverride = m.isOverride === true || (m.originalBomItemId && String(m.originalBomItemId) !== String(m.bomItemId));
|
||||
const contentMemo = m.content
|
||||
? String(m.content)
|
||||
: isOverride
|
||||
? `대체${m.originalBomItemId ? `:${m.originalBomItemId}` : ""}`
|
||||
: "원본";
|
||||
await client.query(
|
||||
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
|
||||
[
|
||||
companyCode,
|
||||
wiWorkItemId,
|
||||
"material_input",
|
||||
contentMemo,
|
||||
"N",
|
||||
sortOrder,
|
||||
m.remark || null,
|
||||
null, null, null, null, null,
|
||||
null, null, null, null,
|
||||
null, null,
|
||||
null, null, null,
|
||||
null, null,
|
||||
String(m.bomItemId),
|
||||
m.bomItemName || null,
|
||||
qtyStr,
|
||||
m.bomUnit || null,
|
||||
null, null,
|
||||
null, null,
|
||||
null, null,
|
||||
null, null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) cleanup: material_input detail이 하나도 없는 work_phase='material' work_item 제거 (이번 페이로드에서 빠진 공정)
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item
|
||||
WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM wi_process_work_item_detail d
|
||||
WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1
|
||||
)`,
|
||||
[companyCode, wiNo]
|
||||
);
|
||||
} else if (editId) {
|
||||
// 편집 시 명시적으로 빈 materialOverrides가 들어오면 기존 자재 매핑 비움 (사용자가 다시 폴백으로 돌리려는 의도)
|
||||
if (Array.isArray(materialOverrides)) {
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item_detail
|
||||
WHERE company_code = $1
|
||||
AND detail_type = 'material_input'
|
||||
AND wi_work_item_id IN (
|
||||
SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1
|
||||
)`,
|
||||
[companyCode, wiNo]
|
||||
);
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item
|
||||
WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM wi_process_work_item_detail d
|
||||
WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1
|
||||
)`,
|
||||
[companyCode, wiNo]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
|
||||
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
|
||||
@@ -676,8 +837,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
|
||||
);
|
||||
|
||||
// 커스텀 작업기준이 있는지 확인
|
||||
// 주의: work_phase='material' 은 작업지시별 BOM 자재투입 매핑 전용 항목이므로 작업기준 화면 표시 대상에서 제외.
|
||||
const customCheck = await pool.query(
|
||||
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
|
||||
@@ -687,12 +849,12 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
|
||||
let workItems;
|
||||
|
||||
if (hasCustom) {
|
||||
// 커스텀 버전에서 조회
|
||||
// 커스텀 버전에서 조회 (work_phase='material'은 자재투입 매핑 전용이므로 제외)
|
||||
const wiResult = await pool.query(
|
||||
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
||||
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
||||
FROM wi_process_work_item wi
|
||||
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3
|
||||
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 AND COALESCE(wi.work_phase, '') <> 'material'
|
||||
ORDER BY wi.work_phase, wi.sort_order`,
|
||||
[wiNo, proc.routing_detail_id, companyCode]
|
||||
);
|
||||
@@ -853,9 +1015,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 커스텀 데이터 삭제
|
||||
// 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존)
|
||||
const existingItems = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
for (const row of existingItems.rows) {
|
||||
@@ -865,7 +1027,7 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
|
||||
@@ -936,9 +1098,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 해당 공정의 기존 커스텀 데이터 삭제
|
||||
// 해당 공정의 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존)
|
||||
const existing = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, routingDetailId, companyCode]
|
||||
);
|
||||
for (const row of existing.rows) {
|
||||
@@ -948,7 +1110,7 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, routingDetailId, companyCode]
|
||||
);
|
||||
|
||||
@@ -1009,8 +1171,9 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// 원본 초기화 — work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존
|
||||
const items = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
for (const row of items.rows) {
|
||||
@@ -1020,7 +1183,7 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
|
||||
@@ -1093,3 +1256,166 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response)
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용, TASK:ERP-node-090 트리화) ───
|
||||
// GET /bom-tree/:itemCode
|
||||
// itemCode → bom 헤더(현재 활성 버전) → bom_detail 전체를 parent_detail_id로 재귀 트리 구성하여 응답.
|
||||
// 응답: { success, hasBom, treeRoots: [{ ...node, children }] } — 평면 호환 필드도 포함(child_item_id, child_item_name, child_item_code, quantity, unit, item_unit).
|
||||
// 활성 버전이 없는 경우(미초기화 BOM): version_id IS NULL 인 detail을 폴백 조회 — 기존 process-info/bom-materials 동작과 정합.
|
||||
export async function getBomTree(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { itemCode } = req.params;
|
||||
if (!itemCode) {
|
||||
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
// 1) BOM 헤더 찾기 — bom.item_code 우선, 없으면 item_info.id 경유
|
||||
// 루트 노드(품목 자체)를 트리 0레벨로 표시하기 위해 item_info도 함께 조회 (BomTreeComponent 패턴 동일)
|
||||
const bomRes = await pool.query(
|
||||
`SELECT b.id AS bom_id, b.current_version_id, b.base_qty,
|
||||
COALESCE(b.item_code, i.item_number) AS root_item_code,
|
||||
i.id AS root_item_id,
|
||||
i.item_number AS root_item_number,
|
||||
i.item_name AS root_item_name,
|
||||
i.unit AS root_unit
|
||||
FROM bom b
|
||||
LEFT JOIN item_info i ON i.id = b.item_id AND i.company_code = b.company_code
|
||||
WHERE b.company_code = $1
|
||||
AND (b.item_code = $2 OR i.item_number = $2)
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode]
|
||||
);
|
||||
if (bomRes.rowCount === 0) {
|
||||
return res.json({ success: true, hasBom: false, treeRoots: [], rootItem: null });
|
||||
}
|
||||
const bomRow = bomRes.rows[0];
|
||||
const bomId = bomRow.bom_id;
|
||||
const versionId = bomRow.current_version_id || null;
|
||||
const rootItem = {
|
||||
itemId: bomRow.root_item_id || null,
|
||||
itemCode: bomRow.root_item_number || bomRow.root_item_code || itemCode,
|
||||
itemName: bomRow.root_item_name || "",
|
||||
baseQty: bomRow.base_qty || "1",
|
||||
unit: bomRow.root_unit || "",
|
||||
};
|
||||
|
||||
// 2) bom_detail 전체 조회 — 활성 버전 또는 version_id IS NULL 폴백
|
||||
const detailParams: any[] = [bomId, companyCode];
|
||||
let whereVersion = "AND bd.version_id IS NULL";
|
||||
if (versionId) {
|
||||
whereVersion = "AND bd.version_id = $3";
|
||||
detailParams.push(versionId);
|
||||
}
|
||||
const detailRes = await pool.query(
|
||||
`SELECT bd.id, bd.bom_id, bd.parent_detail_id, bd.child_item_id,
|
||||
bd.quantity, bd.unit AS detail_unit, bd.process_type,
|
||||
bd.level, bd.seq_no,
|
||||
ii.item_number AS child_item_code, ii.item_name AS child_item_name,
|
||||
ii.unit AS item_unit
|
||||
FROM bom_detail bd
|
||||
LEFT JOIN item_info ii ON ii.id = bd.child_item_id AND ii.company_code = bd.company_code
|
||||
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
|
||||
ORDER BY bd.parent_detail_id NULLS FIRST, NULLIF(bd.seq_no, '')::int NULLS LAST, bd.id`,
|
||||
detailParams
|
||||
);
|
||||
|
||||
// 3) 클라이언트가 트리 빌드도 가능하지만, 백엔드에서 재귀 빌드까지 해 응답 (회사 표준 패턴)
|
||||
type Node = Record<string, any> & { children: Node[] };
|
||||
const nodeMap = new Map<string, Node>();
|
||||
const roots: Node[] = [];
|
||||
for (const r of detailRes.rows) {
|
||||
nodeMap.set(r.id, { ...r, children: [] });
|
||||
}
|
||||
for (const r of detailRes.rows) {
|
||||
const node = nodeMap.get(r.id)!;
|
||||
if (r.parent_detail_id && nodeMap.has(r.parent_detail_id)) {
|
||||
nodeMap.get(r.parent_detail_id)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, hasBom: true, treeRoots: roots, rootItem });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 트리 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 작업지시 단위 자재투입 매핑 조회 (편집 모달 복원용) ───
|
||||
// GET /:wiNo/material-overrides
|
||||
// 응답: { success, data: [{ routingDetailId, processCode, processName, materials: [{ bomItemId, bomItemName, bomQty, bomUnit, content, isOverride, originalBomItemId }] }] }
|
||||
// work_phase='material' 인 wi_process_work_item과 그 하위 detail_type='material_input' detail만 묶어 반환.
|
||||
export async function getMaterialOverrides(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wiNo } = req.params;
|
||||
if (!wiNo) return res.status(400).json({ success: false, message: "wiNo 필요" });
|
||||
|
||||
const pool = getPool();
|
||||
const rows = await pool.query(
|
||||
`SELECT wi.id AS wi_work_item_id,
|
||||
wi.routing_detail_id,
|
||||
rd.process_code,
|
||||
COALESCE(p.process_name, rd.process_code, '') AS process_name,
|
||||
d.id AS detail_id,
|
||||
d.bom_item_id,
|
||||
d.bom_item_name,
|
||||
d.bom_qty,
|
||||
d.bom_unit,
|
||||
d.content,
|
||||
d.sort_order
|
||||
FROM wi_process_work_item wi
|
||||
LEFT JOIN wi_process_work_item_detail d
|
||||
ON d.wi_work_item_id = wi.id AND d.company_code = wi.company_code AND d.detail_type = 'material_input'
|
||||
LEFT JOIN item_routing_detail rd
|
||||
ON rd.id = wi.routing_detail_id AND rd.company_code = wi.company_code
|
||||
LEFT JOIN process_mng p
|
||||
ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
||||
WHERE wi.work_instruction_no = $1
|
||||
AND wi.company_code = $2
|
||||
AND wi.work_phase = 'material'
|
||||
ORDER BY wi.routing_detail_id, d.sort_order NULLS LAST, d.id`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
|
||||
// routingDetailId 단위로 그룹핑
|
||||
const byRouting = new Map<string, any>();
|
||||
for (const r of rows.rows) {
|
||||
const rdId = r.routing_detail_id;
|
||||
if (!rdId) continue;
|
||||
if (!byRouting.has(rdId)) {
|
||||
byRouting.set(rdId, {
|
||||
routingDetailId: rdId,
|
||||
processCode: r.process_code || "",
|
||||
processName: r.process_name || "",
|
||||
materials: [],
|
||||
});
|
||||
}
|
||||
if (r.detail_id) {
|
||||
const contentStr = String(r.content || "");
|
||||
const isOverride = contentStr.startsWith("대체");
|
||||
const originalBomItemId = isOverride && contentStr.includes(":")
|
||||
? contentStr.split(":", 2)[1] || null
|
||||
: null;
|
||||
byRouting.get(rdId).materials.push({
|
||||
detailId: r.detail_id,
|
||||
bomItemId: r.bom_item_id || "",
|
||||
bomItemName: r.bom_item_name || "",
|
||||
bomQty: r.bom_qty || "",
|
||||
bomUnit: r.bom_unit || "",
|
||||
content: contentStr,
|
||||
isOverride,
|
||||
originalBomItemId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: Array.from(byRouting.values()) });
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 자재투입 매핑 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk);
|
||||
// BOM 기준수 일괄 조회 (작업지시 등록 모달 기준수/배치수 산출용)
|
||||
router.post("/bom-base-qty", ctrl.getBomBaseQtyMap);
|
||||
|
||||
// BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용 — TASK:ERP-node-090 트리화)
|
||||
router.get("/bom-tree/:itemCode", ctrl.getBomTree);
|
||||
|
||||
// 라우팅 & 공정작업기준
|
||||
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
|
||||
router.put("/:wiNo/routing", ctrl.updateRouting);
|
||||
@@ -29,4 +32,7 @@ router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
|
||||
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
|
||||
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
|
||||
|
||||
// 작업지시별 자재투입(BOM 대체) 매핑 조회 — 편집 모달 복원용
|
||||
router.get("/:wiNo/material-overrides", ctrl.getMaterialOverrides);
|
||||
|
||||
export default router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,6 +191,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 3단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 카테고리 컬럼별 유효값 캐시 (검증 시 조회한 값을 라벨→코드 변환에 재사용)
|
||||
// key: 시스템 컬럼명, value: 해당 컬럼의 flatten 된 카테고리 값 목록
|
||||
const categoryValuesCacheRef = useRef<Record<string, FlatCategoryValue[]>>({});
|
||||
|
||||
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
|
||||
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
|
||||
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
|
||||
@@ -711,33 +715,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증
|
||||
const mismatches: typeof categoryMismatches = {};
|
||||
|
||||
// 카테고리 유효값 캐시 초기화 (이번 검증 기준으로 갱신)
|
||||
categoryValuesCacheRef.current = {};
|
||||
|
||||
for (const catCol of mappedCategoryColumns) {
|
||||
const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol);
|
||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||
|
||||
const validValues = flattenCategoryValues(valuesResponse.data as any[]);
|
||||
|
||||
// 라벨→코드 변환에 재사용하기 위해 캐시에 저장
|
||||
categoryValuesCacheRef.current[catCol.systemCol] = validValues;
|
||||
|
||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||
|
||||
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||
// 엑셀 데이터에서 유효하지 않은 값(토큰) 수집
|
||||
// 셀 값에 쉼표가 있으면 토큰별로 분리 검증 (쉼표 없으면 토큰 1개 → 기존 동작과 동일)
|
||||
const invalidMap = new Map<string, number[]>();
|
||||
|
||||
allData.forEach((row, rowIdx) => {
|
||||
const val = row[catCol.excelCol];
|
||||
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||
const strVal = String(val).trim();
|
||||
|
||||
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
|
||||
if (validCodes.has(strVal)) return;
|
||||
if (validLabels.has(strVal)) return;
|
||||
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||
const tokens = String(val)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!invalidMap.has(strVal)) {
|
||||
invalidMap.set(strVal, []);
|
||||
for (const token of tokens) {
|
||||
// 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭
|
||||
if (validCodes.has(token)) continue;
|
||||
if (validLabels.has(token)) continue;
|
||||
if (validLabelsLower.has(token.toLowerCase())) continue;
|
||||
|
||||
if (!invalidMap.has(token)) {
|
||||
invalidMap.set(token, []);
|
||||
}
|
||||
const rows = invalidMap.get(token)!;
|
||||
// 같은 토큰이 한 셀에 중복 등장해도 행 인덱스는 1번만 기록
|
||||
if (!rows.includes(rowIdx)) rows.push(rowIdx);
|
||||
}
|
||||
invalidMap.get(strVal)!.push(rowIdx);
|
||||
});
|
||||
|
||||
if (invalidMap.size > 0) {
|
||||
@@ -800,17 +819,35 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
const excelCol = systemToExcelMap.get(systemCol);
|
||||
if (!excelCol) continue;
|
||||
|
||||
// 이 컬럼에서 mismatch였던 토큰 → 대체 라벨 매핑 구축
|
||||
const tokenReplacementMap = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
if (!item.replacement) continue;
|
||||
// 선택된 대체값의 라벨 찾기
|
||||
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||
const replacementLabel = selectedOption?.label || item.replacement;
|
||||
tokenReplacementMap.set(item.invalidValue, replacementLabel);
|
||||
}
|
||||
|
||||
for (const rowIdx of item.rowIndices) {
|
||||
if (newData[rowIdx]) {
|
||||
newData[rowIdx][excelCol] = replacementLabel;
|
||||
}
|
||||
}
|
||||
// mismatch 토큰이 등장한 행들만 셀 값을 토큰 단위로 재구성
|
||||
const affectedRows = new Set<number>();
|
||||
for (const item of items) {
|
||||
for (const rowIdx of item.rowIndices) affectedRows.add(rowIdx);
|
||||
}
|
||||
|
||||
for (const rowIdx of affectedRows) {
|
||||
if (!newData[rowIdx]) continue;
|
||||
const cellVal = newData[rowIdx][excelCol];
|
||||
if (cellVal === undefined || cellVal === null || String(cellVal).trim() === "") continue;
|
||||
|
||||
// 셀을 토큰화 → mismatch였던 토큰만 치환, 유효 토큰은 유지 → 쉼표 join
|
||||
const replacedTokens = String(cellVal)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((token) => tokenReplacementMap.get(token) ?? token);
|
||||
|
||||
newData[rowIdx][excelCol] = replacedTokens.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,6 +1095,100 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑된 데이터의 카테고리 컬럼 값을 토큰별로 라벨→코드 변환
|
||||
// - 토큰이 이미 코드(validCodes)면 그대로 유지 (이중 변환 방지)
|
||||
// - 토큰이 라벨/소문자 라벨이면 대응 코드로 치환
|
||||
// - 멀티 카테고리(쉼표 구분)는 토큰별 변환 후 쉼표 join
|
||||
// - 검증 다이얼로그를 거치지 않은 경우에도 적용되도록 handleUpload 경로에 둠
|
||||
const convertCategoryLabelsToCodes = async (
|
||||
rows: Record<string, any>[]
|
||||
): Promise<Record<string, any>[]> => {
|
||||
try {
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
const targetTableName =
|
||||
isMasterDetail && masterDetailRelation
|
||||
? masterDetailRelation.detailTable
|
||||
: tableName;
|
||||
|
||||
// 대상 테이블의 카테고리 타입 컬럼 조회
|
||||
const colResponse = await getTableColumns(targetTableName);
|
||||
if (!colResponse.success || !colResponse.data?.columns) return rows;
|
||||
|
||||
const categoryColumns = colResponse.data.columns.filter(
|
||||
(col: any) => col.inputType === "category"
|
||||
);
|
||||
if (categoryColumns.length === 0) return rows;
|
||||
|
||||
// mappedData의 키는 시스템 컬럼명(rawName). 카테고리 컬럼명 집합 구성
|
||||
const categoryColNames = new Set<string>(
|
||||
categoryColumns.map((cc: any) => cc.columnName || cc.column_name)
|
||||
);
|
||||
|
||||
// 컬럼별 변환 맵 구축: 라벨/소문자라벨 → 코드, 코드 → 코드(통과용)
|
||||
const columnConvertMaps: Record<
|
||||
string,
|
||||
{ codeSet: Set<string>; labelToCode: Map<string, string> }
|
||||
> = {};
|
||||
|
||||
for (const colName of categoryColNames) {
|
||||
// 검증 시 캐싱해둔 값 우선 사용, 없으면 직접 조회
|
||||
let validValues = categoryValuesCacheRef.current[colName];
|
||||
if (!validValues) {
|
||||
const valuesResponse: any = await getCategoryValues(targetTableName, colName);
|
||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||
validValues = flattenCategoryValues(valuesResponse.data as any[]);
|
||||
categoryValuesCacheRef.current[colName] = validValues;
|
||||
}
|
||||
|
||||
const codeSet = new Set(validValues.map((v) => v.valueCode));
|
||||
const labelToCode = new Map<string, string>();
|
||||
for (const v of validValues) {
|
||||
// 라벨 및 소문자 라벨 모두 등록 (소문자 라벨 매칭 대응)
|
||||
if (!labelToCode.has(v.valueLabel)) labelToCode.set(v.valueLabel, v.valueCode);
|
||||
const lower = v.valueLabel.toLowerCase();
|
||||
if (!labelToCode.has(lower)) labelToCode.set(lower, v.valueCode);
|
||||
}
|
||||
columnConvertMaps[colName] = { codeSet, labelToCode };
|
||||
}
|
||||
|
||||
// 각 행의 카테고리 컬럼 값을 토큰별 변환
|
||||
return rows.map((row) => {
|
||||
const newRow = { ...row };
|
||||
for (const colName of categoryColNames) {
|
||||
const convertMap = columnConvertMaps[colName];
|
||||
if (!convertMap) continue;
|
||||
|
||||
const val = newRow[colName];
|
||||
if (val === undefined || val === null || String(val).trim() === "") continue;
|
||||
|
||||
const convertedTokens = String(val)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
// 이미 코드면 그대로 (이중 변환 방지)
|
||||
if (convertMap.codeSet.has(token)) return token;
|
||||
// 라벨 → 코드
|
||||
const byLabel = convertMap.labelToCode.get(token);
|
||||
if (byLabel) return byLabel;
|
||||
// 소문자 라벨 → 코드
|
||||
const byLower = convertMap.labelToCode.get(token.toLowerCase());
|
||||
if (byLower) return byLower;
|
||||
// 매칭 실패 토큰은 원본 유지 (검증을 통과한 데이터이므로 정상적으론 도달 안 함)
|
||||
return token;
|
||||
});
|
||||
|
||||
newRow[colName] = convertedTokens.join(",");
|
||||
}
|
||||
return newRow;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨→코드 변환 실패 (원본 유지):", error);
|
||||
return rows;
|
||||
}
|
||||
};
|
||||
|
||||
// 업로드 핸들러
|
||||
const handleUpload = async () => {
|
||||
if (!file || !tableName) {
|
||||
@@ -1085,7 +1216,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
});
|
||||
|
||||
// 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외
|
||||
const filteredData = mappedData.filter((row) => {
|
||||
let filteredData = mappedData.filter((row) => {
|
||||
const values = Object.values(row);
|
||||
return values.some((value) => {
|
||||
if (value === undefined || value === null) return false;
|
||||
@@ -1098,6 +1229,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||
);
|
||||
|
||||
// 카테고리 컬럼 라벨→코드 변환 (멀티 카테고리는 토큰별 변환 후 쉼표 join)
|
||||
// 검증 다이얼로그 통과 여부와 무관하게 저장 직전에 적용
|
||||
filteredData = await convertCategoryLabelsToCodes(filteredData);
|
||||
|
||||
// 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지)
|
||||
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||
// 마스터 테이블에서 채번 컬럼 자동 감지
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface PaginatedResponse { success: boolean; data: any[]; totalCount: number; page: number; pageSize: number; }
|
||||
export interface PaginatedResponse {
|
||||
success: boolean;
|
||||
data: any[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export async function getWorkInstructionList(params?: Record<string, any>) {
|
||||
const res = await apiClient.get("/work-instruction/list", { params });
|
||||
@@ -187,3 +193,102 @@ export async function resetWIWorkStandard(wiNo: string) {
|
||||
const res = await apiClient.delete(`/work-instruction/${wiNo}/work-standard/reset`);
|
||||
return res.data as { success: boolean };
|
||||
}
|
||||
|
||||
// ─── BOM 대체품 / 작업지시 단위 자재투입 매핑 (TASK:ERP-node-090) ───
|
||||
|
||||
// BOM 자재 행 (작업지시 등록 모달에서 펼침)
|
||||
export interface WIBomMaterial {
|
||||
id: string; // bom_detail.id
|
||||
child_item_id: string | null; // item_info.id (자재 품목 id)
|
||||
child_item_code?: string | null;
|
||||
child_item_name?: string | null;
|
||||
quantity?: string | number | null;
|
||||
detail_unit?: string | null;
|
||||
item_unit?: string | null;
|
||||
process_type?: string | null;
|
||||
}
|
||||
|
||||
// 품목 코드(item_code)로 그 품목의 BOM 자재 목록을 조회 (단일 레벨)
|
||||
// process-info의 /process-info/bom-materials/:itemCode 라우트 재활용
|
||||
export async function getWIBomMaterials(itemCode: string) {
|
||||
const res = await apiClient.get(`/process-info/bom-materials/${encodeURIComponent(itemCode)}`);
|
||||
return res.data as { success: boolean; data: WIBomMaterial[] };
|
||||
}
|
||||
|
||||
// BOM 트리 조회 — 다중 레벨 자재를 재귀 트리로 반환 (TASK:ERP-node-090 트리화)
|
||||
// 활성 버전(current_version_id) 기준. 미초기화 BOM은 version_id IS NULL 폴백.
|
||||
export interface WIBomTreeNode {
|
||||
id: string; // bom_detail.id
|
||||
bom_id: string;
|
||||
parent_detail_id: string | null;
|
||||
child_item_id: string | null;
|
||||
child_item_code?: string | null;
|
||||
child_item_name?: string | null;
|
||||
quantity?: string | number | null;
|
||||
detail_unit?: string | null;
|
||||
item_unit?: string | null;
|
||||
process_type?: string | null;
|
||||
level?: number | string | null;
|
||||
seq_no?: string | null;
|
||||
children: WIBomTreeNode[];
|
||||
}
|
||||
// 루트 노드(품목 자체) 정보 — BomTreeComponent와 동일하게 0레벨로 표시
|
||||
export interface WIBomTreeRootItem {
|
||||
itemId: string | null;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
baseQty: string | number | null;
|
||||
unit: string;
|
||||
}
|
||||
export async function getWIBomTree(itemCode: string) {
|
||||
const res = await apiClient.get(`/work-instruction/bom-tree/${encodeURIComponent(itemCode)}`);
|
||||
return res.data as {
|
||||
success: boolean;
|
||||
hasBom: boolean;
|
||||
treeRoots: WIBomTreeNode[];
|
||||
rootItem: WIBomTreeRootItem | null;
|
||||
};
|
||||
}
|
||||
|
||||
// bom_detail.id 단위 대체품 후보 조회 (마스터 bom_detail_substitute)
|
||||
export interface WIBomSubstitute {
|
||||
id: string;
|
||||
bom_detail_id: string;
|
||||
substitute_item_id: string;
|
||||
substitute_item_name?: string | null;
|
||||
substitute_item_number?: string | null;
|
||||
substitute_unit?: string | null;
|
||||
priority?: number | string | null;
|
||||
ratio?: number | string | null;
|
||||
status?: string | null;
|
||||
}
|
||||
export async function getWIBomSubstitutes(detailId: string) {
|
||||
const res = await apiClient.get(`/bom/details/${encodeURIComponent(detailId)}/substitutes`);
|
||||
return res.data as { success: boolean; data: WIBomSubstitute[] };
|
||||
}
|
||||
|
||||
// 작업지시별 자재투입 매핑 조회 (편집 복원용)
|
||||
export interface WIMaterialOverrideMaterial {
|
||||
detailId: string;
|
||||
bomItemId: string;
|
||||
bomItemName: string;
|
||||
bomQty: string;
|
||||
bomUnit: string;
|
||||
content: string;
|
||||
isOverride: boolean;
|
||||
originalBomItemId: string | null;
|
||||
}
|
||||
export interface WIMaterialOverrideRouting {
|
||||
routingDetailId: string;
|
||||
processCode: string;
|
||||
processName: string;
|
||||
materials: WIMaterialOverrideMaterial[];
|
||||
}
|
||||
export async function getWIMaterialOverrides(wiNo: string) {
|
||||
const res = await apiClient.get(`/work-instruction/${encodeURIComponent(wiNo)}/material-overrides`);
|
||||
return res.data as { success: boolean; data: WIMaterialOverrideRouting[] };
|
||||
}
|
||||
|
||||
// 품목 검색 (대체품 SmartSelect 옵션 — 마스터에 등록 안 된 임의 자재로 교체할 때 사용)
|
||||
// 작업지시 등록 모달의 source/item 라우트를 재활용 (이미 keyword 검색 지원)
|
||||
// → getWIItemSource(params={ keyword, page, pageSize })
|
||||
|
||||
Reference in New Issue
Block a user