diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index 05f7d79..a093065 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\WorkOrder\MaterialInputForItemRequest; use App\Http\Requests\WorkOrder\StoreItemInspectionRequest; use App\Http\Requests\WorkOrder\WorkOrderAssignRequest; use App\Http\Requests\WorkOrder\WorkOrderIssueRequest; @@ -288,4 +289,56 @@ public function createWorkLog(Request $request, int $id) return $this->service->createWorkLog($id, $request->all()); }, __('message.work_order.work_log_saved')); } + + // ────────────────────────────────────────────────────────────── + // 개소별 자재 투입 + // ────────────────────────────────────────────────────────────── + + /** + * 개소별 자재 목록 조회 + */ + public function materialsForItem(int $id, int $itemId) + { + return ApiResponse::handle(function () use ($id, $itemId) { + return $this->service->getMaterialsForItem($id, $itemId); + }, __('message.work_order.materials_fetched')); + } + + /** + * 개소별 자재 투입 등록 + */ + public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId) + { + return ApiResponse::handle(function () use ($request, $id, $itemId) { + return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']); + }, __('message.work_order.material_input_registered')); + } + + /** + * 개소별 자재 투입 이력 조회 + */ + public function materialInputsForItem(int $id, int $itemId) + { + return ApiResponse::handle(function () use ($id, $itemId) { + return $this->service->getMaterialInputsForItem($id, $itemId); + }, __('message.work_order.fetched')); + } + + public function deleteMaterialInput(int $id, int $inputId) + { + return ApiResponse::handle(function () use ($id, $inputId) { + $this->service->deleteMaterialInput($id, $inputId); + }, __('message.work_order.deleted')); + } + + public function updateMaterialInput(Request $request, int $id, int $inputId) + { + $data = $request->validate([ + 'qty' => ['required', 'numeric', 'gt:0'], + ]); + + return ApiResponse::handle(function () use ($id, $inputId, $data) { + return $this->service->updateMaterialInput($id, $inputId, (float) $data['qty']); + }, __('message.work_order.updated')); + } } diff --git a/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php b/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php new file mode 100644 index 0000000..d1c7a5f --- /dev/null +++ b/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php @@ -0,0 +1,32 @@ + 'required|array|min:1', + 'inputs.*.stock_lot_id' => 'required|integer', + 'inputs.*.qty' => 'required|numeric|gt:0', + ]; + } + + public function messages(): array + { + return [ + 'inputs.required' => __('error.validation.required', ['attribute' => '투입 목록']), + 'inputs.*.stock_lot_id.required' => __('error.validation.required', ['attribute' => 'LOT ID']), + 'inputs.*.qty.required' => __('error.validation.required', ['attribute' => '수량']), + 'inputs.*.qty.gt' => __('error.validation.gt', ['attribute' => '수량', 'value' => 0]), + ]; + } +} diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 3d7480e..8d31f86 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -213,6 +213,14 @@ public function documents(): MorphMany return $this->morphMany(Document::class, 'linkable'); } + /** + * 개소별 자재 투입 이력 + */ + public function materialInputs(): HasMany + { + return $this->hasMany(WorkOrderMaterialInput::class); + } + /** * 출하 목록 */ diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index ccf3143..635e95b 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -8,6 +8,7 @@ use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * 작업지시 품목 모델 @@ -81,6 +82,14 @@ public function sourceOrderItem(): BelongsTo return $this->belongsTo(OrderItem::class, 'source_order_item_id'); } + /** + * 자재 투입 이력 + */ + public function materialInputs(): HasMany + { + return $this->hasMany(WorkOrderMaterialInput::class); + } + // ────────────────────────────────────────────────────────────── // 스코프 // ────────────────────────────────────────────────────────────── diff --git a/app/Models/Production/WorkOrderMaterialInput.php b/app/Models/Production/WorkOrderMaterialInput.php new file mode 100644 index 0000000..c921d34 --- /dev/null +++ b/app/Models/Production/WorkOrderMaterialInput.php @@ -0,0 +1,103 @@ + 'decimal:3', + 'input_at' => 'datetime', + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 작업지시 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * 작업지시 품목 (개소) + */ + public function workOrderItem(): BelongsTo + { + return $this->belongsTo(WorkOrderItem::class); + } + + /** + * 투입 로트 + */ + public function stockLot(): BelongsTo + { + return $this->belongsTo(StockLot::class); + } + + /** + * 자재 품목 + */ + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + /** + * 투입자 + */ + public function inputBy(): BelongsTo + { + return $this->belongsTo(User::class, 'input_by'); + } + + // ────────────────────────────────────────────────────────────── + // 스코프 + // ────────────────────────────────────────────────────────────── + + /** + * 특정 개소의 투입 이력 + */ + public function scopeForItem($query, int $workOrderItemId) + { + return $query->where('work_order_item_id', $workOrderItemId); + } + + /** + * 특정 자재의 투입 이력 + */ + public function scopeForMaterial($query, int $itemId) + { + return $query->where('item_id', $itemId); + } +} diff --git a/app/Services/StockService.php b/app/Services/StockService.php index c6032e6..0a51525 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -703,6 +703,90 @@ public function decreaseFromLot(int $stockLotId, float $qty, string $reason, int }); } + /** + * 특정 LOT에 수량 복원 (투입 취소, 삭제 등) + * decreaseFromLot의 역방향 + */ + public function increaseToLot(int $stockLotId, float $qty, string $reason, int $referenceId): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($stockLotId, $qty, $reason, $referenceId, $tenantId, $userId) { + $lot = StockLot::where('tenant_id', $tenantId) + ->where('id', $stockLotId) + ->lockForUpdate() + ->first(); + + if (! $lot) { + throw new \Exception(__('error.stock.lot_not_available')); + } + + $stock = Stock::where('id', $lot->stock_id) + ->lockForUpdate() + ->first(); + + if (! $stock) { + throw new \Exception(__('error.stock.not_found')); + } + + $oldStockQty = $stock->stock_qty; + + // LOT 수량 복원 + $lot->qty += $qty; + $lot->available_qty += $qty; + $lot->updated_by = $userId; + + if ($lot->status === 'used' && $lot->qty > 0) { + $lot->status = 'available'; + } + $lot->save(); + + // Stock 정보 갱신 + $stock->refreshFromLots(); + + // 거래 이력 기록 + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_IN, + qty: $qty, + reason: $reason, + referenceType: $reason, + referenceId: $referenceId, + lotNo: $lot->lot_no, + stockLotId: $lot->id + ); + + // 감사 로그 + $this->logStockChange( + stock: $stock, + action: 'stock_increase', + reason: $reason, + referenceType: $reason, + referenceId: $referenceId, + qtyChange: $qty, + lotNo: $lot->lot_no + ); + + Log::info('Stock increased to specific lot', [ + 'stock_lot_id' => $stockLotId, + 'lot_no' => $lot->lot_no, + 'qty' => $qty, + 'reason' => $reason, + 'reference_id' => $referenceId, + 'old_stock_qty' => $oldStockQty, + 'new_stock_qty' => $stock->stock_qty, + ]); + + return [ + 'lot_id' => $lot->id, + 'lot_no' => $lot->lot_no, + 'restored_qty' => $qty, + 'remaining_qty' => $lot->qty, + ]; + }); + } + /** * 품목별 가용 재고 조회 * diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index f98f5af..02b3a85 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -9,6 +9,7 @@ use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderBendingDetail; use App\Models\Production\WorkOrderItem; +use App\Models\Production\WorkOrderMaterialInput; use App\Models\Production\WorkOrderStepProgress; use App\Models\Tenants\Shipment; use App\Models\Tenants\ShipmentItem; @@ -58,6 +59,9 @@ public function index(array $params) 'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id', 'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code', 'items.sourceOrderItem.node:id,name,code', + 'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at', + 'items.materialInputs.stockLot:id,lot_no', + 'items.materialInputs.item:id,code,name,unit', ]); // 검색어 @@ -1418,7 +1422,10 @@ public function getStepProgress(int $workOrderId): array $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) - ->with(['process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order')]) + ->with([ + 'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'), + 'items', + ]) ->find($workOrderId); if (! $workOrder) { @@ -1430,41 +1437,85 @@ public function getStepProgress(int $workOrderId): array return []; } - // 기존 진행 레코드 조회 - $existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId) - ->whereNull('work_order_item_id') - ->get() - ->keyBy('process_step_id'); - - // 없는 단계는 자동 생성 + $items = $workOrder->items; $result = []; - foreach ($processSteps as $step) { - if ($existingProgress->has($step->id)) { - $progress = $existingProgress->get($step->id); - } else { - $progress = WorkOrderStepProgress::create([ - 'tenant_id' => $tenantId, - 'work_order_id' => $workOrderId, + + if ($items->isNotEmpty()) { + // 개소(item)별 진행 레코드 생성/조회 + $existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId) + ->whereNotNull('work_order_item_id') + ->get() + ->groupBy('work_order_item_id'); + + foreach ($items as $item) { + $itemProgress = ($existingProgress->get($item->id) ?? collect())->keyBy('process_step_id'); + + foreach ($processSteps as $step) { + if ($itemProgress->has($step->id)) { + $progress = $itemProgress->get($step->id); + } else { + $progress = WorkOrderStepProgress::create([ + 'tenant_id' => $tenantId, + 'work_order_id' => $workOrderId, + 'process_step_id' => $step->id, + 'work_order_item_id' => $item->id, + 'status' => WorkOrderStepProgress::STATUS_WAITING, + ]); + } + + $result[] = [ + 'id' => $progress->id, + 'process_step_id' => $step->id, + 'work_order_item_id' => $item->id, + 'step_code' => $step->step_code, + 'step_name' => $step->step_name, + 'sort_order' => $step->sort_order, + 'needs_inspection' => $step->needs_inspection, + 'connection_type' => $step->connection_type, + 'completion_type' => $step->completion_type, + 'status' => $progress->status, + 'is_completed' => $progress->isCompleted(), + 'completed_at' => $progress->completed_at?->toDateTimeString(), + 'completed_by' => $progress->completed_by, + ]; + } + } + } else { + // items 없으면 작업지시 전체 레벨 (기존 동작) + $existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId) + ->whereNull('work_order_item_id') + ->get() + ->keyBy('process_step_id'); + + foreach ($processSteps as $step) { + if ($existingProgress->has($step->id)) { + $progress = $existingProgress->get($step->id); + } else { + $progress = WorkOrderStepProgress::create([ + 'tenant_id' => $tenantId, + 'work_order_id' => $workOrderId, + 'process_step_id' => $step->id, + 'work_order_item_id' => null, + 'status' => WorkOrderStepProgress::STATUS_WAITING, + ]); + } + + $result[] = [ + 'id' => $progress->id, 'process_step_id' => $step->id, 'work_order_item_id' => null, - 'status' => WorkOrderStepProgress::STATUS_WAITING, - ]); + 'step_code' => $step->step_code, + 'step_name' => $step->step_name, + 'sort_order' => $step->sort_order, + 'needs_inspection' => $step->needs_inspection, + 'connection_type' => $step->connection_type, + 'completion_type' => $step->completion_type, + 'status' => $progress->status, + 'is_completed' => $progress->isCompleted(), + 'completed_at' => $progress->completed_at?->toDateTimeString(), + 'completed_by' => $progress->completed_by, + ]; } - - $result[] = [ - 'id' => $progress->id, - 'process_step_id' => $step->id, - 'step_code' => $step->step_code, - 'step_name' => $step->step_name, - 'sort_order' => $step->sort_order, - 'needs_inspection' => $step->needs_inspection, - 'connection_type' => $step->connection_type, - 'completion_type' => $step->completion_type, - 'status' => $progress->status, - 'is_completed' => $progress->isCompleted(), - 'completed_at' => $progress->completed_at?->toDateTimeString(), - 'completed_by' => $progress->completed_by, - ]; } return $result; @@ -1813,7 +1864,7 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) - ->with(['process']) + ->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')]) ->find($workOrderId); if (! $workOrder) { @@ -1840,16 +1891,35 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData ->latest() ->first(); - // 프론트 InspectionData를 document_data 정규화 레코드 형식으로 변환 - $documentDataRecords = $this->transformInspectionDataToDocumentRecords( - $inspectionData['data'] ?? [], - $templateId - ); + // ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집 + $rawItems = []; + foreach ($workOrder->items as $item) { + $inspData = $item->getInspectionData(); + if ($inspData) { + $rawItems[] = $inspData; + } + } + $documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId); + + // 기존 문서의 기본필드(bf_*) 보존 if ($existingDocument) { + $existingBasicFields = $existingDocument->data() + ->whereNull('section_id') + ->where('field_key', 'LIKE', 'bf_%') + ->get() + ->map(fn ($d) => [ + 'section_id' => null, + 'column_id' => null, + 'row_index' => $d->row_index, + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + ]) + ->toArray(); + $document = $documentService->update($existingDocument->id, [ 'title' => $inspectionData['title'] ?? $existingDocument->title, - 'data' => $documentDataRecords, + 'data' => array_merge($existingBasicFields, $documentDataRecords), ]); $action = 'inspection_document_updated'; @@ -2303,7 +2373,7 @@ public function createWorkLog(int $workOrderId, array $workLogData): array $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) - ->with(['process', 'salesOrder:id,order_no,client_name,site_name']) + ->with(['process', 'salesOrder:id,order_no,client_name,site_name', 'items']) ->find($workOrderId); if (! $workOrder) { @@ -2317,6 +2387,9 @@ public function createWorkLog(int $workOrderId, array $workLogData): array throw new BadRequestHttpException(__('error.work_order.no_work_log_template')); } + // 템플릿의 기본필드 로드 (bf_{id} 형식으로 저장하기 위해) + $template = DocumentTemplate::with('basicFields')->find($templateId); + $documentService = app(DocumentService::class); // 기존 DRAFT/REJECTED 문서 확인 @@ -2330,7 +2403,7 @@ public function createWorkLog(int $workOrderId, array $workLogData): array ->first(); // 작업일지 데이터를 document_data 레코드로 변환 - $documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder); + $documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template); if ($existingDocument) { $document = $documentService->update($existingDocument->id, [ @@ -2375,20 +2448,31 @@ private function buildWorkLogAutoValues(WorkOrder $workOrder): array { $salesOrder = $workOrder->salesOrder; + // 수주일: received_at (date 또는 datetime) + $receivedAt = $salesOrder?->received_at; + $orderDate = $receivedAt ? substr((string) $receivedAt, 0, 10) : ''; + + // 납기일/출고예정일 + $deliveryDate = $salesOrder?->delivery_date; + $deliveryStr = $deliveryDate ? substr((string) $deliveryDate, 0, 10) : ''; + + // 제품 LOT NO = 수주번호 (order_no) + $orderNo = $salesOrder?->order_no ?? ''; + return [ '발주처' => $salesOrder?->client_name ?? '', '현장명' => $salesOrder?->site_name ?? '', '작업일자' => now()->format('Y-m-d'), - 'LOT NO' => '', - '납기일' => $salesOrder?->delivery_date?->format('Y-m-d') ?? '', + 'LOT NO' => $orderNo, + '납기일' => $deliveryStr, '작업지시번호' => $workOrder->work_order_no ?? '', - '수주일' => $salesOrder?->order_date ?? '', + '수주일' => $orderDate, '수주처' => $salesOrder?->client_name ?? '', '담당자' => '', '연락처' => '', - '제품 LOT NO' => '', + '제품 LOT NO' => $orderNo, '생산담당자' => '', - '출고예정일' => $salesOrder?->delivery_date?->format('Y-m-d') ?? '', + '출고예정일' => $deliveryStr, ]; } @@ -2426,37 +2510,39 @@ private function calculateWorkStats(WorkOrder $workOrder): array /** * 작업일지 데이터를 document_data 레코드로 변환 * - * 입력 형식: - * basic_data: { '발주처': '...', '현장명': '...' } - * table_data: [{ item_name, floor_code, specification, quantity, status }] - * remarks: "특이사항" + * mng show.blade.php 호환 형식: + * 기본필드: field_key = 'bf_{basicField->id}' (template basicFields 기반) + * 통계/비고: field_key = 'stats_*', 'remarks' + * + * auto_values로 기본필드 자동 채움, basic_data로 수동 override 가능 */ - private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $workOrder): array + private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $workOrder, ?DocumentTemplate $template): array { $records = []; - // 1. 기본필드 저장 - $basicData = $workLogData['basic_data'] ?? []; - foreach ($basicData as $key => $value) { - $records[] = [ - 'field_key' => "basic_{$key}", - 'field_value' => $value, - ]; - } + // 1. 기본필드: bf_{id} 형식으로 저장 (mng show.blade.php 호환) + if ($template && $template->basicFields) { + $autoValues = $this->buildWorkLogAutoValues($workOrder); + $manualData = $workLogData['basic_data'] ?? []; - // 2. 품목 테이블 데이터 (WorkOrderItem 기반) - $tableData = $workLogData['table_data'] ?? []; - foreach ($tableData as $index => $row) { - foreach ($row as $fieldKey => $fieldValue) { - $records[] = [ - 'row_index' => $index, - 'field_key' => "item_{$fieldKey}", - 'field_value' => is_array($fieldValue) ? json_encode($fieldValue) : (string) $fieldValue, - ]; + foreach ($template->basicFields as $field) { + // 수동 입력 우선, 없으면 auto_values에서 라벨로 매칭 + $value = $manualData[$field->label] + ?? $manualData[$field->field_key ?? ''] + ?? $autoValues[$field->label] + ?? $field->default_value + ?? ''; + + if ($value !== '') { + $records[] = [ + 'field_key' => "bf_{$field->id}", + 'field_value' => (string) $value, + ]; + } } } - // 3. 작업 통계 (자동 계산) + // 2. 작업 통계 (자동 계산) $stats = $this->calculateWorkStats($workOrder); foreach ($stats as $key => $value) { $records[] = [ @@ -2465,7 +2551,7 @@ private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $wo ]; } - // 4. 특이사항 + // 3. 특이사항 if (isset($workLogData['remarks'])) { $records[] = [ 'field_key' => 'remarks', @@ -2475,4 +2561,390 @@ private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $wo return $records; } + + // ────────────────────────────────────────────────────────────── + // 개소별 자재 투입 + // ────────────────────────────────────────────────────────────── + + /** + * 개소별 BOM 기반 필요 자재 + 재고 LOT 조회 + */ + public function getMaterialsForItem(int $workOrderId, int $itemId): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $woItem = WorkOrderItem::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->with('item') + ->find($itemId); + + if (! $woItem) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 해당 개소의 BOM 기반 자재 추출 + $materialItems = []; + + if ($woItem->item_id) { + $item = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->find($woItem->item_id); + + if ($item && ! empty($item->bom)) { + foreach ($item->bom as $bomItem) { + $childItemId = $bomItem['child_item_id'] ?? null; + $bomQty = (float) ($bomItem['qty'] ?? 1); + + if (! $childItemId) { + continue; + } + + $childItem = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->find($childItemId); + + if (! $childItem) { + continue; + } + + $materialItems[] = [ + 'item' => $childItem, + 'bom_qty' => $bomQty, + 'required_qty' => $bomQty * ($woItem->quantity ?? 1), + ]; + } + } + } + + // BOM이 없으면 품목 자체를 자재로 사용 + if (empty($materialItems) && $woItem->item_id && $woItem->item) { + $materialItems[] = [ + 'item' => $woItem->item, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } + + // 이미 투입된 수량 조회 (item_id별 SUM) + $inputtedQties = WorkOrderMaterialInput::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->where('work_order_item_id', $itemId) + ->selectRaw('item_id, SUM(qty) as total_qty') + ->groupBy('item_id') + ->pluck('total_qty', 'item_id'); + + // 자재별 LOT 조회 + $materials = []; + $rank = 1; + + foreach ($materialItems as $matInfo) { + $materialItem = $matInfo['item']; + $alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0); + $remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted); + + $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) + ->where('item_id', $materialItem->id) + ->first(); + + $lotsFound = false; + + if ($stock) { + $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) + ->where('stock_id', $stock->id) + ->where('status', 'available') + ->where('available_qty', '>', 0) + ->orderBy('fifo_order', 'asc') + ->get(); + + foreach ($lots as $lot) { + $lotsFound = true; + $materials[] = [ + 'stock_lot_id' => $lot->id, + 'item_id' => $materialItem->id, + 'lot_no' => $lot->lot_no, + 'material_code' => $materialItem->code, + 'material_name' => $materialItem->name, + 'specification' => $materialItem->specification, + 'unit' => $lot->unit ?? $materialItem->unit ?? 'EA', + 'bom_qty' => $matInfo['bom_qty'], + 'required_qty' => $matInfo['required_qty'], + 'already_inputted' => $alreadyInputted, + 'remaining_required_qty' => $remainingRequired, + 'lot_qty' => (float) $lot->qty, + 'lot_available_qty' => (float) $lot->available_qty, + 'lot_reserved_qty' => (float) $lot->reserved_qty, + 'receipt_date' => $lot->receipt_date, + 'supplier' => $lot->supplier, + 'fifo_rank' => $rank++, + ]; + } + } + + if (! $lotsFound) { + $materials[] = [ + 'stock_lot_id' => null, + 'item_id' => $materialItem->id, + 'lot_no' => null, + 'material_code' => $materialItem->code, + 'material_name' => $materialItem->name, + 'specification' => $materialItem->specification, + 'unit' => $materialItem->unit ?? 'EA', + 'bom_qty' => $matInfo['bom_qty'], + 'required_qty' => $matInfo['required_qty'], + 'already_inputted' => $alreadyInputted, + 'remaining_required_qty' => $remainingRequired, + 'lot_qty' => 0, + 'lot_available_qty' => 0, + 'lot_reserved_qty' => 0, + 'receipt_date' => null, + 'supplier' => null, + 'fifo_rank' => $rank++, + ]; + } + } + + return $materials; + } + + /** + * 개소별 자재 투입 등록 + */ + public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $woItem = WorkOrderItem::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->find($itemId); + + if (! $woItem) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) { + $stockService = app(StockService::class); + $inputResults = []; + + foreach ($inputs as $input) { + $stockLotId = $input['stock_lot_id'] ?? null; + $qty = (float) ($input['qty'] ?? 0); + + if (! $stockLotId || $qty <= 0) { + continue; + } + + // 기존 재고 차감 로직 재사용 + $result = $stockService->decreaseFromLot( + stockLotId: $stockLotId, + qty: $qty, + reason: 'work_order_input', + referenceId: $workOrderId + ); + + // 로트의 품목 ID 조회 + $lot = \App\Models\Tenants\StockLot::find($stockLotId); + $lotItemId = $lot ? ($lot->stock->item_id ?? null) : null; + + // 개소별 매핑 레코드 생성 + WorkOrderMaterialInput::create([ + 'tenant_id' => $tenantId, + 'work_order_id' => $workOrderId, + 'work_order_item_id' => $itemId, + 'stock_lot_id' => $stockLotId, + 'item_id' => $lotItemId ?? 0, + 'qty' => $qty, + 'input_by' => $userId, + 'input_at' => now(), + ]); + + $inputResults[] = [ + 'stock_lot_id' => $stockLotId, + 'qty' => $qty, + 'status' => 'success', + 'deducted_lot' => $result, + ]; + } + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'material_input_for_item', + null, + [ + 'work_order_item_id' => $itemId, + 'inputs' => $inputs, + 'input_results' => $inputResults, + 'input_by' => $userId, + 'input_at' => now()->toDateTimeString(), + ] + ); + + return [ + 'work_order_id' => $workOrderId, + 'work_order_item_id' => $itemId, + 'material_count' => count($inputResults), + 'input_results' => $inputResults, + 'input_at' => now()->toDateTimeString(), + ]; + }); + } + + /** + * 개소별 자재 투입 이력 조회 + */ + public function getMaterialInputsForItem(int $workOrderId, int $itemId): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $woItem = WorkOrderItem::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->find($itemId); + + if (! $woItem) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $inputs = WorkOrderMaterialInput::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->where('work_order_item_id', $itemId) + ->with(['stockLot', 'item', 'inputBy']) + ->orderBy('input_at', 'desc') + ->get(); + + return $inputs->map(function ($input) { + return [ + 'id' => $input->id, + 'stock_lot_id' => $input->stock_lot_id, + 'lot_no' => $input->stockLot?->lot_no, + 'item_id' => $input->item_id, + 'material_code' => $input->item?->code, + 'material_name' => $input->item?->name, + 'qty' => (float) $input->qty, + 'unit' => $input->item?->unit ?? 'EA', + 'input_by' => $input->input_by, + 'input_by_name' => $input->inputBy?->name, + 'input_at' => $input->input_at?->toDateTimeString(), + ]; + })->toArray(); + } + + /** + * 개소별 자재 투입 삭제 (재고 복원) + */ + public function deleteMaterialInput(int $workOrderId, int $inputId): void + { + $tenantId = $this->tenantId(); + + $input = WorkOrderMaterialInput::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->find($inputId); + + if (! $input) { + throw new NotFoundHttpException(__('error.not_found')); + } + + DB::transaction(function () use ($input, $tenantId, $workOrderId) { + // 재고 복원 + $stockService = app(StockService::class); + $stockService->increaseToLot( + stockLotId: $input->stock_lot_id, + qty: (float) $input->qty, + reason: 'work_order_input_cancel', + referenceId: $workOrderId + ); + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'material_input_deleted', + [ + 'input_id' => $input->id, + 'stock_lot_id' => $input->stock_lot_id, + 'qty' => (float) $input->qty, + 'work_order_item_id' => $input->work_order_item_id, + ], + null + ); + + $input->delete(); + }); + } + + /** + * 개소별 자재 투입 수량 수정 (재고 차이 반영) + */ + public function updateMaterialInput(int $workOrderId, int $inputId, float $newQty): array + { + $tenantId = $this->tenantId(); + + $input = WorkOrderMaterialInput::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->find($inputId); + + if (! $input) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($input, $newQty, $tenantId, $workOrderId) { + $oldQty = (float) $input->qty; + $diff = $newQty - $oldQty; + + if (abs($diff) < 0.001) { + return ['id' => $input->id, 'qty' => $oldQty, 'changed' => false]; + } + + $stockService = app(StockService::class); + + if ($diff > 0) { + // 수량 증가 → 추가 차감 + $stockService->decreaseFromLot( + stockLotId: $input->stock_lot_id, + qty: $diff, + reason: 'work_order_input', + referenceId: $workOrderId + ); + } else { + // 수량 감소 → 차이만큼 복원 + $stockService->increaseToLot( + stockLotId: $input->stock_lot_id, + qty: abs($diff), + reason: 'work_order_input_adjust', + referenceId: $workOrderId + ); + } + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'material_input_updated', + ['input_id' => $input->id, 'qty' => $oldQty], + ['input_id' => $input->id, 'qty' => $newQty] + ); + + $input->qty = $newQty; + $input->save(); + + return ['id' => $input->id, 'qty' => $newQty, 'changed' => true]; + }); + } } diff --git a/database/migrations/2026_02_12_100000_create_work_order_material_inputs_table.php b/database/migrations/2026_02_12_100000_create_work_order_material_inputs_table.php new file mode 100644 index 0000000..3daac41 --- /dev/null +++ b/database/migrations/2026_02_12_100000_create_work_order_material_inputs_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->foreignId('work_order_id')->constrained('work_orders')->cascadeOnDelete()->comment('작업지시 ID'); + $table->foreignId('work_order_item_id')->constrained('work_order_items')->cascadeOnDelete()->comment('개소(작업지시품목) ID'); + $table->unsignedBigInteger('stock_lot_id')->comment('투입 로트 ID'); + $table->unsignedBigInteger('item_id')->comment('자재 품목 ID'); + $table->decimal('qty', 12, 3)->comment('투입 수량'); + $table->unsignedBigInteger('input_by')->nullable()->comment('투입자 ID'); + $table->timestamp('input_at')->useCurrent()->comment('투입 시각'); + $table->timestamps(); + + // 인덱스 + $table->index('tenant_id', 'idx_womi_tenant'); + $table->index(['work_order_id', 'work_order_item_id'], 'idx_womi_wo_item'); + $table->index('stock_lot_id', 'idx_womi_lot'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_material_inputs'); + } +}; diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 2dc47d0..0700bfa 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -69,6 +69,13 @@ Route::get('/{id}/material-input-history', [WorkOrderController::class, 'materialInputHistory'])->whereNumber('id')->name('v1.work-orders.material-input-history'); // 자재 투입 이력 Route::get('/{id}/material-input-lots', [WorkOrderController::class, 'materialInputLots'])->whereNumber('id')->name('v1.work-orders.material-input-lots'); // 투입 LOT 번호 조회 + // 개소별 자재 관리 + Route::get('/{id}/items/{itemId}/materials', [WorkOrderController::class, 'materialsForItem'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.materials'); // 개소별 자재 조회 + Route::post('/{id}/items/{itemId}/material-inputs', [WorkOrderController::class, 'registerMaterialInputForItem'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.material-inputs'); // 개소별 자재 투입 + Route::get('/{id}/items/{itemId}/material-inputs', [WorkOrderController::class, 'materialInputsForItem'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.material-inputs.index'); // 개소별 투입 이력 + Route::delete('/{id}/material-inputs/{inputId}', [WorkOrderController::class, 'deleteMaterialInput'])->whereNumber('id')->whereNumber('inputId')->name('v1.work-orders.material-inputs.delete'); // 자재 투입 삭제 + Route::patch('/{id}/material-inputs/{inputId}', [WorkOrderController::class, 'updateMaterialInput'])->whereNumber('id')->whereNumber('inputId')->name('v1.work-orders.material-inputs.update'); // 자재 투입 수정 + // 공정 단계 진행 관리 Route::get('/{id}/step-progress', [WorkOrderController::class, 'stepProgress'])->whereNumber('id')->name('v1.work-orders.step-progress'); // 단계 진행 조회 Route::patch('/{id}/step-progress/{progressId}/toggle', [WorkOrderController::class, 'toggleStepProgress'])->whereNumber('id')->whereNumber('progressId')->name('v1.work-orders.step-progress.toggle'); // 단계 토글