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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 이력 조회
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user