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>
This commit is contained in:
2026-03-18 14:56:07 +09:00
parent abf5b6896e
commit 59469d4bf6
3 changed files with 104 additions and 47 deletions

View File

@@ -88,6 +88,7 @@ class Shipment extends Model
public const STATUSES = [
'scheduled' => '출고예정',
'ready' => '출하대기',
'cancelled' => '취소',
'shipping' => '배송중',
'completed' => '배송완료',
];

View File

@@ -162,7 +162,7 @@ public function show(int $id): Shipment
{
$tenantId = $this->tenantId();
return Shipment::query()
$shipment = Shipment::query()
->where('tenant_id', $tenantId)
->with([
'items' => function ($query) {
@@ -171,11 +171,36 @@ public function show(int $id): Shipment
'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;
}
/**

View File

@@ -571,6 +571,11 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
switch ($status) {
case WorkOrder::STATUS_IN_PROGRESS:
$workOrder->started_at = $workOrder->started_at ?? now();
// 완료 → 진행중 역전환 시: completed_at 초기화 + 품목 result 초기화
if ($oldStatus === WorkOrder::STATUS_COMPLETED) {
$workOrder->completed_at = null;
$this->clearItemResults($workOrder);
}
break;
case WorkOrder::STATUS_COMPLETED:
// Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략)
@@ -750,55 +755,30 @@ private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $ten
'updated_by' => $userId,
]);
// 모든 메인 작업지시의 품목을 출하 품목으로 복사
// 수주 품목(order_items)에서 출하 품목 생성
// 출하는 수주와 직접 연결 — 생산(WorkOrder)은 트리거만 제공
$orderItems = $order->items()->orderBy('sort_order')->get();
$seq = 0;
foreach ($mainWorkOrders as $wo) {
$workOrderItems = $wo->items()->get();
foreach ($workOrderItems as $woItem) {
$result = $woItem->options['result'] ?? [];
$lotNo = $result['lot_no'] ?? null;
$floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId);
foreach ($orderItems as $orderItem) {
$floorParts = array_filter([$orderItem->floor_code, $orderItem->symbol_code]);
$floorUnit = ! empty($floorParts) ? implode('/', $floorParts) : null;
ShipmentItem::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => ++$seq,
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
'item_name' => $woItem->item_name,
'floor_unit' => $floorUnit,
'specification' => $woItem->specification,
'quantity' => $result['good_qty'] ?? $woItem->quantity,
'unit' => $woItem->unit,
'lot_no' => $lotNo,
'order_item_id' => $woItem->source_order_item_id,
'work_order_item_id' => $woItem->id,
'remarks' => null,
]);
}
// WO에 품목이 없으면 수주 품목에서 fallback (해당 WO의 공정에 매칭되는 품목)
if ($workOrderItems->isEmpty() && $wo->salesOrder) {
$orderItems = $wo->salesOrder->items()->get();
foreach ($orderItems as $orderItem) {
$floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId);
ShipmentItem::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => ++$seq,
'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null,
'item_name' => $orderItem->item_name,
'floor_unit' => $floorUnit,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,
'unit' => $orderItem->unit,
'lot_no' => null,
'order_item_id' => $orderItem->id,
'work_order_item_id' => null,
'remarks' => null,
]);
}
}
ShipmentItem::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => ++$seq,
'item_code' => $orderItem->item_code,
'item_name' => $orderItem->item_name,
'floor_unit' => $floorUnit,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,
'unit' => $orderItem->unit,
'lot_no' => null,
'order_item_id' => $orderItem->id,
'work_order_item_id' => null,
'remarks' => null,
]);
}
$this->auditLogger->log(
@@ -1067,6 +1047,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
if ($newOrderStatus === Order::STATUS_PRODUCED) {
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
}
// 생산완료 → 생산중 역전환 시 → ready 상태 출하 삭제
if ($oldOrderStatus === Order::STATUS_PRODUCED && $newOrderStatus === Order::STATUS_IN_PRODUCTION) {
$this->cancelReadyShipments($order, $tenantId);
}
}
/**
@@ -1152,6 +1137,52 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
return $lotNo;
}
/**
* 생산 역전환 시 ready 상태 출하를 cancelled로 변경
*
* 아직 배차/출하 진행 전(ready)인 출하만 취소 처리.
* shipping/completed 등 이미 진행된 출하는 건드리지 않음.
*/
private function cancelReadyShipments(Order $order, int $tenantId): void
{
$readyShipments = Shipment::where('tenant_id', $tenantId)
->where('order_id', $order->id)
->where('status', 'ready')
->get();
foreach ($readyShipments as $shipment) {
$shipment->update([
'status' => 'cancelled',
'updated_by' => $this->apiUserId(),
]);
$this->auditLogger->log(
$tenantId,
'shipment',
$shipment->id,
'auto_cancelled_on_production_rollback',
['status' => 'ready', 'order_id' => $order->id],
['status' => 'cancelled']
);
}
}
/**
* 작업지시 품목의 result 데이터 초기화 (완료 → 진행중 역전환 시)
*/
private function clearItemResults(WorkOrder $workOrder): void
{
$items = $workOrder->items;
foreach ($items as $item) {
$options = $item->options ?? [];
unset($options['result']);
$item->options = $options;
$item->status = WorkOrderItem::STATUS_IN_PROGRESS;
$item->save();
}
}
/**
* 보조 공정(재고생산 등) 여부 판단
*/