Implement process materials auto-fill functionality for outsource purchase orders

- Added a new endpoint to retrieve process materials based on routing details and work order ID.
- Introduced the `getProcessMaterials` function in the `outsourcePurchaseController` to handle the logic for fetching materials.
- Updated the `outsourcePurchaseRoutes` to include the new route for process materials.
- Enhanced the `RegistrationModal` component to toggle material needs and automatically fill materials when required.

(TASK:ERP-019)
This commit is contained in:
kjs
2026-05-06 18:09:23 +09:00
parent bd182386e6
commit 970a8f708a
10 changed files with 467 additions and 243 deletions

View File

@@ -182,6 +182,32 @@ export async function autoProcesses(
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 공정 자재투입(material_input) 자동 채움
// — 사급자재 체크 시 해당 공정의 자재 목록을 외주발주 자재 형식으로 반환
// ─────────────────────────────────────────────────────────────────────────────
export async function getProcessMaterials(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const workOrderId = req.query.work_order_id as string | undefined;
const routingDetailId = req.query.routing_detail_id as string | undefined;
if (!routingDetailId) {
return fail(res, 400, "routing_detail_id는 필수입니다");
}
const data = await svc.getProcessMaterialInputs(
companyCode,
workOrderId,
routingDetailId,
);
return ok(res, data);
} catch (e: any) {
return fail(res, 500, e?.message || "공정 자재투입 조회 실패", e);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 외주발주 가능 작업지시 목록
// (TASK:ERP-019 재구현 — 좌측 리스트 필터)

View File

@@ -466,6 +466,7 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
selected_bom_items, 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,
created_date
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
@@ -499,6 +500,8 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
// 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
// 자재투입(material_input) 전용 4필드 — 외주발주 사급자재 자동 채움 연동
bom_item_id, bom_item_name, bom_qty, bom_unit,
} = req.body;
if (!work_item_id || !content) {
@@ -524,9 +527,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
duration_minutes, input_type, lookup_target, display_fields, selected_bom_items,
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data)
condition_auto_collect, condition_plc_data,
bom_item_id, bom_item_name, bom_qty, bom_unit)
VALUES ($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)
$21, $22, $23, $24, $25, $26, $27, $28, $29)
RETURNING *
`;
@@ -559,6 +563,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
condition_tolerance || null,
condition_auto_collect || null,
condition_plc_data || null,
bom_item_id || null,
bom_item_name || null,
bom_qty != null && bom_qty !== "" ? String(bom_qty) : null,
bom_unit || null,
]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
@@ -588,6 +596,8 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
// 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
// 자재투입(material_input) 전용 4필드 — 외주발주 사급자재 자동 채움 연동
bom_item_id, bom_item_name, bom_qty, bom_unit,
} = req.body;
const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null;
@@ -616,6 +626,10 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
condition_tolerance = $22,
condition_auto_collect = $23,
condition_plc_data = $24,
bom_item_id = $25,
bom_item_name = $26,
bom_qty = $27,
bom_unit = $28,
updated_date = NOW()
WHERE id = $6 AND company_code = $7
RETURNING *
@@ -646,6 +660,10 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
condition_tolerance ?? null,
condition_auto_collect ?? null,
condition_plc_data ?? null,
bom_item_id ?? null,
bom_item_name ?? null,
bom_qty != null && bom_qty !== "" ? String(bom_qty) : null,
bom_unit ?? null,
]);
if (result.rowCount === 0) {
@@ -762,9 +780,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data)
condition_auto_collect, condition_plc_data,
bom_item_id, bom_item_name, bom_qty, bom_unit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22)`,
$18, $19, $20, $21, $22, $23, $24, $25, $26)`,
[
companyCode,
workItemId,
@@ -789,6 +808,11 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
detail.condition_tolerance || null,
detail.condition_auto_collect || null,
detail.condition_plc_data || null,
// 자재투입(material_input) 전용 4필드 — 외주발주 사급자재 자동 채움 연동
detail.bom_item_id || null,
detail.bom_item_name || null,
detail.bom_qty != null && detail.bom_qty !== "" ? String(detail.bom_qty) : null,
detail.bom_unit || null,
]
);
}

View File

@@ -668,7 +668,8 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
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
condition_auto_collect, condition_plc_data,
bom_item_id, bom_item_name, bom_qty, bom_unit
FROM wi_process_work_item_detail
WHERE wi_work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -695,7 +696,8 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
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
condition_auto_collect, condition_plc_data,
bom_item_id, bom_item_name, bom_qty, bom_unit
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -751,7 +753,7 @@ async function syncMasterChecklistFromWi(
// 3. 접수 건수 확인
const acceptCount = await client.query(
`SELECT COUNT(*)::int AS cnt FROM work_order_process_result wopr
JOIN work_order_process wop ON wop.id = wopr.work_order_process_id
JOIN work_order_process wop ON wop.id = wopr.wop_id
WHERE wop.wo_id = $1 AND wop.company_code = $2 AND wopr.company_code = $2`,
[wiId, companyCode],
);
@@ -850,9 +852,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
for (const origDetail of origDetails.rows) {
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, 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)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, userId]
`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, 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)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, origDetail.bom_item_id || null, origDetail.bom_item_name || null, origDetail.bom_qty || null, origDetail.bom_unit || null, userId]
);
}
}
@@ -919,9 +921,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
if (wi.details && Array.isArray(wi.details)) {
for (const d of wi.details) {
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, 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)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, userId]
`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, 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)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, d.bom_item_id || null, d.bom_item_name || null, d.bom_qty != null && d.bom_qty !== "" ? String(d.bom_qty) : null, d.bom_unit || null, userId]
);
}
}
@@ -939,7 +941,13 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
client.release();
}
} catch (error: any) {
logger.error("작업지시 공정작업기준 저장 실패", { error: error.message });
logger.error("작업지시 공정작업기준 저장 실패", {
message: error?.message,
code: error?.code,
detail: error?.detail,
where: error?.where,
stack: error?.stack,
});
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -14,6 +14,9 @@ router.use(authenticateToken);
// 헬퍼 (정적 경로 — :id 라우트보다 위에)
router.get("/auto-processes", ctrl.autoProcesses);
// 공정 자재투입 자동 채움 (사급자재 체크 시)
router.get("/process-materials", ctrl.getProcessMaterials);
// 외주발주 가능 작업지시 목록 (좌측 리스트 필터, TASK:ERP-019 재구현)
router.get("/outsourceable-work-orders", ctrl.listOutsourceableWorkOrders);

View File

@@ -30,7 +30,8 @@ export interface OPOProcessInput {
seq: number;
process_code?: string;
process_name?: string;
vendor_code?: string;
vendor_id?: string; // subcontractor_mng.id (그룹핑 키)
vendor_code?: string; // 표시·하위호환용
vendor_name?: string;
material_needed?: boolean;
remark?: string;
@@ -271,8 +272,8 @@ export async function createOrder(
const pr = await client.query(
`INSERT INTO outsource_purchase_order_process (
opo_id, company_code, seq, process_code, process_name,
vendor_code, vendor_name, material_needed, remark
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
vendor_id, vendor_code, vendor_name, material_needed, remark
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`,
[
master.id,
@@ -280,6 +281,7 @@ export async function createOrder(
proc.seq,
proc.process_code || null,
proc.process_name || null,
proc.vendor_id || null,
proc.vendor_code || null,
proc.vendor_name || null,
proc.material_needed === true,
@@ -452,8 +454,8 @@ export async function updateOrder(
const pr = await client.query(
`INSERT INTO outsource_purchase_order_process (
opo_id, company_code, seq, process_code, process_name,
vendor_code, vendor_name, material_needed, remark
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
vendor_id, vendor_code, vendor_name, material_needed, remark
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`,
[
id,
@@ -461,6 +463,7 @@ export async function updateOrder(
proc.seq,
proc.process_code || null,
proc.process_name || null,
proc.vendor_id || null,
proc.vendor_code || null,
proc.vendor_name || null,
proc.material_needed === true,
@@ -562,7 +565,7 @@ export async function requestRelease(
`SELECT
mm.id, mm.opo_process_id, mm.item_code, mm.item_name,
mm.qty, mm.unit, mm.release_status,
pp.opo_id, pp.process_name, pp.vendor_code, pp.vendor_name,
pp.opo_id, pp.process_name, pp.vendor_id, pp.vendor_code, pp.vendor_name,
oo.order_no, oo.spec, oo.material AS opo_material
FROM outsource_purchase_order_material mm
JOIN outsource_purchase_order_process pp ON pp.id = mm.opo_process_id
@@ -578,22 +581,30 @@ export async function requestRelease(
throw new Error("선택한 자재 중 출고요청 가능한 항목이 없습니다 (이미 요청됐거나 권한 없음)");
}
// 2) OPO로 그룹핑하여 outbound_mng 1건씩 INSERT
const byOpo = new Map<string, any[]>();
// 2) (OPO, 외주사) 단위로 그룹핑하여 outbound_mng 1건씩 INSERT
// └─ 외주사 = 자재가 가는 곳. 외주사 다르면 출고전표·납품처가 분리되어야 함.
// └─ 그룹핑 키는 외주사 마스터 PK(vendor_id) — 코드(vendor_code)는 사람이 바꿀 수 있어 정합성 위험.
// └─ 외주사 미지정(vendor_id 비어있음)은 별도 그룹으로 묶어 출고요청. POP 화면에서 외주사 선택하여 출고 처리.
const byOpoVendor = new Map<string, any[]>();
for (const m of mats.rows) {
if (!byOpo.has(m.opo_id)) byOpo.set(m.opo_id, []);
byOpo.get(m.opo_id)!.push(m);
const vid = m.vendor_id || "__unassigned__";
const k = `${m.opo_id}::${vid}`;
if (!byOpoVendor.has(k)) byOpoVendor.set(k, []);
byOpoVendor.get(k)!.push(m);
}
const outboundIds: string[] = [];
const updatedMaterialIds: string[] = [];
for (const [opoId, group] of byOpo.entries()) {
for (const [k, group] of byOpoVendor.entries()) {
const opoId = k.split("::")[0];
const head = group[0];
const orderNo = head.order_no;
const vendorAssigned = !!head.vendor_id;
const vendorLabel = vendorAssigned ? (head.vendor_name || "외주사") : "외주사 미지정";
const today = new Date().toISOString().slice(0, 10);
// outbound_mng INSERT — 사급출고
// outbound_mng INSERT — 사급출고 (외주사별 1건, 미지정은 별도 1건)
const ob = await client.query(
`INSERT INTO outbound_mng (
id, company_code, outbound_number, outbound_type,
@@ -613,7 +624,7 @@ export async function requestRelease(
today,
orderNo,
userId,
payload.memo || `외주발주 ${orderNo} 사급자재 출고요청`,
payload.memo || `외주발주 ${orderNo} 사급자재 출고요청 (${vendorLabel})`,
payload.warehouse_code || null,
]
);
@@ -784,7 +795,7 @@ async function fetchWorkOrderProcessesWithStatus(
) wopr ON true
LEFT JOIN LATERAL (
SELECT jsonb_agg(
jsonb_build_object('code', sm.subcontractor_code, 'name', sm.subcontractor_name)
jsonb_build_object('id', sm.id, 'code', sm.subcontractor_code, 'name', sm.subcontractor_name)
ORDER BY irs.seq_order
) AS vendor_options
FROM item_routing_subcontractor irs
@@ -890,7 +901,7 @@ export async function autoSelectProcesses(
let routing: any[] = [];
try {
const r = await pool.query(
`SELECT rd.id, rd.seq_no, rd.process_code, rd.work_type, rd.execution_type,
`SELECT rd.id, rd.id AS routing_detail_id, rd.seq_no, rd.process_code, rd.work_type, rd.execution_type,
rd.outsource_supplier, rd.standard_time,
pm.process_name,
COALESCE(vend.vendor_options, '[]'::jsonb) AS vendor_options
@@ -1007,3 +1018,116 @@ export async function listOutsourceableWorkOrders(
const sliced = matched.slice(offset, offset + size);
return { rows: sliced, total, page, size };
}
// ─────────────────────────────────────────────────────────────────────────────
// 공정 자재투입(material_input) 목록 조회 — 외주발주 사급자재 자동 채움
//
// 우선순위:
// 1. 작업지시 커스텀 (wi_process_work_item / wi_process_work_item_detail)
// — work_instruction_no + routing_detail_id 키
// 2. 마스터 (process_work_item / process_work_item_detail)
// — routing_detail_id 키
//
// 응답: bom_* 4컬럼 → 외주발주 자재 입력 형식으로 매핑
// { item_code, item_name, qty, unit }
// item_code 우선순위: item_info.item_number(또는 item_code) > bom_item_id > 빈값
// ─────────────────────────────────────────────────────────────────────────────
export async function getProcessMaterialInputs(
companyCode: string,
workOrderId: string | undefined,
routingDetailId: string,
) {
const pool = getPool();
if (!routingDetailId) return [];
// 1) 작업지시 커스텀 우선 — work_order_id가 있으면 work_instruction_no 조회
let workInstructionNo: string | null = null;
if (workOrderId) {
const wi = await pool.query(
`SELECT work_instruction_no FROM work_instruction
WHERE id = $1 AND company_code = $2 LIMIT 1`,
[workOrderId, companyCode],
);
workInstructionNo = wi.rows[0]?.work_instruction_no || null;
}
// wi_* 우선 — "커스텀 작업기준이 존재하기만 하면" 그 결과를 따른다 (자재투입 0건이어도 마스터로 폴백 금지)
if (workInstructionNo) {
// 1-A. 커스텀 작업기준 존재 여부 (work_item 자체)
const wiExists = await pool.query(
`SELECT 1 FROM wi_process_work_item
WHERE company_code = $1 AND work_instruction_no = $2 AND routing_detail_id = $3
LIMIT 1`,
[companyCode, workInstructionNo, routingDetailId],
);
if (wiExists.rowCount && wiExists.rowCount > 0) {
// 1-B. 자재투입 detail 조회 (0건이면 빈 배열 반환 → 사용자가 의도적으로 뺀 케이스 보호)
const wiRes = await pool.query(
`SELECT wid.bom_item_id, wid.bom_item_name, wid.bom_qty, wid.bom_unit, wid.content
FROM wi_process_work_item wpwi
JOIN wi_process_work_item_detail wid
ON wid.wi_work_item_id = wpwi.id AND wid.company_code = wpwi.company_code
WHERE wpwi.company_code = $1
AND wpwi.work_instruction_no = $2
AND wpwi.routing_detail_id = $3
AND wid.detail_type = 'material_input'
ORDER BY wpwi.sort_order, wid.sort_order`,
[companyCode, workInstructionNo, routingDetailId],
);
return await mapToOpoMaterials(pool, companyCode, wiRes.rows);
}
}
// 2) 마스터 폴백 — 커스텀 작업기준 자체가 없는 경우에만
const masterRes = await pool.query(
`SELECT pwd.bom_item_id, pwd.bom_item_name, pwd.bom_qty, pwd.bom_unit, pwd.content
FROM process_work_item pwi
JOIN process_work_item_detail pwd
ON pwd.work_item_id = pwi.id AND pwd.company_code = pwi.company_code
WHERE pwi.company_code = $1
AND pwi.routing_detail_id = $2
AND pwd.detail_type = 'material_input'
ORDER BY pwi.sort_order, pwd.sort_order`,
[companyCode, routingDetailId],
);
return await mapToOpoMaterials(pool, companyCode, masterRes.rows);
}
/** bom_* 4필드 → 외주발주 자재 입력 형식으로 매핑.
* bom_item_id로 item_info 조회하여 item_code/item_name 보강. */
async function mapToOpoMaterials(
pool: any,
companyCode: string,
rows: any[],
): Promise<Array<{ item_code: string; item_name: string; qty: string; unit: string }>> {
if (rows.length === 0) return [];
// bom_item_id가 있는 row의 item_info 일괄 조회 (id 또는 item_code/item_number 매칭)
const ids = rows.map((r) => r.bom_item_id).filter((v) => v != null && v !== "");
let infoMap = new Map<string, { item_code: string; item_name: string }>();
if (ids.length > 0) {
const ph = ids.map((_, i) => `$${i + 2}`).join(",");
const r = await pool.query(
`SELECT id, item_number AS item_code, item_name
FROM item_info
WHERE company_code = $1
AND id IN (${ph})`,
[companyCode, ...ids],
);
for (const row of r.rows) {
infoMap.set(row.id, { item_code: row.item_code || "", item_name: row.item_name || "" });
}
}
return rows
.filter((r) => r.bom_item_name || r.bom_item_id) // 안내문 row 스킵
.map((r) => {
const info = r.bom_item_id ? infoMap.get(r.bom_item_id) : undefined;
return {
item_code: info?.item_code || r.bom_item_id || "",
item_name: info?.item_name || r.bom_item_name || "",
qty: r.bom_qty != null && r.bom_qty !== "" ? String(r.bom_qty) : "",
unit: r.bom_unit || "",
};
});
}