diff --git a/app/Http/Controllers/Api/V1/ShipmentController.php b/app/Http/Controllers/Api/V1/ShipmentController.php new file mode 100644 index 0000000..6262700 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ShipmentController.php @@ -0,0 +1,164 @@ +only([ + 'search', + 'status', + 'priority', + 'delivery_method', + 'scheduled_from', + 'scheduled_to', + 'can_ship', + 'deposit_confirmed', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $shipments = $this->service->index($params); + + return ApiResponse::success($shipments, __('message.fetched')); + } + + /** + * 출하 통계 조회 + */ + public function stats(): JsonResponse + { + $stats = $this->service->stats(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 상태별 통계 조회 (탭용) + */ + public function statsByStatus(): JsonResponse + { + $stats = $this->service->statsByStatus(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 출하 상세 조회 + */ + public function show(int $id): JsonResponse + { + try { + $shipment = $this->service->show($id); + + return ApiResponse::success($shipment, __('message.fetched')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.shipment.not_found'), 404); + } + } + + /** + * 출하 생성 + */ + public function store(ShipmentStoreRequest $request): JsonResponse + { + $shipment = $this->service->store($request->validated()); + + return ApiResponse::success($shipment, __('message.created'), 201); + } + + /** + * 출하 수정 + */ + public function update(ShipmentUpdateRequest $request, int $id): JsonResponse + { + try { + $shipment = $this->service->update($id, $request->validated()); + + return ApiResponse::success($shipment, __('message.updated')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.shipment.not_found'), 404); + } + } + + /** + * 출하 상태 변경 + */ + public function updateStatus(ShipmentUpdateStatusRequest $request, int $id): JsonResponse + { + try { + $shipment = $this->service->updateStatus( + $id, + $request->validated('status'), + $request->validated() + ); + + return ApiResponse::success($shipment, __('message.updated')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.shipment.not_found'), 404); + } + } + + /** + * 출하 삭제 + */ + public function destroy(int $id): JsonResponse + { + try { + $this->service->delete($id); + + return ApiResponse::success(null, __('message.deleted')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.shipment.not_found'), 404); + } + } + + /** + * LOT 옵션 조회 + */ + public function lotOptions(): JsonResponse + { + $options = $this->service->getLotOptions(); + + return ApiResponse::success($options, __('message.fetched')); + } + + /** + * 물류사 옵션 조회 + */ + public function logisticsOptions(): JsonResponse + { + $options = $this->service->getLogisticsOptions(); + + return ApiResponse::success($options, __('message.fetched')); + } + + /** + * 차량 톤수 옵션 조회 + */ + public function vehicleTonnageOptions(): JsonResponse + { + $options = $this->service->getVehicleTonnageOptions(); + + return ApiResponse::success($options, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Shipment/ShipmentStoreRequest.php b/app/Http/Requests/Shipment/ShipmentStoreRequest.php new file mode 100644 index 0000000..a00fbc7 --- /dev/null +++ b/app/Http/Requests/Shipment/ShipmentStoreRequest.php @@ -0,0 +1,80 @@ + 'nullable|string|max:50', + 'lot_no' => 'nullable|string|max:50', + 'order_id' => 'nullable|integer|exists:orders,id', + 'scheduled_date' => 'required|date', + 'status' => 'nullable|in:scheduled,ready,shipping,completed', + 'priority' => 'nullable|in:urgent,normal,low', + 'delivery_method' => 'nullable|in:pickup,direct,logistics', + + // 발주처/배송 정보 + 'client_id' => 'nullable|integer|exists:clients,id', + 'customer_name' => 'nullable|string|max:100', + 'site_name' => 'nullable|string|max:100', + 'delivery_address' => 'nullable|string|max:255', + 'receiver' => 'nullable|string|max:50', + 'receiver_contact' => 'nullable|string|max:50', + + // 상태 플래그 + 'can_ship' => 'nullable|boolean', + 'deposit_confirmed' => 'nullable|boolean', + 'invoice_issued' => 'nullable|boolean', + 'customer_grade' => 'nullable|string|max:20', + + // 상차 정보 + 'loading_manager' => 'nullable|string|max:50', + 'loading_time' => 'nullable|date', + + // 물류/배차 정보 + 'logistics_company' => 'nullable|string|max:50', + 'vehicle_tonnage' => 'nullable|string|max:20', + 'shipping_cost' => 'nullable|numeric|min:0', + + // 차량/운전자 정보 + 'vehicle_no' => 'nullable|string|max:20', + 'driver_name' => 'nullable|string|max:50', + 'driver_contact' => 'nullable|string|max:50', + 'expected_arrival' => 'nullable|date', + + // 기타 + 'remarks' => 'nullable|string', + + // 출하 품목 + 'items' => 'nullable|array', + 'items.*.seq' => 'nullable|integer|min:1', + 'items.*.item_code' => 'nullable|string|max:50', + 'items.*.item_name' => 'required|string|max:100', + 'items.*.floor_unit' => 'nullable|string|max:50', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit' => 'nullable|string|max:20', + 'items.*.lot_no' => 'nullable|string|max:50', + 'items.*.stock_lot_id' => 'nullable|integer|exists:stock_lots,id', + 'items.*.remarks' => 'nullable|string', + ]; + } + + public function messages(): array + { + return [ + 'scheduled_date.required' => __('validation.required', ['attribute' => '출고예정일']), + 'items.*.item_name.required' => __('validation.required', ['attribute' => '품목명']), + ]; + } +} diff --git a/app/Http/Requests/Shipment/ShipmentUpdateRequest.php b/app/Http/Requests/Shipment/ShipmentUpdateRequest.php new file mode 100644 index 0000000..f86c4a2 --- /dev/null +++ b/app/Http/Requests/Shipment/ShipmentUpdateRequest.php @@ -0,0 +1,70 @@ + 'nullable|string|max:50', + 'order_id' => 'nullable|integer|exists:orders,id', + 'scheduled_date' => 'nullable|date', + 'priority' => 'nullable|in:urgent,normal,low', + 'delivery_method' => 'nullable|in:pickup,direct,logistics', + + // 발주처/배송 정보 + 'client_id' => 'nullable|integer|exists:clients,id', + 'customer_name' => 'nullable|string|max:100', + 'site_name' => 'nullable|string|max:100', + 'delivery_address' => 'nullable|string|max:255', + 'receiver' => 'nullable|string|max:50', + 'receiver_contact' => 'nullable|string|max:50', + + // 상태 플래그 + 'can_ship' => 'nullable|boolean', + 'deposit_confirmed' => 'nullable|boolean', + 'invoice_issued' => 'nullable|boolean', + 'customer_grade' => 'nullable|string|max:20', + + // 상차 정보 + 'loading_manager' => 'nullable|string|max:50', + 'loading_time' => 'nullable|date', + + // 물류/배차 정보 + 'logistics_company' => 'nullable|string|max:50', + 'vehicle_tonnage' => 'nullable|string|max:20', + 'shipping_cost' => 'nullable|numeric|min:0', + + // 차량/운전자 정보 + 'vehicle_no' => 'nullable|string|max:20', + 'driver_name' => 'nullable|string|max:50', + 'driver_contact' => 'nullable|string|max:50', + 'expected_arrival' => 'nullable|date', + + // 기타 + 'remarks' => 'nullable|string', + + // 출하 품목 + 'items' => 'nullable|array', + 'items.*.seq' => 'nullable|integer|min:1', + 'items.*.item_code' => 'nullable|string|max:50', + 'items.*.item_name' => 'required|string|max:100', + 'items.*.floor_unit' => 'nullable|string|max:50', + 'items.*.specification' => 'nullable|string|max:100', + 'items.*.quantity' => 'nullable|numeric|min:0', + 'items.*.unit' => 'nullable|string|max:20', + 'items.*.lot_no' => 'nullable|string|max:50', + 'items.*.stock_lot_id' => 'nullable|integer|exists:stock_lots,id', + 'items.*.remarks' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/Shipment/ShipmentUpdateStatusRequest.php b/app/Http/Requests/Shipment/ShipmentUpdateStatusRequest.php new file mode 100644 index 0000000..7589400 --- /dev/null +++ b/app/Http/Requests/Shipment/ShipmentUpdateStatusRequest.php @@ -0,0 +1,36 @@ + 'required|in:scheduled,ready,shipping,completed', + + // 상태별 추가 데이터 + 'loading_time' => 'nullable|date', + 'loading_completed_at' => 'nullable|date', + 'vehicle_no' => 'nullable|string|max:20', + 'driver_name' => 'nullable|string|max:50', + 'driver_contact' => 'nullable|string|max:50', + 'confirmed_arrival' => 'nullable|date', + ]; + } + + public function messages(): array + { + return [ + 'status.required' => __('validation.required', ['attribute' => '상태']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + ]; + } +} diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php new file mode 100644 index 0000000..54c3144 --- /dev/null +++ b/app/Models/Tenants/Shipment.php @@ -0,0 +1,210 @@ + 'date', + 'can_ship' => 'boolean', + 'deposit_confirmed' => 'boolean', + 'invoice_issued' => 'boolean', + 'loading_completed_at' => 'datetime', + 'loading_time' => 'datetime', + 'expected_arrival' => 'datetime', + 'confirmed_arrival' => 'datetime', + 'shipping_cost' => 'decimal:0', + 'order_id' => 'integer', + 'client_id' => 'integer', + ]; + + /** + * 출하 상태 목록 + */ + public const STATUSES = [ + 'scheduled' => '출고예정', + 'ready' => '출하대기', + 'shipping' => '배송중', + 'completed' => '배송완료', + ]; + + /** + * 우선순위 목록 + */ + public const PRIORITIES = [ + 'urgent' => '긴급', + 'normal' => '보통', + 'low' => '낮음', + ]; + + /** + * 배송방식 목록 + */ + public const DELIVERY_METHODS = [ + 'pickup' => '상차', + 'direct' => '직접배차', + 'logistics' => '물류사', + ]; + + /** + * 출하 품목 관계 + */ + public function items(): HasMany + { + return $this->hasMany(ShipmentItem::class)->orderBy('seq'); + } + + /** + * 거래처 관계 + */ + public function client(): BelongsTo + { + return $this->belongsTo(\App\Models\Clients\Client::class); + } + + /** + * 생성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'created_by'); + } + + /** + * 수정자 관계 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'updated_by'); + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUSES[$this->status] ?? $this->status; + } + + /** + * 우선순위 라벨 + */ + public function getPriorityLabelAttribute(): string + { + return self::PRIORITIES[$this->priority] ?? $this->priority; + } + + /** + * 배송방식 라벨 + */ + public function getDeliveryMethodLabelAttribute(): string + { + return self::DELIVERY_METHODS[$this->delivery_method] ?? $this->delivery_method; + } + + /** + * 총 품목 수량 + */ + public function getTotalQuantityAttribute(): float + { + return $this->items->sum('quantity'); + } + + /** + * 품목 수 + */ + public function getItemCountAttribute(): int + { + return $this->items->count(); + } + + /** + * 긴급 여부 + */ + public function getIsUrgentAttribute(): bool + { + return $this->priority === 'urgent'; + } + + /** + * 출하 가능 여부 확인 + */ + public function canProceedToShip(): bool + { + return $this->can_ship && $this->deposit_confirmed; + } + + /** + * 새 출하번호 생성 + */ + public static function generateShipmentNo(int $tenantId): string + { + $today = now()->format('Ymd'); + $prefix = 'SHP-'.$today.'-'; + + $lastShipment = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('shipment_no', 'like', $prefix.'%') + ->orderByDesc('shipment_no') + ->first(); + + if ($lastShipment) { + $lastSeq = (int) substr($lastShipment->shipment_no, -4); + $newSeq = str_pad($lastSeq + 1, 4, '0', STR_PAD_LEFT); + } else { + $newSeq = '0001'; + } + + return $prefix.$newSeq; + } +} diff --git a/app/Models/Tenants/ShipmentItem.php b/app/Models/Tenants/ShipmentItem.php new file mode 100644 index 0000000..20d5091 --- /dev/null +++ b/app/Models/Tenants/ShipmentItem.php @@ -0,0 +1,61 @@ + 'integer', + 'quantity' => 'decimal:2', + 'shipment_id' => 'integer', + 'stock_lot_id' => 'integer', + ]; + + /** + * 출하 관계 + */ + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } + + /** + * 재고 LOT 관계 + */ + public function stockLot(): BelongsTo + { + return $this->belongsTo(StockLot::class); + } + + /** + * 다음 순번 가져오기 + */ + public static function getNextSeq(int $shipmentId): int + { + $maxSeq = static::where('shipment_id', $shipmentId)->max('seq'); + + return ($maxSeq ?? 0) + 1; + } +} diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php new file mode 100644 index 0000000..b9e2354 --- /dev/null +++ b/app/Services/ShipmentService.php @@ -0,0 +1,436 @@ +tenantId(); + + $query = Shipment::query() + ->where('tenant_id', $tenantId) + ->with('items'); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('shipment_no', 'like', "%{$search}%") + ->orWhere('lot_no', 'like', "%{$search}%") + ->orWhere('customer_name', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 우선순위 필터 + if (! empty($params['priority'])) { + $query->where('priority', $params['priority']); + } + + // 배송방식 필터 + if (! empty($params['delivery_method'])) { + $query->where('delivery_method', $params['delivery_method']); + } + + // 예정일 범위 필터 + if (! empty($params['scheduled_from'])) { + $query->where('scheduled_date', '>=', $params['scheduled_from']); + } + if (! empty($params['scheduled_to'])) { + $query->where('scheduled_date', '<=', $params['scheduled_to']); + } + + // 출하가능 필터 + if (isset($params['can_ship'])) { + $query->where('can_ship', (bool) $params['can_ship']); + } + + // 입금확인 필터 + if (isset($params['deposit_confirmed'])) { + $query->where('deposit_confirmed', (bool) $params['deposit_confirmed']); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'scheduled_date'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 출하 통계 조회 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $total = Shipment::where('tenant_id', $tenantId)->count(); + + $scheduled = Shipment::where('tenant_id', $tenantId) + ->where('status', 'scheduled') + ->count(); + + $ready = Shipment::where('tenant_id', $tenantId) + ->where('status', 'ready') + ->count(); + + $shipping = Shipment::where('tenant_id', $tenantId) + ->where('status', 'shipping') + ->count(); + + $completed = Shipment::where('tenant_id', $tenantId) + ->where('status', 'completed') + ->count(); + + $urgent = Shipment::where('tenant_id', $tenantId) + ->where('priority', 'urgent') + ->whereIn('status', ['scheduled', 'ready']) + ->count(); + + $todayScheduled = Shipment::where('tenant_id', $tenantId) + ->whereDate('scheduled_date', now()) + ->count(); + + return [ + 'total' => $total, + 'scheduled' => $scheduled, + 'ready' => $ready, + 'shipping' => $shipping, + 'completed' => $completed, + 'urgent' => $urgent, + 'today_scheduled' => $todayScheduled, + ]; + } + + /** + * 상태별 통계 (탭용) + */ + public function statsByStatus(): array + { + $tenantId = $this->tenantId(); + + $stats = Shipment::where('tenant_id', $tenantId) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->get() + ->keyBy('status'); + + $result = []; + foreach (Shipment::STATUSES as $key => $label) { + $data = $stats->get($key); + $result[$key] = [ + 'label' => $label, + 'count' => $data?->count ?? 0, + ]; + } + + return $result; + } + + /** + * 출하 상세 조회 + */ + public function show(int $id): Shipment + { + $tenantId = $this->tenantId(); + + return Shipment::query() + ->where('tenant_id', $tenantId) + ->with(['items' => function ($query) { + $query->orderBy('seq'); + }, 'creator', 'updater']) + ->findOrFail($id); + } + + /** + * 출하 생성 + */ + public function store(array $data): Shipment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 출하번호 자동 생성 + $shipmentNo = $data['shipment_no'] ?? Shipment::generateShipmentNo($tenantId); + + $shipment = Shipment::create([ + 'tenant_id' => $tenantId, + 'shipment_no' => $shipmentNo, + 'lot_no' => $data['lot_no'] ?? null, + 'order_id' => $data['order_id'] ?? null, + 'scheduled_date' => $data['scheduled_date'], + 'status' => $data['status'] ?? 'scheduled', + 'priority' => $data['priority'] ?? 'normal', + 'delivery_method' => $data['delivery_method'] ?? 'pickup', + // 발주처/배송 정보 + 'client_id' => $data['client_id'] ?? null, + 'customer_name' => $data['customer_name'] ?? null, + 'site_name' => $data['site_name'] ?? null, + 'delivery_address' => $data['delivery_address'] ?? null, + 'receiver' => $data['receiver'] ?? null, + 'receiver_contact' => $data['receiver_contact'] ?? null, + // 상태 플래그 + 'can_ship' => $data['can_ship'] ?? false, + 'deposit_confirmed' => $data['deposit_confirmed'] ?? false, + 'invoice_issued' => $data['invoice_issued'] ?? false, + 'customer_grade' => $data['customer_grade'] ?? null, + // 상차 정보 + 'loading_manager' => $data['loading_manager'] ?? null, + 'loading_time' => $data['loading_time'] ?? null, + // 물류/배차 정보 + 'logistics_company' => $data['logistics_company'] ?? null, + 'vehicle_tonnage' => $data['vehicle_tonnage'] ?? null, + 'shipping_cost' => $data['shipping_cost'] ?? null, + // 차량/운전자 정보 + 'vehicle_no' => $data['vehicle_no'] ?? null, + 'driver_name' => $data['driver_name'] ?? null, + 'driver_contact' => $data['driver_contact'] ?? null, + 'expected_arrival' => $data['expected_arrival'] ?? null, + // 기타 + 'remarks' => $data['remarks'] ?? null, + 'created_by' => $userId, + ]); + + // 출하 품목 추가 + if (! empty($data['items'])) { + $this->syncItems($shipment, $data['items'], $tenantId); + } + + return $shipment->load('items'); + }); + } + + /** + * 출하 수정 + */ + public function update(int $id, array $data): Shipment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id); + + $shipment->update([ + 'lot_no' => $data['lot_no'] ?? $shipment->lot_no, + 'order_id' => $data['order_id'] ?? $shipment->order_id, + 'scheduled_date' => $data['scheduled_date'] ?? $shipment->scheduled_date, + 'priority' => $data['priority'] ?? $shipment->priority, + 'delivery_method' => $data['delivery_method'] ?? $shipment->delivery_method, + // 발주처/배송 정보 + 'client_id' => $data['client_id'] ?? $shipment->client_id, + 'customer_name' => $data['customer_name'] ?? $shipment->customer_name, + 'site_name' => $data['site_name'] ?? $shipment->site_name, + 'delivery_address' => $data['delivery_address'] ?? $shipment->delivery_address, + 'receiver' => $data['receiver'] ?? $shipment->receiver, + 'receiver_contact' => $data['receiver_contact'] ?? $shipment->receiver_contact, + // 상태 플래그 + 'can_ship' => $data['can_ship'] ?? $shipment->can_ship, + 'deposit_confirmed' => $data['deposit_confirmed'] ?? $shipment->deposit_confirmed, + 'invoice_issued' => $data['invoice_issued'] ?? $shipment->invoice_issued, + 'customer_grade' => $data['customer_grade'] ?? $shipment->customer_grade, + // 상차 정보 + 'loading_manager' => $data['loading_manager'] ?? $shipment->loading_manager, + 'loading_time' => $data['loading_time'] ?? $shipment->loading_time, + // 물류/배차 정보 + 'logistics_company' => $data['logistics_company'] ?? $shipment->logistics_company, + 'vehicle_tonnage' => $data['vehicle_tonnage'] ?? $shipment->vehicle_tonnage, + 'shipping_cost' => $data['shipping_cost'] ?? $shipment->shipping_cost, + // 차량/운전자 정보 + 'vehicle_no' => $data['vehicle_no'] ?? $shipment->vehicle_no, + 'driver_name' => $data['driver_name'] ?? $shipment->driver_name, + 'driver_contact' => $data['driver_contact'] ?? $shipment->driver_contact, + 'expected_arrival' => $data['expected_arrival'] ?? $shipment->expected_arrival, + // 기타 + 'remarks' => $data['remarks'] ?? $shipment->remarks, + 'updated_by' => $userId, + ]); + + // 출하 품목 동기화 + if (isset($data['items'])) { + $this->syncItems($shipment, $data['items'], $tenantId); + } + + return $shipment->load('items'); + }); + } + + /** + * 출하 상태 변경 + */ + public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id); + + $updateData = [ + 'status' => $status, + 'updated_by' => $userId, + ]; + + // 상태별 추가 데이터 + if ($status === 'ready' && isset($additionalData['loading_time'])) { + $updateData['loading_time'] = $additionalData['loading_time']; + } + + if ($status === 'shipping') { + if (isset($additionalData['loading_completed_at'])) { + $updateData['loading_completed_at'] = $additionalData['loading_completed_at']; + } else { + $updateData['loading_completed_at'] = now(); + } + + if (isset($additionalData['vehicle_no'])) { + $updateData['vehicle_no'] = $additionalData['vehicle_no']; + } + if (isset($additionalData['driver_name'])) { + $updateData['driver_name'] = $additionalData['driver_name']; + } + if (isset($additionalData['driver_contact'])) { + $updateData['driver_contact'] = $additionalData['driver_contact']; + } + } + + if ($status === 'completed' && isset($additionalData['confirmed_arrival'])) { + $updateData['confirmed_arrival'] = $additionalData['confirmed_arrival']; + } + + $shipment->update($updateData); + + return $shipment->load('items'); + } + + /** + * 출하 삭제 + */ + public function delete(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id); + + return DB::transaction(function () use ($shipment, $userId) { + // 품목 삭제 + $shipment->items()->delete(); + + // 출하 삭제 + $shipment->update(['deleted_by' => $userId]); + $shipment->delete(); + + return true; + }); + } + + /** + * 출하 품목 동기화 + */ + protected function syncItems(Shipment $shipment, array $items, int $tenantId): void + { + // 기존 품목 삭제 + $shipment->items()->forceDelete(); + + // 새 품목 생성 + $seq = 1; + foreach ($items as $item) { + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => $item['seq'] ?? $seq, + 'item_code' => $item['item_code'] ?? null, + 'item_name' => $item['item_name'], + 'floor_unit' => $item['floor_unit'] ?? null, + 'specification' => $item['specification'] ?? null, + 'quantity' => $item['quantity'] ?? 0, + 'unit' => $item['unit'] ?? null, + 'lot_no' => $item['lot_no'] ?? null, + 'stock_lot_id' => $item['stock_lot_id'] ?? null, + 'remarks' => $item['remarks'] ?? null, + ]); + $seq++; + } + } + + /** + * LOT 옵션 조회 (출고 가능한 LOT 목록) + */ + public function getLotOptions(): array + { + $tenantId = $this->tenantId(); + + return \App\Models\Tenants\StockLot::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', ['available', 'reserved']) + ->where('qty', '>', 0) + ->with('stock:id,item_code,item_name') + ->orderBy('fifo_order') + ->get() + ->map(function ($lot) { + return [ + 'id' => $lot->id, + 'lot_no' => $lot->lot_no, + 'item_code' => $lot->stock?->item_code, + 'item_name' => $lot->stock?->item_name, + 'qty' => $lot->qty, + 'available_qty' => $lot->available_qty, + 'unit' => $lot->unit, + 'location' => $lot->location, + 'fifo_order' => $lot->fifo_order, + ]; + }) + ->toArray(); + } + + /** + * 물류사 옵션 조회 + */ + public function getLogisticsOptions(): array + { + // TODO: 별도 물류사 테이블이 있다면 조회 + // 현재는 기본값 반환 + return [ + ['value' => '한진택배', 'label' => '한진택배'], + ['value' => '롯데택배', 'label' => '롯데택배'], + ['value' => 'CJ대한통운', 'label' => 'CJ대한통운'], + ['value' => '우체국택배', 'label' => '우체국택배'], + ['value' => '로젠택배', 'label' => '로젠택배'], + ['value' => '경동택배', 'label' => '경동택배'], + ['value' => '직접입력', 'label' => '직접입력'], + ]; + } + + /** + * 차량 톤수 옵션 조회 + */ + public function getVehicleTonnageOptions(): array + { + return [ + ['value' => '1톤', 'label' => '1톤'], + ['value' => '2.5톤', 'label' => '2.5톤'], + ['value' => '5톤', 'label' => '5톤'], + ['value' => '11톤', 'label' => '11톤'], + ['value' => '25톤', 'label' => '25톤'], + ]; + } +} diff --git a/app/Swagger/v1/ShipmentApi.php b/app/Swagger/v1/ShipmentApi.php new file mode 100644 index 0000000..58a205d --- /dev/null +++ b/app/Swagger/v1/ShipmentApi.php @@ -0,0 +1,625 @@ +id(); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->string('shipment_no', 50)->comment('출고번호'); + $table->string('lot_no', 50)->nullable()->comment('LOT번호'); + $table->foreignId('order_id')->nullable()->comment('수주 ID'); + $table->date('scheduled_date')->comment('출고예정일'); + $table->enum('status', ['scheduled', 'ready', 'shipping', 'completed'])->default('scheduled')->comment('상태: scheduled=출고예정, ready=출하대기, shipping=배송중, completed=배송완료'); + $table->enum('priority', ['urgent', 'normal', 'low'])->default('normal')->comment('우선순위'); + $table->enum('delivery_method', ['pickup', 'direct', 'logistics'])->default('pickup')->comment('배송방식: pickup=상차, direct=직접배차, logistics=물류사'); + + // 발주처/배송 정보 + $table->foreignId('client_id')->nullable()->comment('거래처 ID'); + $table->string('customer_name', 100)->nullable()->comment('발주처명'); + $table->string('site_name', 100)->nullable()->comment('현장명'); + $table->string('delivery_address', 255)->nullable()->comment('배송주소'); + $table->string('receiver', 50)->nullable()->comment('인수자'); + $table->string('receiver_contact', 50)->nullable()->comment('인수자 연락처'); + + // 상태 플래그 + $table->boolean('can_ship')->default(false)->comment('출하가능 여부'); + $table->boolean('deposit_confirmed')->default(false)->comment('입금확인 여부'); + $table->boolean('invoice_issued')->default(false)->comment('세금계산서 발행 여부'); + $table->string('customer_grade', 20)->nullable()->comment('거래처 등급'); + + // 상차 정보 + $table->string('loading_manager', 50)->nullable()->comment('상차담당자'); + $table->datetime('loading_completed_at')->nullable()->comment('상차완료 일시'); + $table->datetime('loading_time')->nullable()->comment('상차시간(입차예정)'); + + // 물류/배차 정보 + $table->string('logistics_company', 50)->nullable()->comment('물류사'); + $table->string('vehicle_tonnage', 20)->nullable()->comment('차량 톤수'); + $table->decimal('shipping_cost', 12, 0)->nullable()->comment('운송비'); + + // 차량/운전자 정보 + $table->string('vehicle_no', 20)->nullable()->comment('차량번호'); + $table->string('driver_name', 50)->nullable()->comment('운전자명'); + $table->string('driver_contact', 50)->nullable()->comment('운전자 연락처'); + $table->datetime('expected_arrival')->nullable()->comment('입차예정시간'); + $table->datetime('confirmed_arrival')->nullable()->comment('입차확정시간'); + + // 기타 + $table->text('remarks')->nullable()->comment('비고'); + $table->foreignId('created_by')->nullable()->comment('등록자'); + $table->foreignId('updated_by')->nullable()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->unique(['tenant_id', 'shipment_no']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'scheduled_date']); + $table->index(['tenant_id', 'lot_no']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipments'); + } +}; diff --git a/database/migrations/2025_12_26_150605_create_shipment_items_table.php b/database/migrations/2025_12_26_150605_create_shipment_items_table.php new file mode 100644 index 0000000..0fd1e64 --- /dev/null +++ b/database/migrations/2025_12_26_150605_create_shipment_items_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->foreignId('shipment_id')->comment('출하 ID'); + $table->integer('seq')->default(1)->comment('순번'); + $table->string('item_code', 50)->nullable()->comment('품목코드'); + $table->string('item_name', 100)->comment('품목명'); + $table->string('floor_unit', 50)->nullable()->comment('층/M호'); + $table->string('specification', 100)->nullable()->comment('규격'); + $table->decimal('quantity', 10, 2)->default(0)->comment('수량'); + $table->string('unit', 20)->nullable()->comment('단위'); + $table->string('lot_no', 50)->nullable()->comment('LOT번호'); + $table->foreignId('stock_lot_id')->nullable()->comment('재고 LOT ID'); + $table->text('remarks')->nullable()->comment('비고'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['shipment_id', 'seq']); + $table->index(['tenant_id', 'item_code']); + + // 외래키 + $table->foreign('shipment_id')->references('id')->on('shipments')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipment_items'); + } +};