feat: [order] 재고생산 생산지시 자동 처리

- STOCK 타입 store() 시 site_name='재고생산' 자동 설정
- createProductionOrder() STOCK 분기 추가:
  - project_name='재고생산' 고정
  - 절곡 공정 자동 선택 (BOM 매칭 스킵)
  - scheduled_date=now() 자동 설정
- 절곡 공정 미존재 시 에러 메시지 추가
This commit is contained in:
김보곤
2026-03-16 22:24:15 +09:00
parent 407afe38e4
commit ae73275cf9
3 changed files with 134 additions and 106 deletions

View File

@@ -182,6 +182,11 @@ public function store(array $data)
$data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT;
$data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER;
// 재고생산: 현장명 자동 설정
if ($isStock) {
$data['site_name'] = '재고생산';
}
$items = $data['items'] ?? [];
unset($data['items']);
@@ -1233,121 +1238,142 @@ public function createProductionOrder(int $orderId, array $data)
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
}
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
$bomItemIds = [];
$nodesBomMap = []; // node_id => [item_name => bom_item]
// 재고생산(STOCK): 절곡 공정에 모든 품목 직접 배정 (BOM 매칭 스킵)
$isStock = $order->order_type_code === Order::TYPE_STOCK;
$nodesBomMap = [];
foreach ($order->rootNodes as $node) {
$bomResult = $node->options['bom_result'] ?? [];
$bomItems = $bomResult['items'] ?? [];
foreach ($bomItems as $bomItem) {
if (! empty($bomItem['item_id'])) {
$bomItemIds[] = $bomItem['item_id'];
$nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem;
}
}
}
$bomItemIds = array_unique($bomItemIds);
// process_items 테이블에서 item_id → process_id 매핑 조회
$itemProcessMap = [];
if (! empty($bomItemIds)) {
$processItems = DB::table('process_items as pi')
->join('processes as p', 'pi.process_id', '=', 'p.id')
->where('p.tenant_id', $tenantId)
->whereIn('pi.item_id', $bomItemIds)
->where('pi.is_active', true)
->select('pi.item_id', 'pi.process_id')
->get();
foreach ($processItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
// item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회)
$codeToIdMap = [];
if (! empty($bomItemIds)) {
$codeToIdRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('id', $bomItemIds)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($codeToIdRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지)
$orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all();
$unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap));
if (! empty($unmappedCodes)) {
$extraRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $unmappedCodes)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($extraRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// 사전 매핑된 item_id에 대한 process_items도 일괄 조회
$allResolvedIds = array_values(array_unique(array_merge(
array_keys($itemProcessMap),
array_values($codeToIdMap)
)));
$unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap));
if (! empty($unmappedProcessIds)) {
$extraProcessItems = DB::table('process_items')
->whereIn('item_id', $unmappedProcessIds)
if ($isStock) {
$bendingProcess = \App\Models\Process::where('tenant_id', $tenantId)
->where('process_name', '절곡')
->where('is_active', true)
->select('item_id', 'process_id')
->get();
foreach ($extraProcessItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
->first();
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
$processId = null;
// 1. order_item의 item_id가 있으면 직접 매핑
if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) {
$processId = $itemProcessMap[$orderItem->item_id];
if (! $bendingProcess) {
throw new BadRequestHttpException(__('error.order.bending_process_not_found'));
}
// 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기
elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$nodeBom = $nodesBomMap[$orderItem->order_node_id];
$bomItem = $nodeBom[$orderItem->item_name] ?? null;
if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) {
$processId = $itemProcessMap[$bomItem['item_id']];
$itemsByProcess = [
$bendingProcess->id => [
'process_id' => $bendingProcess->id,
'items' => $order->items->all(),
],
];
} else {
// 기존 로직: order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
$bomItemIds = [];
foreach ($order->rootNodes as $node) {
$bomResult = $node->options['bom_result'] ?? [];
$bomItems = $bomResult['items'] ?? [];
foreach ($bomItems as $bomItem) {
if (! empty($bomItem['item_id'])) {
$bomItemIds[] = $bomItem['item_id'];
$nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem;
}
}
}
// 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거)
if ($processId === null && $orderItem->item_code) {
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
$processId = $itemProcessMap[$resolvedId];
$bomItemIds = array_unique($bomItemIds);
// process_items 테이블에서 item_id → process_id 매핑 조회
$itemProcessMap = [];
if (! empty($bomItemIds)) {
$processItems = DB::table('process_items as pi')
->join('processes as p', 'pi.process_id', '=', 'p.id')
->where('p.tenant_id', $tenantId)
->whereIn('pi.item_id', $bomItemIds)
->where('pi.is_active', true)
->select('pi.item_id', 'pi.process_id')
->get();
foreach ($processItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
$key = $processId ?? 'none';
if (! isset($itemsByProcess[$key])) {
$itemsByProcess[$key] = [
'process_id' => $processId,
'items' => [],
];
// item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회)
$codeToIdMap = [];
if (! empty($bomItemIds)) {
$codeToIdRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('id', $bomItemIds)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($codeToIdRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지)
$orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all();
$unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap));
if (! empty($unmappedCodes)) {
$extraRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $unmappedCodes)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($extraRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// 사전 매핑된 item_id에 대한 process_items도 일괄 조회
$allResolvedIds = array_values(array_unique(array_merge(
array_keys($itemProcessMap),
array_values($codeToIdMap)
)));
$unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap));
if (! empty($unmappedProcessIds)) {
$extraProcessItems = DB::table('process_items')
->whereIn('item_id', $unmappedProcessIds)
->where('is_active', true)
->select('item_id', 'process_id')
->get();
foreach ($extraProcessItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
$processId = null;
// 1. order_item의 item_id가 있으면 직접 매핑
if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) {
$processId = $itemProcessMap[$orderItem->item_id];
}
// 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기
elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$nodeBom = $nodesBomMap[$orderItem->order_node_id];
$bomItem = $nodeBom[$orderItem->item_name] ?? null;
if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) {
$processId = $itemProcessMap[$bomItem['item_id']];
}
}
// 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거)
if ($processId === null && $orderItem->item_code) {
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
$processId = $itemProcessMap[$resolvedId];
}
}
$key = $processId ?? 'none';
if (! isset($itemsByProcess[$key])) {
$itemsByProcess[$key] = [
'process_id' => $processId,
'items' => [],
];
}
$itemsByProcess[$key]['items'][] = $orderItem;
}
$itemsByProcess[$key]['items'][] = $orderItem;
}
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) {
@@ -1409,13 +1435,13 @@ public function createProductionOrder(int $orderId, array $data)
'tenant_id' => $tenantId,
'work_order_no' => $workOrderNo,
'sales_order_id' => $order->id,
'project_name' => $order->site_name ?? $order->client_name,
'project_name' => $isStock ? '재고생산' : ($order->site_name ?? $order->client_name),
'process_id' => $processId,
'status' => (! empty($assigneeIds) || $teamId) ? WorkOrder::STATUS_WAITING : WorkOrder::STATUS_UNASSIGNED,
'priority' => $priority,
'assignee_id' => $primaryAssigneeId,
'team_id' => $teamId,
'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date,
'scheduled_date' => $data['scheduled_date'] ?? ($isStock ? now()->toDateString() : $order->delivery_date),
'memo' => $data['memo'] ?? null,
'options' => $workOrderOptions,
'is_active' => true,

View File

@@ -122,6 +122,7 @@
'not_found' => 'Work order not found.',
'cannot_delete_in_progress' => 'Cannot delete in progress or completed work order.',
'not_bending_process' => 'This is not a bending process.',
'bending_process_not_found' => 'Bending process not found. Please check process settings.',
'invalid_transition' => "Cannot transition status from ':from' to ':to'. Allowed statuses: :allowed",
],

View File

@@ -428,6 +428,7 @@
'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.',
'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.',
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
'bending_process_not_found' => '절곡 공정을 찾을 수 없습니다. 공정 설정을 확인해주세요.',
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.',