diff --git a/app/Http/Controllers/Api/V1/ShipmentController.php b/app/Http/Controllers/Api/V1/ShipmentController.php index 6262700..08f4b2b 100644 --- a/app/Http/Controllers/Api/V1/ShipmentController.php +++ b/app/Http/Controllers/Api/V1/ShipmentController.php @@ -8,13 +8,15 @@ use App\Http\Requests\Shipment\ShipmentUpdateRequest; use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest; use App\Services\ShipmentService; +use App\Services\WorkOrderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ShipmentController extends Controller { public function __construct( - private readonly ShipmentService $service + private readonly ShipmentService $service, + private readonly WorkOrderService $workOrderService ) {} /** @@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse { $shipment = $this->service->store($request->validated()); - return ApiResponse::success($shipment, __('message.created'), 201); + return ApiResponse::success($shipment, __('message.created'), [], 201); } /** @@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse } } + /** + * 수주 기반 출하 생성 + */ + public function createFromOrder(int $orderId): JsonResponse + { + try { + $shipment = $this->workOrderService->createShipmentForOrder($orderId); + + return ApiResponse::success($shipment, __('message.created'), [], 201); + } catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) { + return ApiResponse::error($e->getMessage(), 400); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.order.not_found'), 404); + } + } + /** * LOT 옵션 조회 */ diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index df96ff5..90e8f1a 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -283,6 +283,12 @@ public function getOrderContactAttribute(): ?string */ public function getOrderInfoAttribute(): array { + $orderOptions = $this->order?->options; + if (is_string($orderOptions)) { + $orderOptions = json_decode($orderOptions, true) ?? []; + } + $orderOptions = $orderOptions ?? []; + return [ 'order_id' => $this->order_id, 'order_no' => $this->order?->order_no, @@ -290,10 +296,16 @@ public function getOrderInfoAttribute(): array 'client_id' => $this->order_client_id, 'customer_name' => $this->order_customer_name, 'site_name' => $this->order_site_name, - 'delivery_address' => $this->order_delivery_address, + 'delivery_address' => $orderOptions['shipping_address'] ?? $this->order_delivery_address, + 'delivery_address_detail' => $orderOptions['shipping_address_detail'] ?? null, 'contact' => $this->order_contact, + // 수신자 정보 (수주 options에서) + 'receiver' => $orderOptions['receiver'] ?? null, + 'receiver_contact' => $orderOptions['receiver_contact'] ?? $this->order_contact, // 추가 정보 - 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), 'writer_id' => $this->order?->writer_id, + 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), + 'delivery_method' => $this->order?->delivery_method_code, + 'writer_id' => $this->order?->writer_id, 'writer_name' => $this->order?->writer?->name, ]; } diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index df8b35a..3db0bec 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -20,7 +20,7 @@ public function index(array $params): LengthAwarePaginator $query = Shipment::query() ->where('tenant_id', $tenantId) - ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']); + ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', 'creator']); // 검색어 필터 if (! empty($params['search'])) { diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index bbb5ed0..3da8978 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -602,13 +602,9 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($workOrder, $tenantId); - // 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고 - if ($status === WorkOrder::STATUS_COMPLETED) { - if ($workOrder->sales_order_id) { - $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); - } else { - $this->stockInFromProduction($workOrder); - } + // 작업완료 시: 선생산(수주 없음) → 재고 입고 + if ($status === WorkOrder::STATUS_COMPLETED && ! $workOrder->sales_order_id) { + $this->stockInFromProduction($workOrder); } return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); @@ -659,11 +655,167 @@ private function shouldStockIn(WorkOrderItem $woItem): bool } /** - * 작업지시 완료 시 자동 출하 생성 + * PRODUCED 수주에 출하가 없으면 재생성 * - * 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다. - * 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다. - * (Shipment 모델의 accessor 메서드로 수주 정보 참조) + * syncOrderStatus에서 이미 PRODUCED인데 출하가 삭제된 경우 호출됩니다. + */ + private function ensureShipmentExists(Order $order, $mainWorkOrders, int $tenantId): void + { + $hasShipment = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->exists(); + + if (! $hasShipment) { + $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); + } + } + + /** + * 수주 기반 출하 수동 생성 (API 엔드포인트용) + * + * 출하관리 UI에서 수주를 선택하여 출하를 수동 생성할 때 사용합니다. + * PRODUCED 이상 상태의 수주만 가능합니다. + */ + public function createShipmentForOrder(int $orderId): Shipment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $order = Order::where('tenant_id', $tenantId)->findOrFail($orderId); + + // PRODUCED 또는 SHIPPED 상태만 출하 생성 가능 + $allowedStatuses = [Order::STATUS_PRODUCED, Order::STATUS_SHIPPED]; + if (! in_array($order->status_code, $allowedStatuses)) { + throw new BadRequestHttpException(__('error.shipment.order_not_produced')); + } + + // 메인 작업지시 조회 + $allWorkOrders = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $orderId) + ->where('status', '!=', WorkOrder::STATUS_CANCELLED) + ->get(); + + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null); + + if ($mainWorkOrders->isEmpty()) { + throw new BadRequestHttpException(__('error.shipment.no_work_orders')); + } + + $shipment = $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $userId); + + if (! $shipment) { + throw new BadRequestHttpException(__('error.shipment.already_exists')); + } + + return $shipment->load('items'); + } + + /** + * 수주 단위 자동 출하 생성 (생산완료 시) + * + * 수주의 모든 메인 작업지시가 완료되면, 전체 WO 품목을 합쳐서 출하 1건을 생성합니다. + * - 이미 수주에 연결된 출하가 있으면 스킵 (중복 방지) + * - 부분 출고는 출하관리 UI에서 수동 생성 + * + * @param \Illuminate\Support\Collection $mainWorkOrders 메인 작업지시 컬렉션 + */ + private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $tenantId, int $userId): ?Shipment + { + // 이미 이 수주에 연결된 출하가 있으면 스킵 + $existingShipment = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->first(); + + if ($existingShipment) { + return $existingShipment; + } + + $shipmentNo = Shipment::generateShipmentNo($tenantId); + + $shipment = Shipment::create([ + 'tenant_id' => $tenantId, + 'shipment_no' => $shipmentNo, + 'work_order_id' => null, // 수주 단위이므로 개별 WO 연결 안함 + 'order_id' => $order->id, + 'scheduled_date' => $order->delivery_date ?? now()->toDateString(), + 'status' => 'scheduled', + 'priority' => 'normal', + 'delivery_method' => $order->delivery_method_code ?? 'pickup', + 'can_ship' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 모든 메인 작업지시의 품목을 출하 품목으로 복사 + $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); + + 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, + '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, + 'remarks' => null, + ]); + } + } + } + + $this->auditLogger->log( + $tenantId, + 'shipment', + $shipment->id, + 'auto_created_from_order', + null, + [ + 'order_id' => $order->id, + 'order_no' => $order->order_no, + 'shipment_no' => $shipmentNo, + 'work_order_count' => $mainWorkOrders->count(), + 'items_count' => $shipment->items()->count(), + ] + ); + + return $shipment; + } + + /** + * [DEPRECATED] 작업지시 단위 자동 출하 생성 + * + * 수주 단위 출하(createShipmentFromOrder)로 대체됨. + * 부분 출고 등 특수 케이스에서 개별 WO 기반 출하가 필요할 경우를 위해 유지. */ private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment { @@ -842,8 +994,8 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void ->where('status', '!=', WorkOrder::STATUS_CANCELLED) ->get(); - // 보조 공정 제외 - $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo)); + // 보조 공정 및 공정 미지정 작업지시 제외 + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null); if ($mainWorkOrders->isEmpty()) { return; @@ -869,8 +1021,17 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void $newOrderStatus = Order::STATUS_IN_PRODUCTION; } - // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 - if (! $newOrderStatus || $order->status_code === $newOrderStatus) { + // 매핑되는 상태가 없으면 스킵 + if (! $newOrderStatus) { + return; + } + + // 이미 동일한 상태면 상태 변경은 스킵하되, PRODUCED인데 출하 없으면 재생성 + if ($order->status_code === $newOrderStatus) { + if ($newOrderStatus === Order::STATUS_PRODUCED) { + $this->ensureShipmentExists($order, $mainWorkOrders, $tenantId); + } + return; } @@ -893,6 +1054,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void 'in_progress' => $inProgressCount, ]] ); + + // 생산완료(PRODUCED) 전환 시 → 수주 단위 출하 자동 생성 + if ($newOrderStatus === Order::STATUS_PRODUCED) { + $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); + } } /** @@ -981,7 +1147,7 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $ */ private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool { - $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []); + $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options ?? '{}', true) ?? []); return ! empty($options['is_auxiliary']); } @@ -1875,15 +2041,92 @@ public function toggleStepProgress(int $workOrderId, int $progressId): array $after ); + // 모든 공정 단계 완료 시 → 작업지시 자동 완료 + $workOrderStatusChanged = false; + if ($progress->isCompleted()) { + $workOrderStatusChanged = $this->autoCompleteWorkOrderIfAllStepsDone($workOrder, $tenantId, $userId); + } + return [ 'id' => $progress->id, 'status' => $progress->status, 'is_completed' => $progress->isCompleted(), 'completed_at' => $progress->completed_at?->toDateTimeString(), 'completed_by' => $progress->completed_by, + 'work_order_status_changed' => $workOrderStatusChanged, ]; } + /** + * 모든 공정 단계 완료 시 작업지시를 자동으로 완료 처리 + * + * 트리거: 마지막 공정 단계(포장 등) 완료 체크 시 + * 흐름: 전 단계 완료 → 작업지시 completed → 수주 상태 동기화 → 출하 자동 생성 + */ + private function autoCompleteWorkOrderIfAllStepsDone(WorkOrder $workOrder, int $tenantId, int $userId): bool + { + // 이미 완료/출하 상태면 스킵 + if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) { + return false; + } + + // 해당 작업지시의 모든 공정 단계 조회 + $allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get(); + + if ($allSteps->isEmpty()) { + return false; + } + + // 미완료 step 자동 보정: 같은 개소(work_order_item)의 다른 step이 모두 완료된 경우 + // 자재투입 등 모달 방식 step이 DB에 waiting으로 남아있을 수 있음 + $incompleteSteps = $allSteps->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED); + if ($incompleteSteps->isNotEmpty()) { + $this->autoCompleteOrphanedSteps($allSteps, $incompleteSteps, $userId); + + // 보정 후 다시 확인 + $allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get(); + $allCompleted = $allSteps->every(fn ($step) => $step->status === WorkOrderStepProgress::STATUS_COMPLETED); + + if (! $allCompleted) { + return false; + } + } + + // 작업지시 완료 처리 (updateStatus 재사용으로 출하 생성/수주 동기화 모두 트리거) + $this->updateStatus($workOrder->id, WorkOrder::STATUS_COMPLETED); + + return true; + } + + /** + * 같은 개소(work_order_item)의 나머지 step이 모두 완료되었으면 + * 남은 미완료 step(자재투입 등)도 자동 완료 처리 + */ + private function autoCompleteOrphanedSteps($allSteps, $incompleteSteps, int $userId): void + { + // 개소(item)별로 그룹핑 + $stepsByItem = $allSteps->groupBy('work_order_item_id'); + + foreach ($incompleteSteps as $incomplete) { + $itemSteps = $stepsByItem->get($incomplete->work_order_item_id); + if (! $itemSteps) { + continue; + } + + // 이 개소에서 이 step만 미완료인지 확인 + $otherIncomplete = $itemSteps->where('id', '!=', $incomplete->id) + ->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED); + + if ($otherIncomplete->isEmpty()) { + // 이 step만 남았으면 자동 완료 + $incomplete->status = WorkOrderStepProgress::STATUS_COMPLETED; + $incomplete->completed_at = now(); + $incomplete->completed_by = $userId; + $incomplete->save(); + } + } + } + /** * 자재 투입 이력 조회 */ diff --git a/lang/ko/error.php b/lang/ko/error.php index bc537ea..0b824c8 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -99,6 +99,9 @@ 'cannot_delete' => '현재 상태에서는 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 상태입니다.', 'cannot_ship' => '출하 가능 상태가 아닙니다.', + 'order_not_produced' => '생산완료 상태의 수주만 출하를 생성할 수 있습니다.', + 'no_work_orders' => '해당 수주에 유효한 작업지시가 없습니다.', + 'already_exists' => '이미 해당 수주에 출하가 존재합니다.', ], // 파일 관리 관련 diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 73b66fd..f148d75 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -119,6 +119,7 @@ Route::get('/options/logistics', [ShipmentController::class, 'logisticsOptions'])->name('v1.shipments.options.logistics'); Route::get('/options/vehicle-tonnage', [ShipmentController::class, 'vehicleTonnageOptions'])->name('v1.shipments.options.vehicle-tonnage'); Route::post('', [ShipmentController::class, 'store'])->name('v1.shipments.store'); + Route::post('/from-order/{orderId}', [ShipmentController::class, 'createFromOrder'])->whereNumber('orderId')->name('v1.shipments.from-order'); Route::get('/{id}', [ShipmentController::class, 'show'])->whereNumber('id')->name('v1.shipments.show'); Route::put('/{id}', [ShipmentController::class, 'update'])->whereNumber('id')->name('v1.shipments.update'); Route::patch('/{id}/status', [ShipmentController::class, 'updateStatus'])->whereNumber('id')->name('v1.shipments.status');