feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선

- 출하를 작업지시(WO) 단위 → 수주(Order) 단위로 변경
  - createShipmentFromOrder: 모든 메인 WO 품목을 통합하여 출하 1건 생성
  - 출하에 수주 정보 복사 안함 (order_info accessor로 조인 참조)
- syncOrderStatus에서 PRODUCED 전환 시 자동 출하 생성
  - ensureShipmentExists: 이미 PRODUCED인데 출하 없으면 재생성
- POST /shipments/from-order/{orderId} 수동 출하 생성 API 추가
  - createShipmentForOrder: 상태 검증 + 작업지시 조회 + 출하 생성
- Shipment order_info accessor 확장 (receiver, delivery_address_detail, delivery_method)
- ShipmentService index에 creator 관계 추가 (목록 작성자 표시)
- autoCompleteWorkOrderIfAllStepsDone: 전체 step 완료 시 WO 자동완료
- autoCompleteOrphanedSteps: 고아 step 자동보정
- syncOrderStatus: 공정 미지정 WO 바이패스
- ApiResponse::success 201 인자 오류 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 01:32:22 +09:00
parent 12373edf8c
commit 479059747b
6 changed files with 298 additions and 21 deletions

View File

@@ -602,13 +602,9 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
// 연결된 수주(Order) 상태 동기화
$this->syncOrderStatus($workOrder, $tenantId);
// 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고
if ($status === WorkOrder::STATUS_COMPLETED) {
if ($workOrder->sales_order_id) {
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
} else {
$this->stockInFromProduction($workOrder);
}
// 작업완료 시: 선생산(수주 없음) → 재고 입고
if ($status === WorkOrder::STATUS_COMPLETED && ! $workOrder->sales_order_id) {
$this->stockInFromProduction($workOrder);
}
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
@@ -659,11 +655,167 @@ private function shouldStockIn(WorkOrderItem $woItem): bool
}
/**
* 작업지시 완료 시 자동 출하 생성
* PRODUCED 수주에 출하가 없으면 재생성
*
* 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다.
* 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다.
* (Shipment 모델의 accessor 메서드로 수주 정보 참조)
* syncOrderStatus에서 이미 PRODUCED인데 출하가 삭제된 경우 호출됩니다.
*/
private function ensureShipmentExists(Order $order, $mainWorkOrders, int $tenantId): void
{
$hasShipment = Shipment::where('tenant_id', $tenantId)
->where('order_id', $order->id)
->exists();
if (! $hasShipment) {
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
}
}
/**
* 수주 기반 출하 수동 생성 (API 엔드포인트용)
*
* 출하관리 UI에서 수주를 선택하여 출하를 수동 생성할 때 사용합니다.
* PRODUCED 이상 상태의 수주만 가능합니다.
*/
public function createShipmentForOrder(int $orderId): Shipment
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$order = Order::where('tenant_id', $tenantId)->findOrFail($orderId);
// PRODUCED 또는 SHIPPED 상태만 출하 생성 가능
$allowedStatuses = [Order::STATUS_PRODUCED, Order::STATUS_SHIPPED];
if (! in_array($order->status_code, $allowedStatuses)) {
throw new BadRequestHttpException(__('error.shipment.order_not_produced'));
}
// 메인 작업지시 조회
$allWorkOrders = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $orderId)
->where('status', '!=', WorkOrder::STATUS_CANCELLED)
->get();
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null);
if ($mainWorkOrders->isEmpty()) {
throw new BadRequestHttpException(__('error.shipment.no_work_orders'));
}
$shipment = $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $userId);
if (! $shipment) {
throw new BadRequestHttpException(__('error.shipment.already_exists'));
}
return $shipment->load('items');
}
/**
* 수주 단위 자동 출하 생성 (생산완료 시)
*
* 수주의 모든 메인 작업지시가 완료되면, 전체 WO 품목을 합쳐서 출하 1건을 생성합니다.
* - 이미 수주에 연결된 출하가 있으면 스킵 (중복 방지)
* - 부분 출고는 출하관리 UI에서 수동 생성
*
* @param \Illuminate\Support\Collection $mainWorkOrders 메인 작업지시 컬렉션
*/
private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $tenantId, int $userId): ?Shipment
{
// 이미 이 수주에 연결된 출하가 있으면 스킵
$existingShipment = Shipment::where('tenant_id', $tenantId)
->where('order_id', $order->id)
->first();
if ($existingShipment) {
return $existingShipment;
}
$shipmentNo = Shipment::generateShipmentNo($tenantId);
$shipment = Shipment::create([
'tenant_id' => $tenantId,
'shipment_no' => $shipmentNo,
'work_order_id' => null, // 수주 단위이므로 개별 WO 연결 안함
'order_id' => $order->id,
'scheduled_date' => $order->delivery_date ?? now()->toDateString(),
'status' => 'scheduled',
'priority' => 'normal',
'delivery_method' => $order->delivery_method_code ?? 'pickup',
'can_ship' => true,
'created_by' => $userId,
'updated_by' => $userId,
]);
// 모든 메인 작업지시의 품목을 출하 품목으로 복사
$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);
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,
'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,
'remarks' => null,
]);
}
}
}
$this->auditLogger->log(
$tenantId,
'shipment',
$shipment->id,
'auto_created_from_order',
null,
[
'order_id' => $order->id,
'order_no' => $order->order_no,
'shipment_no' => $shipmentNo,
'work_order_count' => $mainWorkOrders->count(),
'items_count' => $shipment->items()->count(),
]
);
return $shipment;
}
/**
* [DEPRECATED] 작업지시 단위 자동 출하 생성
*
* 수주 단위 출하(createShipmentFromOrder)로 대체됨.
* 부분 출고 등 특수 케이스에서 개별 WO 기반 출하가 필요할 경우를 위해 유지.
*/
private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment
{
@@ -842,8 +994,8 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
->where('status', '!=', WorkOrder::STATUS_CANCELLED)
->get();
// 보조 공정 제외
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo));
// 보조 공정 및 공정 미지정 작업지시 제외
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null);
if ($mainWorkOrders->isEmpty()) {
return;
@@ -869,8 +1021,17 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
$newOrderStatus = Order::STATUS_IN_PRODUCTION;
}
// 매핑되는 상태가 없거나 이미 동일한 상태면 스킵
if (! $newOrderStatus || $order->status_code === $newOrderStatus) {
// 매핑되는 상태가 없면 스킵
if (! $newOrderStatus) {
return;
}
// 이미 동일한 상태면 상태 변경은 스킵하되, PRODUCED인데 출하 없으면 재생성
if ($order->status_code === $newOrderStatus) {
if ($newOrderStatus === Order::STATUS_PRODUCED) {
$this->ensureShipmentExists($order, $mainWorkOrders, $tenantId);
}
return;
}
@@ -893,6 +1054,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
'in_progress' => $inProgressCount,
]]
);
// 생산완료(PRODUCED) 전환 시 → 수주 단위 출하 자동 생성
if ($newOrderStatus === Order::STATUS_PRODUCED) {
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
}
}
/**
@@ -981,7 +1147,7 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
*/
private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool
{
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []);
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options ?? '{}', true) ?? []);
return ! empty($options['is_auxiliary']);
}
@@ -1875,15 +2041,92 @@ public function toggleStepProgress(int $workOrderId, int $progressId): array
$after
);
// 모든 공정 단계 완료 시 → 작업지시 자동 완료
$workOrderStatusChanged = false;
if ($progress->isCompleted()) {
$workOrderStatusChanged = $this->autoCompleteWorkOrderIfAllStepsDone($workOrder, $tenantId, $userId);
}
return [
'id' => $progress->id,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
'work_order_status_changed' => $workOrderStatusChanged,
];
}
/**
* 모든 공정 단계 완료 시 작업지시를 자동으로 완료 처리
*
* 트리거: 마지막 공정 단계(포장 등) 완료 체크 시
* 흐름: 전 단계 완료 → 작업지시 completed → 수주 상태 동기화 → 출하 자동 생성
*/
private function autoCompleteWorkOrderIfAllStepsDone(WorkOrder $workOrder, int $tenantId, int $userId): bool
{
// 이미 완료/출하 상태면 스킵
if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) {
return false;
}
// 해당 작업지시의 모든 공정 단계 조회
$allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get();
if ($allSteps->isEmpty()) {
return false;
}
// 미완료 step 자동 보정: 같은 개소(work_order_item)의 다른 step이 모두 완료된 경우
// 자재투입 등 모달 방식 step이 DB에 waiting으로 남아있을 수 있음
$incompleteSteps = $allSteps->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED);
if ($incompleteSteps->isNotEmpty()) {
$this->autoCompleteOrphanedSteps($allSteps, $incompleteSteps, $userId);
// 보정 후 다시 확인
$allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get();
$allCompleted = $allSteps->every(fn ($step) => $step->status === WorkOrderStepProgress::STATUS_COMPLETED);
if (! $allCompleted) {
return false;
}
}
// 작업지시 완료 처리 (updateStatus 재사용으로 출하 생성/수주 동기화 모두 트리거)
$this->updateStatus($workOrder->id, WorkOrder::STATUS_COMPLETED);
return true;
}
/**
* 같은 개소(work_order_item)의 나머지 step이 모두 완료되었으면
* 남은 미완료 step(자재투입 등)도 자동 완료 처리
*/
private function autoCompleteOrphanedSteps($allSteps, $incompleteSteps, int $userId): void
{
// 개소(item)별로 그룹핑
$stepsByItem = $allSteps->groupBy('work_order_item_id');
foreach ($incompleteSteps as $incomplete) {
$itemSteps = $stepsByItem->get($incomplete->work_order_item_id);
if (! $itemSteps) {
continue;
}
// 이 개소에서 이 step만 미완료인지 확인
$otherIncomplete = $itemSteps->where('id', '!=', $incomplete->id)
->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED);
if ($otherIncomplete->isEmpty()) {
// 이 step만 남았으면 자동 완료
$incomplete->status = WorkOrderStepProgress::STATUS_COMPLETED;
$incomplete->completed_at = now();
$incomplete->completed_by = $userId;
$incomplete->save();
}
}
}
/**
* 자재 투입 이력 조회
*/