Files
sam-api/app/Services/ShipmentService.php
kent aca0902c26 feat: H-3 출하 관리 API 구현
- 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>
2025-12-26 15:46:07 +09:00

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톤'],
];
}
}