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:
@@ -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 재구현 — 좌측 리스트 필터)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Loader2, Inbox } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getOutsourcePurchaseOrder, type OPODetail } from "@/lib/api/outsourcePurchase";
|
||||
import { toLocalDateTime } from "@/lib/utils/localDate";
|
||||
|
||||
interface DetailModalProps {
|
||||
open: boolean;
|
||||
@@ -109,7 +110,7 @@ export function DetailModal({ open, onOpenChange, opoId }: DetailModalProps) {
|
||||
<Field label="납기일" value={detail.due_date} />
|
||||
<Field label="담당자" value={detail.manager} />
|
||||
<Field label="작성자" value={detail.writer} />
|
||||
<Field label="생성일" value={detail.created_date} />
|
||||
<Field label="생성일" value={detail.created_date ? toLocalDateTime(new Date(detail.created_date)) : ""} />
|
||||
</div>
|
||||
{detail.memo && (
|
||||
<div className="mt-2 rounded border border-slate-200 bg-white p-2 text-xs text-slate-700">
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
Search as SearchIcon,
|
||||
Trash2,
|
||||
X,
|
||||
Wand2,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
updateOutsourcePurchaseOrder,
|
||||
getOutsourcePurchaseOrder,
|
||||
getAutoProcesses,
|
||||
getProcessMaterials,
|
||||
listSubcontractors,
|
||||
listOutsourceableWorkOrders,
|
||||
type OPOInputPayload,
|
||||
@@ -99,7 +99,8 @@ interface SelectedItemEntry {
|
||||
}
|
||||
|
||||
interface SubcontractorOption {
|
||||
code: string;
|
||||
id: string; // subcontractor_mng.id (그룹핑·저장 키)
|
||||
code: string; // 표시용
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -208,10 +209,11 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
if (!alive) return;
|
||||
const opts: SubcontractorOption[] = rows
|
||||
.map((r: any) => ({
|
||||
code: r.subcontractor_code || r.code || r.id,
|
||||
name: r.subcontractor_name || r.name || r.subcontractor_code,
|
||||
id: r.id || "",
|
||||
code: r.subcontractor_code || r.code || r.id || "",
|
||||
name: r.subcontractor_name || r.name || r.subcontractor_code || "",
|
||||
}))
|
||||
.filter((o: SubcontractorOption) => o.code && o.name);
|
||||
.filter((o: SubcontractorOption) => o.id && o.name);
|
||||
setVendors(opts);
|
||||
} catch {
|
||||
// 외주사 마스터 미존재 시 무시
|
||||
@@ -744,20 +746,24 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
return;
|
||||
}
|
||||
const processes: OPOProcess[] = candidates.map((c, i) => {
|
||||
const opts: { code: string; name: string }[] = Array.isArray((c as any).vendor_options)
|
||||
? (c as any).vendor_options.filter((o: any) => o?.code && o?.name)
|
||||
const opts: { id: string; code: string; name: string }[] = Array.isArray((c as any).vendor_options)
|
||||
? (c as any).vendor_options.filter((o: any) => o?.id && o?.name)
|
||||
: [];
|
||||
// 외주사 후보가 단 1곳이면 자동 선택, 여러 곳이면 비워두고 사용자 선택 유도
|
||||
const auto = opts.length === 1 ? opts[0] : null;
|
||||
// routing_detail_id: work_order 모드는 wop.routing_detail_id, item_routing 폴백은 rd.id
|
||||
const rdid = (c as any).routing_detail_id || (c as any).id || "";
|
||||
return {
|
||||
seq: i + 1,
|
||||
process_code: c.process_code,
|
||||
process_name: c.process_name || c.process_code,
|
||||
vendor_id: auto?.id || "",
|
||||
vendor_code: auto?.code || "",
|
||||
vendor_name: auto?.name || "",
|
||||
material_needed: false,
|
||||
materials: [],
|
||||
vendor_options: opts,
|
||||
routing_detail_id: rdid,
|
||||
};
|
||||
});
|
||||
setSelected((prev) =>
|
||||
@@ -773,50 +779,6 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
[],
|
||||
);
|
||||
|
||||
const autoFillProcesses = async (key: string) => {
|
||||
const entry = selected.find((s) => s.key === key);
|
||||
if (!entry) return;
|
||||
if (!entry.work_order_id && !entry.item_code) {
|
||||
toast.error("작업지시 또는 품목 코드가 없어 자동표기를 진행할 수 없어요");
|
||||
return;
|
||||
}
|
||||
await runAutoFillForKey(key, {
|
||||
workOrderId: entry.work_order_id,
|
||||
itemCode: entry.item_code,
|
||||
silent: false,
|
||||
});
|
||||
};
|
||||
|
||||
const clearProcesses = (key: string) => {
|
||||
setSelected((prev) =>
|
||||
prev.map((s) => (s.key === key ? { ...s, processes: [] } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
const addManualProcess = (key: string) => {
|
||||
setSelected((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.key !== key) return s;
|
||||
const nextSeq = s.processes.length + 1;
|
||||
return {
|
||||
...s,
|
||||
processes: [
|
||||
...s.processes,
|
||||
{
|
||||
seq: nextSeq,
|
||||
process_code: "",
|
||||
process_name: `공정 ${nextSeq}`,
|
||||
vendor_code: "",
|
||||
vendor_name: "",
|
||||
material_needed: false,
|
||||
materials: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeProcess = (key: string, seq: number) => {
|
||||
setSelected((prev) =>
|
||||
prev.map((s) =>
|
||||
@@ -845,9 +807,65 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
);
|
||||
};
|
||||
|
||||
const updateProcessVendor = (key: string, seq: number, vendorCode: string) => {
|
||||
const v = vendors.find((vv) => vv.code === vendorCode);
|
||||
updateProcess(key, seq, { vendor_code: vendorCode, vendor_name: v?.name || "" });
|
||||
const updateProcessVendor = (key: string, seq: number, vendorId: string) => {
|
||||
// 우선순위: 공정의 vendor_options(라우팅 매핑) → 전체 vendors 마스터
|
||||
const entry = selected.find((s) => s.key === key);
|
||||
const proc = entry?.processes.find((p) => p.seq === seq);
|
||||
const fromOptions = proc?.vendor_options?.find((o) => o.id === vendorId);
|
||||
const fromMaster = vendors.find((vv) => vv.id === vendorId);
|
||||
const v = fromOptions || fromMaster;
|
||||
updateProcess(key, seq, {
|
||||
vendor_id: vendorId,
|
||||
vendor_code: v?.code || "",
|
||||
vendor_name: v?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
// 사급자재 체크박스 토글 — ON이면 공정작업기준 material_input을 자재 목록에 자동 채움
|
||||
const toggleMaterialNeeded = async (key: string, seq: number, checked: boolean) => {
|
||||
if (!checked) {
|
||||
updateProcess(key, seq, { material_needed: false });
|
||||
return;
|
||||
}
|
||||
const entry = selected.find((s) => s.key === key);
|
||||
const proc = entry?.processes.find((p) => p.seq === seq);
|
||||
if (!proc) return;
|
||||
|
||||
// 이미 자재가 있으면 덮어쓰지 않고 체크만 ON
|
||||
if ((proc.materials || []).length > 0) {
|
||||
updateProcess(key, seq, { material_needed: true });
|
||||
return;
|
||||
}
|
||||
if (!proc.routing_detail_id) {
|
||||
updateProcess(key, seq, { material_needed: true });
|
||||
toast.warning("라우팅 정보가 없어 자재 자동 채움 불가. [+ 자재] 버튼으로 직접 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mats = await getProcessMaterials({
|
||||
workOrderId: entry?.work_order_id,
|
||||
routingDetailId: proc.routing_detail_id,
|
||||
});
|
||||
if (mats.length === 0) {
|
||||
updateProcess(key, seq, { material_needed: true });
|
||||
toast.info("이 공정에 등록된 자재투입 항목이 없어요. 직접 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
updateProcess(key, seq, {
|
||||
material_needed: true,
|
||||
materials: mats.map((m) => ({
|
||||
item_code: m.item_code,
|
||||
item_name: m.item_name,
|
||||
qty: m.qty,
|
||||
unit: m.unit,
|
||||
release_status: "대기",
|
||||
})),
|
||||
});
|
||||
toast.success(`자재 ${mats.length}건 자동 채움 완료`);
|
||||
} catch (e: any) {
|
||||
updateProcess(key, seq, { material_needed: true });
|
||||
toast.error(e?.message || "자재 자동 채움 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -1281,12 +1299,10 @@ export function RegistrationModal({ open, onOpenChange, editId, onSaved }: Regis
|
||||
setExpanded((p) => ({ ...p, [entry.key]: !p[entry.key] }))
|
||||
}
|
||||
vendors={vendors}
|
||||
onAutoFill={() => autoFillProcesses(entry.key)}
|
||||
onClearProcesses={() => clearProcesses(entry.key)}
|
||||
onAddProcess={() => addManualProcess(entry.key)}
|
||||
onRemoveProcess={(seq) => removeProcess(entry.key, seq)}
|
||||
onUpdateProcess={(seq, patch) => updateProcess(entry.key, seq, patch)}
|
||||
onUpdateVendor={(seq, code) => updateProcessVendor(entry.key, seq, code)}
|
||||
onToggleMaterialNeeded={(seq, checked) => toggleMaterialNeeded(entry.key, seq, checked)}
|
||||
onAddMaterial={(seq) => addMaterialToProcess(entry.key, seq)}
|
||||
onUpdateMaterial={(seq, idx, patch) => updateMaterial(entry.key, seq, idx, patch)}
|
||||
onRemoveMaterial={(seq, idx) => removeMaterial(entry.key, seq, idx)}
|
||||
@@ -1453,12 +1469,10 @@ function ItemAccordion({
|
||||
expanded,
|
||||
onToggle,
|
||||
vendors,
|
||||
onAutoFill,
|
||||
onClearProcesses,
|
||||
onAddProcess,
|
||||
onRemoveProcess,
|
||||
onUpdateProcess,
|
||||
onUpdateVendor,
|
||||
onToggleMaterialNeeded,
|
||||
onAddMaterial,
|
||||
onUpdateMaterial,
|
||||
onRemoveMaterial,
|
||||
@@ -1468,12 +1482,10 @@ function ItemAccordion({
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
vendors: SubcontractorOption[];
|
||||
onAutoFill: () => void;
|
||||
onClearProcesses: () => void;
|
||||
onAddProcess: () => void;
|
||||
onRemoveProcess: (seq: number) => void;
|
||||
onUpdateProcess: (seq: number, patch: Partial<OPOProcess>) => void;
|
||||
onUpdateVendor: (seq: number, vendorCode: string) => void;
|
||||
onToggleMaterialNeeded: (seq: number, checked: boolean) => void;
|
||||
onAddMaterial: (seq: number) => void;
|
||||
onUpdateMaterial: (seq: number, idx: number, patch: any) => void;
|
||||
onRemoveMaterial: (seq: number, idx: number) => void;
|
||||
@@ -1499,15 +1511,6 @@ function ItemAccordion({
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={onAutoFill} className="h-7 px-2 text-xs">
|
||||
<Wand2 className="mr-1 h-3 w-3" /> 공정 자동표기
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onClearProcesses} className="h-7 px-2 text-xs">
|
||||
<RefreshCw className="mr-1 h-3 w-3" /> 비우기
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onAddProcess} className="h-7 px-2 text-xs">
|
||||
+ 공정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -1523,7 +1526,7 @@ function ItemAccordion({
|
||||
<div className="space-y-2 p-3">
|
||||
{entry.processes.length === 0 ? (
|
||||
<div className="rounded border border-dashed border-slate-300 bg-slate-50 p-3 text-center text-xs text-slate-500">
|
||||
공정이 비어있습니다. [공정 자동표기] 또는 [+ 공정] 버튼으로 추가해주세요.
|
||||
자동 표기 가능한 외주 공정이 없습니다. 다른 작업지시를 선택해주세요.
|
||||
</div>
|
||||
) : (
|
||||
entry.processes.map((proc) => (
|
||||
@@ -1532,13 +1535,13 @@ function ItemAccordion({
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{proc.seq}.
|
||||
</Badge>
|
||||
<Input
|
||||
value={proc.process_name || ""}
|
||||
onChange={(e) => onUpdateProcess(proc.seq, { process_name: e.target.value })}
|
||||
className="h-7 flex-1 text-xs"
|
||||
placeholder="공정명"
|
||||
/>
|
||||
<Select value={proc.vendor_code || ""} onValueChange={(v) => onUpdateVendor(proc.seq, v)}>
|
||||
<span
|
||||
className="h-7 flex-1 truncate px-2 py-1 text-xs leading-5 text-slate-800"
|
||||
title="공정명은 라우팅 기준으로 자동 표기됩니다"
|
||||
>
|
||||
{proc.process_name || ""}
|
||||
</span>
|
||||
<Select value={proc.vendor_id || ""} onValueChange={(v) => onUpdateVendor(proc.seq, v)}>
|
||||
<SelectTrigger className="h-7 w-44 text-xs">
|
||||
<SelectValue placeholder="외주사 선택" />
|
||||
</SelectTrigger>
|
||||
@@ -1547,7 +1550,7 @@ function ItemAccordion({
|
||||
? proc.vendor_options
|
||||
: vendors
|
||||
).map((v) => (
|
||||
<SelectItem key={v.code} value={v.code} className="text-xs">
|
||||
<SelectItem key={v.id} value={v.id} className="text-xs">
|
||||
{v.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -1557,7 +1560,7 @@ function ItemAccordion({
|
||||
<Checkbox
|
||||
checked={!!proc.material_needed}
|
||||
onCheckedChange={(v) =>
|
||||
onUpdateProcess(proc.seq, { material_needed: !!v })
|
||||
onToggleMaterialNeeded(proc.seq, !!v)
|
||||
}
|
||||
/>
|
||||
사급자재
|
||||
@@ -1590,22 +1593,18 @@ function ItemAccordion({
|
||||
className="flex items-center gap-1 rounded border border-rose-100 bg-white px-2 py-1"
|
||||
>
|
||||
<Package className="h-3 w-3 text-rose-500" />
|
||||
<Input
|
||||
value={mat.item_code || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateMaterial(proc.seq, idx, { item_code: e.target.value })
|
||||
}
|
||||
placeholder="품목코드"
|
||||
className="h-7 w-32 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={mat.item_name || ""}
|
||||
onChange={(e) =>
|
||||
onUpdateMaterial(proc.seq, idx, { item_name: e.target.value })
|
||||
}
|
||||
placeholder="품목명"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<span
|
||||
className="h-7 w-32 truncate px-2 py-1 text-xs leading-5 text-slate-800"
|
||||
title="품목코드는 작업기준의 자재투입 기준으로 자동 표기됩니다"
|
||||
>
|
||||
{mat.item_code || ""}
|
||||
</span>
|
||||
<span
|
||||
className="h-7 flex-1 truncate px-2 py-1 text-xs leading-5 text-slate-800"
|
||||
title="품목명은 작업기준의 자재투입 기준으로 자동 표기됩니다"
|
||||
>
|
||||
{mat.item_name || ""}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={mat.qty?.toString() || "0"}
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
/**
|
||||
* 사급자재 출고요청 모달 (TASK:ERP-019)
|
||||
*
|
||||
* 입력: 선택된 외주발주 ID 배열
|
||||
* 동작:
|
||||
* 1) 발주 상세 조회 → 사급자재 release_status='대기'인 행만 추출
|
||||
* 2) 사용자 일괄 체크
|
||||
* 3) requestMaterialRelease(material_ids) 호출
|
||||
* 4) outbound_mng + outbound_detail INSERT (백엔드)
|
||||
* 동작 모델:
|
||||
* - 입력: 선택된 외주발주 ID 배열
|
||||
* - 사급자재(release_status='대기')를 외주사별로 자동 그룹핑하여 미리보기
|
||||
* - 사용자는 선택할 게 아니라 "전송" 한 번으로 외주사별 출고요청 N건 일괄 생성
|
||||
* - 외주사 미지정 자재는 별도 박스에 안내(전송 대상 제외)
|
||||
*
|
||||
* 데이터 모델:
|
||||
* 외주발주 1 : N(외주사별 출고요청 = outbound_mng) : N(자재 = outbound_detail)
|
||||
* 그룹핑 키: subcontractor_mng.id (vendor_id)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, Send, Inbox, AlertCircle } from "lucide-react";
|
||||
import { Loader2, Send, Inbox, AlertCircle, Building2, Package } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getOutsourcePurchaseOrder, requestMaterialRelease } from "@/lib/api/outsourcePurchase";
|
||||
@@ -35,19 +37,25 @@ interface ReleaseRow {
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
process_name: string;
|
||||
vendor_id: string;
|
||||
vendor_name: string;
|
||||
material_item_code: string;
|
||||
material_item_name: string;
|
||||
qty: number;
|
||||
unit: string;
|
||||
release_status: string;
|
||||
vendor_missing: boolean;
|
||||
}
|
||||
|
||||
interface VendorGroup {
|
||||
vendor_id: string;
|
||||
vendor_name: string;
|
||||
rows: ReleaseRow[];
|
||||
}
|
||||
|
||||
const UNASSIGNED_KEY = "__unassigned__";
|
||||
|
||||
export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: ReleaseRequestModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rows, setRows] = useState<ReleaseRow[]>([]);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,25 +77,17 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }:
|
||||
item_code: detail.item_code || "",
|
||||
item_name: detail.item_name || "",
|
||||
process_name: proc.process_name || proc.process_code || "(공정)",
|
||||
vendor_id: proc.vendor_id || "",
|
||||
vendor_name: proc.vendor_name || "",
|
||||
material_item_code: mat.item_code || "",
|
||||
material_item_name: mat.item_name || "",
|
||||
qty: Number(mat.qty || 0),
|
||||
unit: mat.unit || "",
|
||||
release_status: mat.release_status || "대기",
|
||||
vendor_missing: !proc.vendor_code,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (alive) {
|
||||
setRows(collected);
|
||||
// 외주사 미지정 자재는 기본 체크 해제, 나머지 전부 기본 선택
|
||||
const initial = new Set(
|
||||
collected.filter((r) => !r.vendor_missing).map((r) => r.material_id),
|
||||
);
|
||||
setChecked(initial);
|
||||
}
|
||||
if (alive) setRows(collected);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "출고요청 대상 자재 조회 실패");
|
||||
} finally {
|
||||
@@ -99,35 +99,47 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }:
|
||||
};
|
||||
}, [open, opoIds]);
|
||||
|
||||
const allChecked = checked.size === rows.length && rows.length > 0;
|
||||
const someChecked = checked.size > 0 && checked.size < rows.length;
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allChecked) setChecked(new Set());
|
||||
else setChecked(new Set(rows.map((r) => r.material_id)));
|
||||
};
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
// 외주사별 그룹핑 (vendor_id 기준, 미지정은 UNASSIGNED_KEY로 별도 그룹)
|
||||
const vendorGroups: VendorGroup[] = useMemo(() => {
|
||||
const map = new Map<string, VendorGroup>();
|
||||
for (const r of rows) {
|
||||
const key = r.vendor_id || UNASSIGNED_KEY;
|
||||
const g = map.get(key);
|
||||
if (g) g.rows.push(r);
|
||||
else
|
||||
map.set(key, {
|
||||
vendor_id: key,
|
||||
vendor_name: r.vendor_id ? r.vendor_name : "외주사 미지정",
|
||||
rows: [r],
|
||||
});
|
||||
}
|
||||
// 미지정 그룹은 항상 마지막
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
if (a.vendor_id === UNASSIGNED_KEY) return 1;
|
||||
if (b.vendor_id === UNASSIGNED_KEY) return -1;
|
||||
return a.vendor_name.localeCompare(b.vendor_name, "ko");
|
||||
});
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
vendors: vendorGroups.length,
|
||||
materials: rows.length,
|
||||
};
|
||||
}, [vendorGroups, rows]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (checked.size === 0) {
|
||||
toast.error("출고요청할 자재를 선택해주세요");
|
||||
if (rows.length === 0) {
|
||||
toast.error("출고요청 가능한 자재가 없습니다");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await requestMaterialRelease({
|
||||
material_ids: Array.from(checked),
|
||||
material_ids: rows.map((r) => r.material_id),
|
||||
});
|
||||
toast.success(
|
||||
`사급출고 요청 ${result.outbound_count}건 (자재 ${result.material_count}건) 등록 완료. 출고관리 화면에서 후속 처리 가능합니다.`,
|
||||
`외주사 ${result.outbound_count}곳에 사급출고 요청 완료 (자재 ${result.material_count}건). 출고관리 화면에서 후속 처리 가능합니다.`,
|
||||
);
|
||||
onSubmitted();
|
||||
} catch (e: any) {
|
||||
@@ -137,13 +149,6 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }:
|
||||
}
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = rows.length;
|
||||
const sel = checked.size;
|
||||
const missing = rows.filter((r) => r.vendor_missing).length;
|
||||
return { total, sel, missing };
|
||||
}, [rows, checked]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] w-[95vw] max-w-3xl overflow-auto">
|
||||
@@ -155,19 +160,12 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }:
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택된 외주발주의 사급자재 중 출고가 필요한 항목만 표시됩니다. 출고요청 시 해당 발주의 상태가
|
||||
<Badge className="mx-1 bg-violet-100 text-violet-800">출고요청</Badge>
|
||||
으로 변경되고 출고관리 시스템에 [사급출고] 유형으로 등록됩니다.
|
||||
선택한 외주발주의 사급자재(대기 상태)를 외주사별로 자동 그룹핑하여 출고요청합니다. 외주사 1곳당 출고전표 1건이
|
||||
<Badge className="mx-1 bg-violet-100 text-violet-800">사급출고</Badge>
|
||||
유형으로 출고관리에 등록됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>
|
||||
외주사 미지정 자재({stats.missing}건)는 기본 체크 해제 상태입니다. 외주사 지정 후 요청을 권장합니다.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-400" />
|
||||
@@ -178,79 +176,95 @@ export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }:
|
||||
<span className="text-sm">출고요청 가능한 사급자재가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="w-10 border-b px-2 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={toggleAll}
|
||||
className={someChecked ? "data-[state=checked]:bg-slate-400" : ""}
|
||||
/>
|
||||
</th>
|
||||
<th className="border-b px-2 py-2 text-left font-semibold">외주발주</th>
|
||||
<th className="border-b px-2 py-2 text-left font-semibold">품목</th>
|
||||
<th className="border-b px-2 py-2 text-left font-semibold">공정</th>
|
||||
<th className="border-b px-2 py-2 text-left font-semibold">외주사</th>
|
||||
<th className="border-b px-2 py-2 text-left font-semibold">사급자재</th>
|
||||
<th className="border-b px-2 py-2 text-right font-semibold">필요수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const isChecked = checked.has(r.material_id);
|
||||
return (
|
||||
<tr key={r.material_id} className="hover:bg-slate-50">
|
||||
<td className="border-b px-2 py-2 text-center">
|
||||
<Checkbox checked={isChecked} onCheckedChange={() => toggleOne(r.material_id)} />
|
||||
</td>
|
||||
<td className="border-b px-2 py-2 font-medium text-slate-900">{r.order_no}</td>
|
||||
<td className="border-b px-2 py-2 text-slate-700">
|
||||
<div>{r.item_code}</div>
|
||||
<div className="text-slate-500">{r.item_name}</div>
|
||||
</td>
|
||||
<td className="border-b px-2 py-2 text-slate-700">{r.process_name}</td>
|
||||
<td className="border-b px-2 py-2">
|
||||
{r.vendor_missing ? (
|
||||
<Badge variant="outline" className="border-amber-300 text-amber-700">
|
||||
미지정
|
||||
</Badge>
|
||||
) : (
|
||||
<span>{r.vendor_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border-b px-2 py-2 text-slate-700">
|
||||
<div>{r.material_item_code}</div>
|
||||
<div className="text-slate-500">{r.material_item_name}</div>
|
||||
</td>
|
||||
<td className="border-b px-2 py-2 text-right font-medium">
|
||||
{r.qty.toLocaleString()} {r.unit}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{vendorGroups.map((g) => {
|
||||
const isUnassigned = g.vendor_id === UNASSIGNED_KEY;
|
||||
return (
|
||||
<div
|
||||
key={g.vendor_id}
|
||||
className={
|
||||
isUnassigned
|
||||
? "rounded-md border border-amber-200 bg-amber-50"
|
||||
: "rounded-md border border-slate-200 bg-white"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isUnassigned
|
||||
? "flex items-center justify-between gap-2 border-b border-amber-200 bg-amber-100 px-3 py-2"
|
||||
: "flex items-center justify-between gap-2 border-b bg-slate-50 px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isUnassigned ? (
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
) : (
|
||||
<Building2 className="h-4 w-4 text-violet-600" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
isUnassigned
|
||||
? "text-sm font-semibold text-amber-900"
|
||||
: "text-sm font-semibold text-slate-900"
|
||||
}
|
||||
>
|
||||
{g.vendor_name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
자재 {g.rows.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<span className={isUnassigned ? "text-xs text-amber-700" : "text-xs text-slate-500"}>
|
||||
{isUnassigned ? "POP 출고 시 외주사 선택 필요" : "출고전표 1건 생성 예정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isUnassigned ? "divide-y divide-amber-100" : "divide-y divide-slate-100"}>
|
||||
{g.rows.map((r) => (
|
||||
<div key={r.material_id} className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
<Package
|
||||
className={
|
||||
isUnassigned ? "h-3 w-3 shrink-0 text-amber-500" : "h-3 w-3 shrink-0 text-rose-500"
|
||||
}
|
||||
/>
|
||||
<div className="w-32 shrink-0 truncate text-slate-700">{r.order_no}</div>
|
||||
<div className="w-32 shrink-0 truncate text-slate-700">{r.process_name}</div>
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-medium text-slate-900">{r.material_item_code}</span>{" "}
|
||||
<span className="text-slate-600">{r.material_item_name}</span>
|
||||
</div>
|
||||
<div className="w-20 shrink-0 text-right text-slate-700">
|
||||
{r.qty.toLocaleString()} {r.unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="text-xs text-slate-600">
|
||||
총 {stats.total}건 중 {stats.sel}건 선택
|
||||
{stats.materials > 0 ? (
|
||||
<span>
|
||||
출고전표 <strong className="text-violet-700">{stats.vendors}건</strong> · 자재{" "}
|
||||
<strong className="text-violet-700">{stats.materials}건</strong>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || checked.size === 0}>
|
||||
<Button onClick={handleSubmit} disabled={submitting || rows.length === 0}>
|
||||
{submitting ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
출고요청 전송 ({checked.size})
|
||||
출고요청 전송 ({stats.vendors}건)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { toLocalDateTime } from "@/lib/utils/localDate";
|
||||
|
||||
import {
|
||||
listOutsourcePurchaseOrders,
|
||||
@@ -158,6 +159,8 @@ export default function OutsourcePurchaseOrderPage() {
|
||||
? `${processVendor} 외 ${(r.process_count || 0) - 1}건`
|
||||
: processVendor,
|
||||
material_status_label: matLabel,
|
||||
// 생성일 — UTC ISO를 브라우저 로컬 시각(KST 사용자 기준)으로 표시
|
||||
created_date: r.created_date ? toLocalDateTime(new Date(r.created_date)) : "",
|
||||
};
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
@@ -38,13 +38,18 @@ export interface OPOProcess {
|
||||
seq: number;
|
||||
process_code?: string;
|
||||
process_name?: string;
|
||||
/** subcontractor_mng.id — 그룹핑·출고요청 키 */
|
||||
vendor_id?: string;
|
||||
/** 표시·하위호환용. 그룹핑 키로 사용하지 말 것 (사람이 바꿀 수 있음) */
|
||||
vendor_code?: string;
|
||||
vendor_name?: string;
|
||||
material_needed?: boolean;
|
||||
remark?: string;
|
||||
materials?: OPOMaterial[];
|
||||
/** 라우팅에 매핑된 외주사 후보 (드롭다운을 이 목록으로 제한) */
|
||||
vendor_options?: { code: string; name: string }[];
|
||||
vendor_options?: { id: string; code: string; name: string }[];
|
||||
/** item_routing_detail.id (사급자재 자동 채움 호출에 사용) */
|
||||
routing_detail_id?: string;
|
||||
}
|
||||
|
||||
export interface OPOMaster {
|
||||
@@ -244,6 +249,23 @@ export async function getAutoProcesses(
|
||||
return res.data?.data || { source: "none", routing: [], candidates: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 자재투입(material_input) 자동 채움 — 사급자재 체크 시 호출
|
||||
* 응답: 외주발주 자재 입력 형식 배열 [{item_code, item_name, qty, unit}]
|
||||
*/
|
||||
export async function getProcessMaterials(
|
||||
args: { workOrderId?: string; routingDetailId: string },
|
||||
): Promise<Array<{ item_code: string; item_name: string; qty: string; unit: string }>> {
|
||||
if (!args.routingDetailId) return [];
|
||||
const params: Record<string, string> = { routing_detail_id: args.routingDetailId };
|
||||
if (args.workOrderId) params.work_order_id = args.workOrderId;
|
||||
const res = await apiClient.get<ApiEnvelope<Array<{ item_code: string; item_name: string; qty: string; unit: string }>>>(
|
||||
"/outsource-purchase/process-materials",
|
||||
{ params },
|
||||
);
|
||||
return res.data?.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주발주 가능 작업지시 목록 (TASK:ERP-019 재구현)
|
||||
* "이전 공정 완료 + 다음 공정 외주/선택가능" 작업지시만 반환
|
||||
|
||||
Reference in New Issue
Block a user