- VehicleDispatchService: index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update - VehicleDispatchController + VehicleDispatchUpdateRequest - options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer) - ShipmentService.syncDispatches에 options 필드 지원 추가 - inventory.php에 vehicle-dispatches 라우트 4개 등록 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
584 lines
21 KiB
PHP
584 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Orders\Order;
|
|
use App\Models\Tenants\Shipment;
|
|
use App\Models\Tenants\ShipmentItem;
|
|
use App\Models\Tenants\ShipmentVehicleDispatch;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ShipmentService extends Service
|
|
{
|
|
/**
|
|
* 출하 목록 조회
|
|
*/
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Shipment::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']);
|
|
|
|
// 검색어 필터
|
|
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,
|
|
// 프론트엔드 호환 필드 (snake_case)
|
|
'today_shipment_count' => $todayScheduled,
|
|
'scheduled_count' => $scheduled,
|
|
'shipping_count' => $shipping,
|
|
'urgent_count' => $urgent,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 상태별 통계 (탭용)
|
|
*/
|
|
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');
|
|
},
|
|
'vehicleDispatches',
|
|
'order.client',
|
|
'order.writer',
|
|
'workOrder',
|
|
'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);
|
|
}
|
|
|
|
// 배차정보 추가
|
|
if (! empty($data['vehicle_dispatches'])) {
|
|
$this->syncDispatches($shipment, $data['vehicle_dispatches'], $tenantId);
|
|
}
|
|
|
|
return $shipment->load(['items', 'vehicleDispatches']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 출하 수정
|
|
*/
|
|
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);
|
|
}
|
|
|
|
// 배차정보 동기화
|
|
if (isset($data['vehicle_dispatches'])) {
|
|
$this->syncDispatches($shipment, $data['vehicle_dispatches'], $tenantId);
|
|
}
|
|
|
|
return $shipment->load(['items', 'vehicleDispatches']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 출하 상태 변경
|
|
*/
|
|
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'];
|
|
}
|
|
|
|
$previousStatus = $shipment->status;
|
|
$shipment->update($updateData);
|
|
|
|
// 🆕 출하완료 시 재고 차감 (FIFO)
|
|
if ($status === 'completed' && $previousStatus !== 'completed') {
|
|
$this->decreaseStockForShipment($shipment);
|
|
}
|
|
|
|
// 연결된 수주(Order) 상태 동기화
|
|
$this->syncOrderStatus($shipment, $tenantId);
|
|
|
|
return $shipment->load(['items', 'vehicleDispatches']);
|
|
}
|
|
|
|
/**
|
|
* 출하 완료 시 재고 차감
|
|
*/
|
|
private function decreaseStockForShipment(Shipment $shipment): void
|
|
{
|
|
$stockService = app(StockService::class);
|
|
|
|
// 출하 품목 조회
|
|
$items = $shipment->items;
|
|
|
|
foreach ($items as $item) {
|
|
// item_id가 없는 경우 item_code로 품목 조회 시도
|
|
$itemId = $item->item_id;
|
|
|
|
if (! $itemId && $item->item_code) {
|
|
$tenantId = $this->tenantId();
|
|
$foundItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
|
->where('code', $item->item_code)
|
|
->first();
|
|
$itemId = $foundItem?->id;
|
|
}
|
|
|
|
if (! $itemId || ! $item->quantity) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$stockService->decreaseForShipment(
|
|
itemId: $itemId,
|
|
qty: (float) $item->quantity,
|
|
shipmentId: $shipment->id,
|
|
stockLotId: $item->stock_lot_id
|
|
);
|
|
} catch (\Exception $e) {
|
|
// 재고 부족 등의 에러는 로그만 기록하고 계속 진행
|
|
\Illuminate\Support\Facades\Log::warning('Failed to decrease stock for shipment item', [
|
|
'shipment_id' => $shipment->id,
|
|
'item_code' => $item->item_code,
|
|
'quantity' => $item->quantity,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 출하 상태 변경 시 연결된 수주(Order) 상태 동기화
|
|
*
|
|
* 매핑 규칙:
|
|
* - 'shipping' → Order::STATUS_SHIPPING (출하중)
|
|
* - 'completed' → Order::STATUS_SHIPPED (출하완료)
|
|
*/
|
|
private function syncOrderStatus(Shipment $shipment, int $tenantId): void
|
|
{
|
|
// 수주 연결이 없으면 스킵
|
|
if (! $shipment->order_id) {
|
|
return;
|
|
}
|
|
|
|
$order = Order::where('tenant_id', $tenantId)->find($shipment->order_id);
|
|
if (! $order) {
|
|
return;
|
|
}
|
|
|
|
// 출하 상태 → 수주 상태 매핑
|
|
$statusMap = [
|
|
'shipping' => Order::STATUS_SHIPPING,
|
|
'completed' => Order::STATUS_SHIPPED,
|
|
];
|
|
|
|
$newOrderStatus = $statusMap[$shipment->status] ?? null;
|
|
|
|
// 매핑되는 상태가 없거나 이미 동일한 상태면 스킵
|
|
if (! $newOrderStatus || $order->status_code === $newOrderStatus) {
|
|
return;
|
|
}
|
|
|
|
$order->status_code = $newOrderStatus;
|
|
$order->updated_by = $this->apiUserId();
|
|
$order->save();
|
|
}
|
|
|
|
/**
|
|
* 출하 삭제
|
|
*/
|
|
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->vehicleDispatches()->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_id' => $item['item_id'] ?? null,
|
|
'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++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 배차정보 동기화
|
|
*/
|
|
protected function syncDispatches(Shipment $shipment, array $dispatches, int $tenantId): void
|
|
{
|
|
// 기존 배차정보 삭제
|
|
$shipment->vehicleDispatches()->forceDelete();
|
|
|
|
// 새 배차정보 생성
|
|
$seq = 1;
|
|
foreach ($dispatches as $dispatch) {
|
|
ShipmentVehicleDispatch::create([
|
|
'tenant_id' => $tenantId,
|
|
'shipment_id' => $shipment->id,
|
|
'seq' => $dispatch['seq'] ?? $seq,
|
|
'logistics_company' => $dispatch['logistics_company'] ?? null,
|
|
'arrival_datetime' => $dispatch['arrival_datetime'] ?? null,
|
|
'tonnage' => $dispatch['tonnage'] ?? null,
|
|
'vehicle_no' => $dispatch['vehicle_no'] ?? null,
|
|
'driver_contact' => $dispatch['driver_contact'] ?? null,
|
|
'remarks' => $dispatch['remarks'] ?? null,
|
|
'options' => $dispatch['options'] ?? 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톤'],
|
|
];
|
|
}
|
|
}
|