feat(WEB): 절곡품 선생산→재고적재 Phase 1 - 생산입고 기반 구축

- StockTransaction: REASON_PRODUCTION_OUTPUT 상수 및 '생산입고' 라벨 추가
- StockLot: work_order_id FK 컬럼 마이그레이션 + 모델 fillable/casts/relation 추가
- StockService: increaseFromProduction() 메서드 구현 (increaseFromReceiving 기반)
- WorkOrderService: 완료 시 sales_order_id 유무에 따라 출하/재고입고 분기
  - stockInFromProduction(): 품목별 양품 재고 입고 처리
  - shouldStockIn(): items.options 기반 입고 대상 판단

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 15:32:24 +09:00
parent ba49313ffa
commit 8be54c3b8b
5 changed files with 259 additions and 23 deletions

View File

@@ -24,7 +24,8 @@ class WorkOrderService extends Service
private const AUDIT_TARGET = 'work_order';
public function __construct(
private readonly AuditLogger $auditLogger
private readonly AuditLogger $auditLogger,
private readonly StockService $stockService
) {}
/**
@@ -587,15 +588,62 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
// 연결된 수주(Order) 상태 동기화
$this->syncOrderStatus($workOrder, $tenantId);
// 작업완료 시 자동 출하 생성
// 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고
if ($status === WorkOrder::STATUS_COMPLETED) {
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
if ($workOrder->sales_order_id) {
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
} else {
$this->stockInFromProduction($workOrder);
}
}
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
});
}
/**
* 선생산 작업지시 완료 시 완성품을 재고로 입고
*
* 수주 없는 작업지시(sales_order_id = null)가 완료되면
* 각 품목의 양품 수량을 재고 시스템에 입고 처리합니다.
*/
private function stockInFromProduction(WorkOrder $workOrder): void
{
$workOrder->loadMissing('items.item');
foreach ($workOrder->items as $woItem) {
if ($this->shouldStockIn($woItem)) {
$resultData = $woItem->options['result'] ?? [];
$goodQty = (float) ($resultData['good_qty'] ?? $woItem->quantity);
$lotNo = $resultData['lot_no'] ?? '';
if ($goodQty > 0 && $lotNo) {
$this->stockService->increaseFromProduction(
$workOrder, $woItem, $goodQty, $lotNo
);
}
}
}
}
/**
* 품목이 생산입고 대상인지 판단
*
* items.options의 production_source와 lot_managed 속성으로 판단.
*/
private function shouldStockIn(WorkOrderItem $woItem): bool
{
$item = $woItem->item;
if (! $item) {
return false;
}
$options = $item->options ?? [];
return ($options['production_source'] ?? null) === 'self_produced'
&& ($options['lot_managed'] ?? false) === true;
}
/**
* 작업지시 완료 시 자동 출하 생성
*
@@ -2193,8 +2241,8 @@ public function getInspectionReport(int $workOrderId): array
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['order', 'items' => function ($q) {
$q->ordered();
->with(['salesOrder', 'items' => function ($q) {
$q->ordered()->with('sourceOrderItem');
}])
->find($workOrderId);
@@ -2202,18 +2250,61 @@ public function getInspectionReport(int $workOrderId): array
throw new NotFoundHttpException(__('error.not_found'));
}
$items = $workOrder->items->map(function ($item) {
return [
'id' => $item->id,
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'sort_order' => $item->sort_order,
'status' => $item->status,
'options' => $item->options,
'inspection_data' => $item->getInspectionData(),
// 개소(order_node_id)별 그룹핑 — WorkerScreen과 동일한 구조
$grouped = $workOrder->items->groupBy(
fn ($item) => $item->sourceOrderItem?->order_node_id ?? 'unassigned'
);
$nodeIds = $grouped->keys()->filter(fn ($k) => $k !== 'unassigned')->values()->all();
$nodes = ! empty($nodeIds)
? \App\Models\Orders\OrderNode::whereIn('id', $nodeIds)->get()->keyBy('id')
: collect();
$nodeGroups = [];
foreach ($grouped as $nodeId => $groupItems) {
$node = $nodeId !== 'unassigned' ? $nodes->get($nodeId) : null;
$nodeOpts = $node?->options ?? [];
$firstItem = $groupItems->first();
$soi = $firstItem->sourceOrderItem;
$floorCode = $soi?->floor_code ?? '-';
$symbolCode = $soi?->symbol_code ?? '-';
$floorLabel = collect([$floorCode, $symbolCode])
->filter(fn ($v) => $v && $v !== '-')->join('/');
$nodeGroups[] = [
'node_id' => $nodeId !== 'unassigned' ? (int) $nodeId : null,
'node_name' => $floorLabel ?: ($node?->name ?? '미지정'),
'floor' => $nodeOpts['floor'] ?? $floorCode,
'code' => $nodeOpts['symbol'] ?? $symbolCode,
'width' => $nodeOpts['width'] ?? 0,
'height' => $nodeOpts['height'] ?? 0,
'total_quantity' => $groupItems->sum('quantity'),
'options' => $nodeOpts,
'items' => $groupItems->map(fn ($item) => [
'id' => $item->id,
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'sort_order' => $item->sort_order,
'status' => $item->status,
'options' => $item->options,
'inspection_data' => $item->getInspectionData(),
])->values()->all(),
];
});
}
// 플랫 아이템 목록 (summary 계산용)
$items = $workOrder->items->map(fn ($item) => [
'id' => $item->id,
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'sort_order' => $item->sort_order,
'status' => $item->status,
'options' => $item->options,
'inspection_data' => $item->getInspectionData(),
]);
return [
'work_order' => [
@@ -2223,13 +2314,14 @@ public function getInspectionReport(int $workOrderId): array
'planned_date' => $workOrder->planned_date,
'due_date' => $workOrder->due_date,
],
'order' => $workOrder->order ? [
'id' => $workOrder->order->id,
'order_no' => $workOrder->order->order_no,
'client_name' => $workOrder->order->client_name ?? null,
'site_name' => $workOrder->order->site_name ?? null,
'order_date' => $workOrder->order->order_date ?? null,
'order' => $workOrder->salesOrder ? [
'id' => $workOrder->salesOrder->id,
'order_no' => $workOrder->salesOrder->order_no,
'client_name' => $workOrder->salesOrder->client_name ?? null,
'site_name' => $workOrder->salesOrder->site_name ?? null,
'order_date' => $workOrder->salesOrder->order_date ?? null,
] : null,
'node_groups' => $nodeGroups,
'items' => $items,
'summary' => [
'total_items' => $items->count(),