From 59469d4bf67a0dc3f6153525a9dc7477d8c8f7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 18 Mar 2026 14:56:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[shipment]=20=EC=B6=9C=ED=95=98=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B0=9C=EC=84=A0=20-?= =?UTF-8?q?=20=EC=88=98=EC=A3=BC=20=ED=92=88=EB=AA=A9=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD,=20=EC=B7=A8=EC=86=8C=E2=86=92cancelled?= =?UTF-8?q?=20=EC=83=81=ED=83=9C,=20=EC=97=AD=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4,=20=EC=A0=9C=ED=92=88?= =?UTF-8?q?=EB=AA=85/=EC=98=A4=ED=94=88=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출하 품목을 수주 품목(order_item_id) 기반으로 변경 - 작업 취소 시 출하를 삭제 대신 cancelled 상태로 변경 - 작업 취소 시 역방향 프로세스 구현 (WorkOrderService) - 출하 상세 API에 제품명(product_name) 매핑 추가 - 출하 상세 제품그룹에 오픈사이즈 추가 - shipment_items 테이블에 없는 item_id 컬럼 참조 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Models/Tenants/Shipment.php | 1 + app/Services/ShipmentService.php | 27 ++++++- app/Services/WorkOrderService.php | 123 +++++++++++++++++++----------- 3 files changed, 104 insertions(+), 47 deletions(-) diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index 90abc70c..44afd600 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -88,6 +88,7 @@ class Shipment extends Model public const STATUSES = [ 'scheduled' => '출고예정', 'ready' => '출하대기', + 'cancelled' => '취소', 'shipping' => '배송중', 'completed' => '배송완료', ]; diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index ee58a073..896c5fdb 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -162,7 +162,7 @@ public function show(int $id): Shipment { $tenantId = $this->tenantId(); - return Shipment::query() + $shipment = Shipment::query() ->where('tenant_id', $tenantId) ->with([ 'items' => function ($query) { @@ -171,11 +171,36 @@ public function show(int $id): Shipment 'vehicleDispatches', 'order.client', 'order.writer', + 'order.nodes', 'workOrder', 'creator', 'updater', ]) ->findOrFail($id); + + // order_nodes의 product_name을 shipment items에 매핑 + if ($shipment->order && $shipment->order->nodes) { + $nodeMap = []; + foreach ($shipment->order->nodes as $node) { + $opts = $node->options ?? []; + $productName = $opts['product_name'] ?? $node->name; + $openW = $opts['open_width'] ?? null; + $openH = $opts['open_height'] ?? null; + $size = ($openW && $openH) ? "{$openW}×{$openH}" : null; + $nodeMap[$node->code] = [ + 'product_name' => $size ? "{$productName} {$size}" : $productName, + ]; + } + + foreach ($shipment->items as $item) { + // floor_unit (예: 1F/FSS-01) → order_node code (예: 1F-FSS-01) + $nodeCode = str_replace('/', '-', $item->floor_unit ?? ''); + $nodeInfo = $nodeMap[$nodeCode] ?? null; + $item->setAttribute('product_name', $nodeInfo['product_name'] ?? null); + } + } + + return $shipment; } /** diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 31494b21..5e08398d 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -571,6 +571,11 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) switch ($status) { case WorkOrder::STATUS_IN_PROGRESS: $workOrder->started_at = $workOrder->started_at ?? now(); + // 완료 → 진행중 역전환 시: completed_at 초기화 + 품목 result 초기화 + if ($oldStatus === WorkOrder::STATUS_COMPLETED) { + $workOrder->completed_at = null; + $this->clearItemResults($workOrder); + } break; case WorkOrder::STATUS_COMPLETED: // Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략) @@ -750,55 +755,30 @@ private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $ten 'updated_by' => $userId, ]); - // 모든 메인 작업지시의 품목을 출하 품목으로 복사 + // 수주 품목(order_items)에서 출하 품목 생성 + // 출하는 수주와 직접 연결 — 생산(WorkOrder)은 트리거만 제공 + $orderItems = $order->items()->orderBy('sort_order')->get(); $seq = 0; - foreach ($mainWorkOrders as $wo) { - $workOrderItems = $wo->items()->get(); - foreach ($workOrderItems as $woItem) { - $result = $woItem->options['result'] ?? []; - $lotNo = $result['lot_no'] ?? null; - $floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId); + foreach ($orderItems as $orderItem) { + $floorParts = array_filter([$orderItem->floor_code, $orderItem->symbol_code]); + $floorUnit = ! empty($floorParts) ? implode('/', $floorParts) : null; - ShipmentItem::create([ - 'tenant_id' => $tenantId, - 'shipment_id' => $shipment->id, - 'seq' => ++$seq, - 'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, - 'item_name' => $woItem->item_name, - 'floor_unit' => $floorUnit, - 'specification' => $woItem->specification, - 'quantity' => $result['good_qty'] ?? $woItem->quantity, - 'unit' => $woItem->unit, - 'lot_no' => $lotNo, - 'order_item_id' => $woItem->source_order_item_id, - 'work_order_item_id' => $woItem->id, - 'remarks' => null, - ]); - } - - // WO에 품목이 없으면 수주 품목에서 fallback (해당 WO의 공정에 매칭되는 품목) - if ($workOrderItems->isEmpty() && $wo->salesOrder) { - $orderItems = $wo->salesOrder->items()->get(); - foreach ($orderItems as $orderItem) { - $floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId); - ShipmentItem::create([ - 'tenant_id' => $tenantId, - 'shipment_id' => $shipment->id, - 'seq' => ++$seq, - 'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null, - 'item_name' => $orderItem->item_name, - 'floor_unit' => $floorUnit, - 'specification' => $orderItem->specification, - 'quantity' => $orderItem->quantity, - 'unit' => $orderItem->unit, - 'lot_no' => null, - 'order_item_id' => $orderItem->id, - 'work_order_item_id' => null, - 'remarks' => null, - ]); - } - } + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => ++$seq, + 'item_code' => $orderItem->item_code, + 'item_name' => $orderItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $orderItem->specification, + 'quantity' => $orderItem->quantity, + 'unit' => $orderItem->unit, + 'lot_no' => null, + 'order_item_id' => $orderItem->id, + 'work_order_item_id' => null, + 'remarks' => null, + ]); } $this->auditLogger->log( @@ -1067,6 +1047,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void if ($newOrderStatus === Order::STATUS_PRODUCED) { $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); } + + // 생산완료 → 생산중 역전환 시 → ready 상태 출하 삭제 + if ($oldOrderStatus === Order::STATUS_PRODUCED && $newOrderStatus === Order::STATUS_IN_PRODUCTION) { + $this->cancelReadyShipments($order, $tenantId); + } } /** @@ -1152,6 +1137,52 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $ return $lotNo; } + /** + * 생산 역전환 시 ready 상태 출하를 cancelled로 변경 + * + * 아직 배차/출하 진행 전(ready)인 출하만 취소 처리. + * shipping/completed 등 이미 진행된 출하는 건드리지 않음. + */ + private function cancelReadyShipments(Order $order, int $tenantId): void + { + $readyShipments = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->where('status', 'ready') + ->get(); + + foreach ($readyShipments as $shipment) { + $shipment->update([ + 'status' => 'cancelled', + 'updated_by' => $this->apiUserId(), + ]); + + $this->auditLogger->log( + $tenantId, + 'shipment', + $shipment->id, + 'auto_cancelled_on_production_rollback', + ['status' => 'ready', 'order_id' => $order->id], + ['status' => 'cancelled'] + ); + } + } + + /** + * 작업지시 품목의 result 데이터 초기화 (완료 → 진행중 역전환 시) + */ + private function clearItemResults(WorkOrder $workOrder): void + { + $items = $workOrder->items; + + foreach ($items as $item) { + $options = $item->options ?? []; + unset($options['result']); + $item->options = $options; + $item->status = WorkOrderItem::STATUS_IN_PROGRESS; + $item->save(); + } + } + /** * 보조 공정(재고생산 등) 여부 판단 */