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:
@@ -88,6 +88,7 @@ class Shipment extends Model
|
||||
public const STATUSES = [
|
||||
'scheduled' => '출고예정',
|
||||
'ready' => '출하대기',
|
||||
'cancelled' => '취소',
|
||||
'shipping' => '배송중',
|
||||
'completed' => '배송완료',
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보조 공정(재고생산 등) 여부 판단
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user