Files
sam-api/app/Services/ShipmentService.php
권혁성 59469d4bf6 feat: [shipment] 출하 프로세스 개선 - 수주 품목 기반 변경, 취소→cancelled 상태, 역방향 프로세스, 제품명/오픈사이즈 추가
- 출하 품목을 수주 품목(order_item_id) 기반으로 변경
- 작업 취소 시 출하를 삭제 대신 cancelled 상태로 변경
- 작업 취소 시 역방향 프로세스 구현 (WorkOrderService)
- 출하 상세 API에 제품명(product_name) 매핑 추가
- 출하 상세 제품그룹에 오픈사이즈 추가
- shipment_items 테이블에 없는 item_id 컬럼 참조 제거

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

636 lines
23 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,
// 물류/배차 정보
'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);
// 출하 가능 여부 검증 (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();
}
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);
// 재고 차감 비활성화: 수주생산은 재고 미경유, 선생산 완성품은 자재 투입 시 차감됨
// 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톤'],
];
}
}