- ShipmentController: 출하 CRUD 및 상태 관리 API - ShipmentService: 출하 비즈니스 로직 - Shipment, ShipmentItem 모델 - FormRequest 검증 클래스 - Swagger 문서화 - shipments, shipment_items 테이블 마이그레이션 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
437 lines
15 KiB
PHP
437 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Tenants\Shipment;
|
|
use App\Models\Tenants\ShipmentItem;
|
|
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');
|
|
|
|
// 검색어 필터
|
|
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톤'],
|
|
];
|
|
}
|
|
}
|