From 8be54c3b8bfaa45cd7d370f65f1f1bd27dc3fe88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 15:32:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88=20?= =?UTF-8?q?=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20Phase=201=20-=20=EC=83=9D=EC=82=B0=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Models/Tenants/StockLot.php | 10 ++ app/Models/Tenants/StockTransaction.php | 5 +- app/Services/StockService.php | 95 ++++++++++++ app/Services/WorkOrderService.php | 136 +++++++++++++++--- ..._add_work_order_id_to_stock_lots_table.php | 36 +++++ 5 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php diff --git a/app/Models/Tenants/StockLot.php b/app/Models/Tenants/StockLot.php index 7bd28e9..4287b74 100644 --- a/app/Models/Tenants/StockLot.php +++ b/app/Models/Tenants/StockLot.php @@ -28,6 +28,7 @@ class StockLot extends Model 'location', 'status', 'receiving_id', + 'work_order_id', 'created_by', 'updated_by', 'deleted_by', @@ -41,6 +42,7 @@ class StockLot extends Model 'available_qty' => 'decimal:3', 'stock_id' => 'integer', 'receiving_id' => 'integer', + 'work_order_id' => 'integer', ]; /** @@ -68,6 +70,14 @@ public function receiving(): BelongsTo return $this->belongsTo(Receiving::class); } + /** + * 작업지시 관계 (생산입고) + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(\App\Models\Production\WorkOrder::class); + } + /** * 생성자 관계 */ diff --git a/app/Models/Tenants/StockTransaction.php b/app/Models/Tenants/StockTransaction.php index 2c5fad5..4c13c11 100644 --- a/app/Models/Tenants/StockTransaction.php +++ b/app/Models/Tenants/StockTransaction.php @@ -48,12 +48,15 @@ class StockTransaction extends Model public const REASON_ORDER_CANCEL = 'order_cancel'; + public const REASON_PRODUCTION_OUTPUT = 'production_output'; + public const REASONS = [ self::REASON_RECEIVING => '입고', self::REASON_WORK_ORDER_INPUT => '생산투입', self::REASON_SHIPMENT => '출하', self::REASON_ORDER_CONFIRM => '수주확정', self::REASON_ORDER_CANCEL => '수주취소', + self::REASON_PRODUCTION_OUTPUT => '생산입고', ]; protected $fillable = [ @@ -111,4 +114,4 @@ public function getReasonLabelAttribute(): string { return self::REASONS[$this->reason] ?? ($this->reason ?? '-'); } -} \ No newline at end of file +} diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 0a51525..72f043d 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Models\Items\Item; +use App\Models\Production\WorkOrder; +use App\Models\Production\WorkOrderItem; use App\Models\Tenants\Receiving; use App\Models\Tenants\Stock; use App\Models\Tenants\StockLot; @@ -313,6 +315,99 @@ public function increaseFromReceiving(Receiving $receiving): StockLot }); } + /** + * 생산 완료 시 완성품 재고 입고 + * + * increaseFromReceiving()을 기반으로 구현. + * 선생산(수주 없는 작업지시) 완료 시 양품을 재고로 적재. + * + * @param WorkOrder $workOrder 선생산 작업지시 + * @param WorkOrderItem $woItem 작업지시 품목 + * @param float $goodQty 양품 수량 + * @param string $lotNo LOT 번호 + * @return StockLot 생성된 StockLot + */ + public function increaseFromProduction( + WorkOrder $workOrder, + WorkOrderItem $woItem, + float $goodQty, + string $lotNo + ): StockLot { + if (! $woItem->item_id) { + throw new \Exception(__('error.stock.item_id_required')); + } + + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($workOrder, $woItem, $goodQty, $lotNo, $tenantId, $userId) { + // 1. Stock 조회 또는 생성 + $stock = $this->getOrCreateStock($woItem->item_id); + + // 2. FIFO 순서 계산 + $fifoOrder = $this->getNextFifoOrder($stock->id); + + // 3. StockLot 생성 + $stockLot = new StockLot; + $stockLot->tenant_id = $tenantId; + $stockLot->stock_id = $stock->id; + $stockLot->lot_no = $lotNo; + $stockLot->fifo_order = $fifoOrder; + $stockLot->receipt_date = now()->toDateString(); + $stockLot->qty = $goodQty; + $stockLot->reserved_qty = 0; + $stockLot->available_qty = $goodQty; + $stockLot->unit = $woItem->unit ?? 'EA'; + $stockLot->supplier = null; + $stockLot->supplier_lot = null; + $stockLot->po_number = null; + $stockLot->location = null; + $stockLot->status = 'available'; + $stockLot->receiving_id = null; + $stockLot->work_order_id = $workOrder->id; + $stockLot->created_by = $userId; + $stockLot->updated_by = $userId; + $stockLot->save(); + + // 4. Stock 합계 갱신 + $stock->refreshFromLots(); + + // 5. 거래 이력 기록 + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_IN, + qty: $goodQty, + reason: StockTransaction::REASON_PRODUCTION_OUTPUT, + referenceType: 'work_order', + referenceId: $workOrder->id, + lotNo: $lotNo, + stockLotId: $stockLot->id + ); + + // 6. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'production_in', + reason: 'production_output', + referenceType: 'work_order', + referenceId: $workOrder->id, + qtyChange: $goodQty, + lotNo: $lotNo + ); + + Log::info('Stock increased from production', [ + 'work_order_id' => $workOrder->id, + 'item_id' => $woItem->item_id, + 'stock_id' => $stock->id, + 'stock_lot_id' => $stockLot->id, + 'qty' => $goodQty, + 'lot_no' => $lotNo, + ]); + + return $stockLot; + }); + } + /** * 입고 수정 시 재고 조정 (차이만큼 증감) * diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 890fa51..2821606 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -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(), diff --git a/database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php b/database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php new file mode 100644 index 0000000..c2232fc --- /dev/null +++ b/database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php @@ -0,0 +1,36 @@ +unsignedBigInteger('work_order_id') + ->nullable() + ->after('receiving_id') + ->comment('생산입고 시 작업지시 참조'); + + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('stock_lots', function (Blueprint $table) { + $table->dropForeign(['work_order_id']); + $table->dropColumn('work_order_id'); + }); + } +}; \ No newline at end of file