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:
@@ -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 || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user