Files
sam-api/app/Services/ShipmentService.php
권혁성 6563d977ee refactor: [shipment] 배차 정보를 shipment_vehicle_dispatches로 일원화
- shipments 테이블에서 배차 관련 컬럼 8개 삭제 (vehicle_no, driver_name 등)
- shipping 전환 시 배차 정보를 vehicle_dispatches에 저장
- delivery_method ENUM → VARCHAR 변경 (common_codes 기반)
- VehicleDispatchService에 수주/작성자 관계 로딩 추가
- Swagger delivery_method enum 제약 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:15:47 +09:00

619 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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', 'creator']);
// 검색어 필터
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,
'ready_count' => $ready,
'shipping_count' => $shipping,
'completed_count' => $completed,
'urgent_count' => $urgent,
'total_count' => $total,
];
}
/**
* 상태별 통계 (탭용)
*/
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();
$shipment = Shipment::query()
->where('tenant_id', $tenantId)
->with([
'items' => function ($query) {
$query->orderBy('seq');
},
'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;
}
/**
* 출하 생성
*/
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,
// 기타
'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,
// 기타
'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);
// 출하 가능 여부 검증 (scheduled → ready 이상 전환 시)
if (in_array($status, ['ready', 'shipping', 'completed']) && ! $shipment->can_ship) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(
__('error.shipment.cannot_ship')
);
}
$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();
}
}
$previousStatus = $shipment->status;
$shipment->update($updateData);
// shipping 전환 시 배차 정보를 shipment_vehicle_dispatches에 저장
if ($status === 'shipping' && $additionalData) {
$nextSeq = $shipment->vehicleDispatches()->max('seq') ?? 0;
ShipmentVehicleDispatch::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => $nextSeq + 1,
'vehicle_no' => $additionalData['vehicle_no'] ?? null,
'driver_contact' => $additionalData['driver_contact'] ?? null,
'logistics_company' => $additionalData['logistics_company'] ?? null,
'tonnage' => $additionalData['vehicle_tonnage'] ?? null,
'arrival_datetime' => $additionalData['confirmed_arrival'] ?? null,
]);
}
// 재고 차감 비활성화: 수주생산은 재고 미경유, 선생산 완성품은 자재 투입 시 차감됨
// TODO: 선생산 로직 검증 후 재검토 (decreaseStockForShipment)
// 연결된 수주(Order) 상태 동기화
$this->syncOrderStatus($shipment, $tenantId);
return $shipment->load(['items', 'vehicleDispatches']);
}
/**
* 출하 완료 시 재고 차감
*
* 수주 연결 출하(order_id 있음)는 재고를 거치지 않으므로 차감 skip.
* 재고 출고(order_id 없음)만 재고 차감 수행.
*
* @return array 실패 내역 (빈 배열이면 전체 성공)
*/
private function decreaseStockForShipment(Shipment $shipment): array
{
// 수주 연결 출하는 재고 입고 없이 바로 출하하므로 차감하지 않음
if ($shipment->order_id) {
return [];
}
$stockService = app(StockService::class);
$failures = [];
// 출하 품목 조회
$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(),
]);
$failures[] = [
'item_code' => $item->item_code,
'item_name' => $item->item_name,
'quantity' => $item->quantity,
'reason' => $e->getMessage(),
];
}
}
return $failures;
}
/**
* 출하 상태 변경 시 연결된 수주(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톤'],
];
}
}