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:
kjs
2026-05-21 10:01:41 +09:00
parent c5364e1d20
commit 1ebd9348ae
5 changed files with 2938 additions and 619 deletions

View File

@@ -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 });
}
}

View File

@@ -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;

View File

@@ -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) {
// 마스터 테이블에서 채번 컬럼 자동 감지

View File

@@ -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 })