tenantId(); $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $status = $params['status'] ?? null; $processId = $params['process_id'] ?? null; $processCode = $params['process_code'] ?? null; $assigneeId = $params['assignee_id'] ?? null; $assignedToMe = isset($params['assigned_to_me']) && $params['assigned_to_me']; $workerScreen = isset($params['worker_screen']) && $params['worker_screen']; $teamId = $params['team_id'] ?? null; $scheduledFrom = $params['scheduled_from'] ?? null; $scheduledTo = $params['scheduled_to'] ?? null; $query = WorkOrder::query() ->where('tenant_id', $tenantId) ->with([ 'assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'client_id', 'client_name', 'client_contact', 'site_name', 'quantity', 'received_at', 'delivery_date', 'options')->withCount('rootNodes'), 'salesOrder.client:id,name', 'process:id,process_name,process_code,department,options', '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', ]); // 검색어 if ($q !== '') { $query->where(function ($qq) use ($q) { $qq->where('work_order_no', 'like', "%{$q}%") ->orWhere('project_name', 'like', "%{$q}%"); }); } // 상태 필터 if ($status !== null) { $query->where('status', $status); } // 공정 필터 (process_id) // - 'none' 또는 '0': 공정 미지정 (process_id IS NULL) // - 숫자: 해당 공정 ID로 필터 if ($processId !== null) { if ($processId === 'none' || $processId === '0' || $processId === 0) { $query->whereNull('process_id'); } else { $query->where('process_id', $processId); } } // 공정 코드 필터 (process_code) - 대시보드용 if ($processCode !== null) { $query->whereHas('process', fn ($q) => $q->where('process_code', $processCode)); } // 담당자 필터 if ($assigneeId !== null) { $query->where('assignee_id', $assigneeId); } // 나에게 배정된 작업만 필터 (주 담당자 또는 공동 담당자) if ($assignedToMe) { $userId = $this->apiUserId(); $query->where(function ($q) use ($userId) { $q->where('assignee_id', $userId) ->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId)); }); } // 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체 if ($workerScreen) { $userId = $this->apiUserId(); // 1차: 개인 배정된 작업이 있는지 확인 $hasPersonal = (clone $query)->where(function ($q) use ($userId) { $q->where('assignee_id', $userId) ->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId)); })->exists(); if ($hasPersonal) { $query->where(function ($q) use ($userId) { $q->where('assignee_id', $userId) ->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId)); }); } else { // 2차: 사용자 소속 부서의 작업지시 필터 $departmentIds = DB::table('department_user') ->where('user_id', $userId) ->where('tenant_id', $tenantId) ->pluck('department_id'); if ($departmentIds->isNotEmpty()) { $query->whereIn('team_id', $departmentIds); } // 3차: 부서도 없으면 필터 없이 전체 노출 } } // 팀 필터 if ($teamId !== null) { $query->where('team_id', $teamId); } // 예정일 범위 if ($scheduledFrom !== null) { $query->where('scheduled_date', '>=', $scheduledFrom); } if ($scheduledTo !== null) { $query->where('scheduled_date', '<=', $scheduledTo); } $query->orderByDesc('created_at'); return $query->paginate($size, ['*'], 'page', $page); } /** * 통계 조회 */ public function stats(): array { $tenantId = $this->tenantId(); $counts = WorkOrder::where('tenant_id', $tenantId) ->select('status', DB::raw('count(*) as count')) ->groupBy('status') ->pluck('count', 'status') ->toArray(); // 공정별 카운트 (탭 숫자 표시용) $byProcess = WorkOrder::where('tenant_id', $tenantId) ->select('process_id', DB::raw('count(*) as count')) ->groupBy('process_id') ->pluck('count', 'process_id') ->toArray(); $total = array_sum($counts); $noneCount = $byProcess[''] ?? $byProcess[0] ?? 0; // null 키는 빈 문자열로 변환되므로 별도 처리 $processedByProcess = []; foreach ($byProcess as $key => $count) { if ($key === '' || $key === 0 || $key === null) { $processedByProcess['none'] = $count; } else { $processedByProcess[(string) $key] = $count; } } return [ 'total' => $total, 'unassigned' => $counts[WorkOrder::STATUS_UNASSIGNED] ?? 0, 'pending' => $counts[WorkOrder::STATUS_PENDING] ?? 0, 'waiting' => $counts[WorkOrder::STATUS_WAITING] ?? 0, 'in_progress' => $counts[WorkOrder::STATUS_IN_PROGRESS] ?? 0, 'completed' => $counts[WorkOrder::STATUS_COMPLETED] ?? 0, 'shipped' => $counts[WorkOrder::STATUS_SHIPPED] ?? 0, 'by_process' => $processedByProcess, ]; } /** * 단건 조회 */ public function show(int $id) { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with([ 'assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'site_name', 'client_id', 'client_contact', 'received_at', 'writer_id', 'created_at', 'quantity', 'options')->withCount('rootNodes'), 'salesOrder.client:id,name', 'salesOrder.writer:id,name', 'process:id,process_name,process_code,work_steps,department,options', 'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'), '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', 'bendingDetail', 'issues' => fn ($q) => $q->orderByDesc('created_at'), 'stepProgress.processStep:id,process_id,step_code,step_name,sort_order,needs_inspection,connection_type,completion_type', ]) ->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } return $workOrder; } /** * 생성 */ public function store(array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 작업지시번호 자동 생성 $data['work_order_no'] = $this->generateWorkOrderNo($tenantId); $data['tenant_id'] = $tenantId; $data['created_by'] = $userId; $data['updated_by'] = $userId; // 담당자가 있으면 상태를 pending으로 if (! empty($data['assignee_id'])) { $data['status'] = $data['status'] ?? WorkOrder::STATUS_PENDING; } $items = $data['items'] ?? []; $bendingDetail = $data['bending_detail'] ?? null; $salesOrderId = $data['sales_order_id'] ?? null; unset($data['items'], $data['bending_detail']); // 공정의 is_auxiliary 플래그를 WO options에 복사 if (! empty($data['process_id'])) { $process = \App\Models\Process::find($data['process_id']); if ($process && ! empty($process->options['is_auxiliary'])) { $opts = $data['options'] ?? []; $opts = is_array($opts) ? $opts : (json_decode($opts, true) ?? []); $opts['is_auxiliary'] = true; $data['options'] = $opts; } } $workOrder = WorkOrder::create($data); // process 관계 로드 (isBending 체크용) $workOrder->load('process:id,process_name,process_code'); // 품목 저장: 직접 전달된 품목이 없고 수주 ID가 있으면 수주에서 복사 if (empty($items) && $salesOrderId) { $salesOrder = \App\Models\Orders\Order::with(['items.node', 'rootNodes'])->find($salesOrderId); if ($salesOrder && $salesOrder->items->isNotEmpty()) { // order_node_id가 null인 품목용 fallback: 수주의 root node를 인덱스로 매핑 $rootNodes = $salesOrder->rootNodes; $rootNodeCount = $rootNodes->count(); foreach ($salesOrder->items as $index => $orderItem) { // 수주 품목 + 노드에서 options 조합 // 1순위: 품목에 직접 연결된 node, 2순위: root node fallback $nodeOptions = $orderItem->node?->options ?? []; if (empty($nodeOptions) && $rootNodeCount > 0) { // root node가 1개면 모든 품목이 해당 node, 여러 개면 인덱스 기반 분배 $fallbackNode = $rootNodeCount === 1 ? $rootNodes->first() : $rootNodes->values()->get($index % $rootNodeCount); $nodeOptions = $fallbackNode?->options ?? []; } $options = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, 'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null, 'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null, 'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null, 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, 'slat_info' => $nodeOptions['slat_info'] ?? null, 'bending_info' => $nodeOptions['bending_info'] ?? null, 'wip_info' => $nodeOptions['wip_info'] ?? null, ], fn ($v) => $v !== null); $workOrder->items()->create([ 'tenant_id' => $tenantId, 'source_order_item_id' => $orderItem->id, // 원본 수주 품목 추적용 'item_id' => $orderItem->item_id, 'item_name' => $orderItem->item_name, 'specification' => $orderItem->specification, 'quantity' => $orderItem->quantity, 'unit' => $orderItem->unit, 'sort_order' => $index, 'options' => ! empty($options) ? $options : null, ]); } } } else { // 직접 전달된 품목 저장 foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['sort_order'] = $index; $workOrder->items()->create($item); } } // 벤딩 상세 저장 (벤딩 공정인 경우) if ($workOrder->isBending() && $bendingDetail) { $bendingDetail['tenant_id'] = $tenantId; $workOrder->bendingDetail()->create($bendingDetail); } // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrder->id, 'created', null, $workOrder->toArray() ); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']); }); } /** * 수정 */ public function update(int $id, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with('process:id,process_name,process_code') ->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $beforeData = $workOrder->toArray(); return DB::transaction(function () use ($workOrder, $data, $userId, $beforeData) { $data['updated_by'] = $userId; $items = $data['items'] ?? null; $bendingDetail = $data['bending_detail'] ?? null; $assigneeIds = $data['assignee_ids'] ?? null; unset($data['items'], $data['bending_detail'], $data['assignee_ids'], $data['work_order_no']); // 번호 변경 불가 // 품목 수정 시 기존 품목 기록 $oldItems = null; if ($items !== null) { $oldItems = $workOrder->items()->get()->toArray(); } // 담당자 수정 시 기존 담당자 기록 $oldAssignees = null; if ($assigneeIds !== null) { $oldAssignees = $workOrder->assignees()->pluck('user_id')->toArray(); } $workOrder->update($data); // 담당자 처리 (assignee_ids 배열) if ($assigneeIds !== null) { $assigneeIds = array_unique(array_filter($assigneeIds)); // 기존 담당자 삭제 후 새로 추가 $workOrder->assignees()->delete(); foreach ($assigneeIds as $index => $assigneeId) { WorkOrderAssignee::create([ 'tenant_id' => $workOrder->tenant_id, 'work_order_id' => $workOrder->id, 'user_id' => $assigneeId, 'is_primary' => $index === 0, // 첫 번째가 주 담당자 ]); } // 주 담당자는 work_orders 테이블에도 설정 (하위 호환) $primaryAssigneeId = $assigneeIds[0] ?? null; $workOrder->assignee_id = $primaryAssigneeId; $workOrder->save(); // 담당자 수정 감사 로그 $this->auditLogger->log( $workOrder->tenant_id, self::AUDIT_TARGET, $workOrder->id, 'assignees_updated', ['assignee_ids' => $oldAssignees], ['assignee_ids' => $assigneeIds] ); } // 품목 부분 수정 (ID 기반 upsert/delete) if ($items !== null) { $existingIds = $workOrder->items()->pluck('id')->toArray(); $incomingIds = []; foreach ($items as $index => $item) { $itemData = [ 'tenant_id' => $workOrder->tenant_id, 'item_name' => $item['item_name'] ?? null, 'specification' => $item['specification'] ?? null, 'quantity' => $item['quantity'] ?? 1, 'unit' => $item['unit'] ?? null, 'sort_order' => $index, ]; if (isset($item['id']) && $item['id']) { // ID가 있으면 업데이트 $existingItem = $workOrder->items()->find($item['id']); if ($existingItem) { $existingItem->update($itemData); $incomingIds[] = (int) $item['id']; } } else { // ID가 없으면 신규 생성 $newItem = $workOrder->items()->create($itemData); $incomingIds[] = $newItem->id; } } // 요청에 없는 기존 품목 삭제 $toDelete = array_diff($existingIds, $incomingIds); if (! empty($toDelete)) { $workOrder->items()->whereIn('id', $toDelete)->delete(); } // 품목 수정 감사 로그 $this->auditLogger->log( $workOrder->tenant_id, self::AUDIT_TARGET, $workOrder->id, 'items_updated', ['items' => $oldItems], ['items' => $workOrder->items()->get()->toArray()] ); } // 벤딩 상세 업데이트 (벤딩 공정인 경우에만) if ($bendingDetail !== null && $workOrder->isBending()) { $bendingDetail['tenant_id'] = $workOrder->tenant_id; $workOrder->bendingDetail()->updateOrCreate( ['work_order_id' => $workOrder->id], $bendingDetail ); } // 수정 감사 로그 $this->auditLogger->log( $workOrder->tenant_id, self::AUDIT_TARGET, $workOrder->id, 'updated', $beforeData, $workOrder->fresh()->toArray() ); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']); }); } /** * 삭제 */ public function destroy(int $id) { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } // 진행 중이거나 완료된 작업은 삭제 불가 if (in_array($workOrder->status, [ WorkOrder::STATUS_IN_PROGRESS, WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED, ])) { throw new BadRequestHttpException(__('error.work_order.cannot_delete_in_progress')); } $beforeData = $workOrder->toArray(); $workOrder->delete(); // 삭제 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrder->id, 'deleted', $beforeData, null ); return 'success'; } /** * 상태 변경 * * @param int $id 작업지시 ID * @param string $status 변경할 상태 * @param array|null $resultData 완료 시 결과 데이터 (선택) */ public function updateStatus(int $id, string $status, ?array $resultData = null) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } // 상태 유효성 검증 if (! in_array($status, WorkOrder::STATUSES)) { throw new BadRequestHttpException(__('error.invalid_status')); } // Fast-track 완료 체크: pending/waiting에서 completed로 직접 전환 허용 // 작업자 화면의 "전량완료" 버튼 지원 $isFastTrackCompletion = $status === WorkOrder::STATUS_COMPLETED && in_array($workOrder->status, [WorkOrder::STATUS_PENDING, WorkOrder::STATUS_WAITING]); // 일반 상태 전이 규칙 검증 (fast-track이 아닌 경우) if (! $isFastTrackCompletion && ! $workOrder->canTransitionTo($status)) { $allowed = implode(', ', $workOrder->getAllowedTransitions()); throw new BadRequestHttpException( __('error.work_order.invalid_transition', [ 'from' => $workOrder->status, 'to' => $status, 'allowed' => $allowed, ]) ); } return DB::transaction(function () use ($workOrder, $status, $resultData, $tenantId, $userId) { $oldStatus = $workOrder->status; $workOrder->status = $status; $workOrder->updated_by = $userId; // 상태에 따른 타임스탬프 업데이트 switch ($status) { case WorkOrder::STATUS_IN_PROGRESS: $workOrder->started_at = $workOrder->started_at ?? now(); break; case WorkOrder::STATUS_COMPLETED: // Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략) $workOrder->started_at = $workOrder->started_at ?? now(); $workOrder->completed_at = now(); // 모든 품목에 결과 데이터 저장 $this->saveItemResults($workOrder, $resultData, $userId); break; case WorkOrder::STATUS_SHIPPED: $workOrder->shipped_at = now(); break; } $workOrder->save(); // 상태 변경 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrder->id, 'status_changed', ['status' => $oldStatus], ['status' => $status] ); // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($workOrder, $tenantId); // 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고 if ($status === WorkOrder::STATUS_COMPLETED) { 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; } /** * 작업지시 완료 시 자동 출하 생성 * * 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다. * 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다. * (Shipment 모델의 accessor 메서드로 수주 정보 참조) */ private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment { // 이미 이 작업지시에 연결된 출하가 있으면 스킵 $existingShipment = Shipment::where('tenant_id', $tenantId) ->where('work_order_id', $workOrder->id) ->first(); if ($existingShipment) { return $existingShipment; } // 출하번호 자동 생성 $shipmentNo = Shipment::generateShipmentNo($tenantId); // 출하 생성 데이터 // 발주처/배송 정보는 수주(Order)를 참조하므로 여기서 복사하지 않음 $shipmentData = [ 'tenant_id' => $tenantId, 'shipment_no' => $shipmentNo, 'work_order_id' => $workOrder->id, 'order_id' => $workOrder->sales_order_id, 'scheduled_date' => now()->toDateString(), // 오늘 날짜로 출하 예정 'status' => 'scheduled', // 예정 상태로 생성 'priority' => 'normal', 'delivery_method' => 'pickup', // 기본값 'can_ship' => true, // 생산 완료 후 생성되므로 출하가능 'created_by' => $userId, 'updated_by' => $userId, ]; $shipment = Shipment::create($shipmentData); // 작업지시 품목을 출하 품목으로 복사 $this->copyWorkOrderItemsToShipment($workOrder, $shipment, $tenantId); // 자동 출하 생성 감사 로그 $this->auditLogger->log( $tenantId, 'shipment', $shipment->id, 'auto_created_from_work_order', null, [ 'work_order_id' => $workOrder->id, 'shipment_no' => $shipmentNo, 'items_count' => $shipment->items()->count(), ] ); return $shipment; } /** * 작업지시 품목을 출하 품목으로 복사 * * 작업지시 품목(work_order_items)의 정보를 출하 품목(shipment_items)으로 복사합니다. * 작업지시 품목이 없으면 수주 품목(order_items)을 대체 사용합니다. * LOT 번호는 작업지시 품목의 결과 데이터에서 가져옵니다. * 층/부호(floor_unit)는 원본 수주품목(order_items)에서 가져옵니다. */ private function copyWorkOrderItemsToShipment(WorkOrder $workOrder, Shipment $shipment, int $tenantId): void { $workOrderItems = $workOrder->items()->get(); // 작업지시 품목이 있으면 사용 if ($workOrderItems->isNotEmpty()) { foreach ($workOrderItems as $index => $woItem) { // 작업지시 품목의 결과 데이터에서 LOT 번호 추출 $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' => $index + 1, '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, ]); } return; } // 작업지시 품목이 없으면 수주 품목에서 복사 (Fallback) if ($workOrder->salesOrder) { $orderItems = $workOrder->salesOrder->items()->get(); foreach ($orderItems as $index => $orderItem) { // 수주품목에서 층/부호 정보 조회 $floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId); // 출하 품목 생성 ShipmentItem::create([ 'tenant_id' => $tenantId, 'shipment_id' => $shipment->id, 'seq' => $index + 1, '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, // 수주 품목에는 LOT 번호 없음 'remarks' => null, ]); } } } /** * 수주품목에서 층/부호 정보 조회 * * floor_code와 symbol_code를 조합하여 floor_unit 형식으로 반환합니다. * 예: floor_code='3층', symbol_code='A호' → '3층/A호' */ private function getFloorUnitFromOrderItem(?int $orderItemId, int $tenantId): ?string { if (! $orderItemId) { return null; } $orderItem = \App\Models\Orders\OrderItem::where('tenant_id', $tenantId) ->find($orderItemId); if (! $orderItem) { return null; } $parts = array_filter([ $orderItem->floor_code, $orderItem->symbol_code, ]); return ! empty($parts) ? implode('/', $parts) : null; } /** * 작업지시 상태 변경 시 연결된 수주(Order) 상태 동기화 * * 매핑 규칙: * - WorkOrder::STATUS_IN_PROGRESS → Order::STATUS_IN_PRODUCTION (생산중) * - WorkOrder::STATUS_COMPLETED → Order::STATUS_PRODUCED (생산완료) * - WorkOrder::STATUS_SHIPPED → Order::STATUS_SHIPPED (출하완료) */ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void { // 수주 연결이 없으면 스킵 if (! $workOrder->sales_order_id) { return; } // 보조 공정(재고생산 등)은 수주 상태에 영향 주지 않음 if ($this->isAuxiliaryWorkOrder($workOrder)) { return; } $order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id); if (! $order) { return; } // 작업지시 상태 → 수주 상태 매핑 $statusMap = [ WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION, WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED, WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED, ]; $newOrderStatus = $statusMap[$workOrder->status] ?? null; // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 if (! $newOrderStatus || $order->status_code === $newOrderStatus) { return; } $oldOrderStatus = $order->status_code; $order->status_code = $newOrderStatus; $order->updated_by = $this->apiUserId(); $order->save(); // 수주 상태 동기화 감사 로그 $this->auditLogger->log( $tenantId, 'order', $order->id, 'status_synced_from_work_order', ['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id], ['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id] ); } /** * 자재 투입 시 작업지시가 대기 상태이면 자동으로 진행중으로 전환 * * pending/waiting 상태에서 첫 자재 투입이 발생하면 * 작업지시 → in_progress, 수주 → IN_PRODUCTION 으로 자동 전환 */ private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $tenantId): void { // 보조 공정(재고생산 등)은 WO 자체는 진행중으로 전환하되, 수주 상태는 변경하지 않음 $isAuxiliary = $this->isAuxiliaryWorkOrder($workOrder); // 아직 진행 전인 상태에서만 자동 전환 (자재투입 = 실질적 작업 시작) if (! in_array($workOrder->status, [ WorkOrder::STATUS_UNASSIGNED, WorkOrder::STATUS_PENDING, WorkOrder::STATUS_WAITING, ])) { return; } $oldStatus = $workOrder->status; $workOrder->status = WorkOrder::STATUS_IN_PROGRESS; $workOrder->updated_by = $this->apiUserId(); $workOrder->save(); // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrder->id, 'status_auto_changed_on_material_input', ['status' => $oldStatus], ['status' => WorkOrder::STATUS_IN_PROGRESS] ); // 보조 공정이 아닌 경우만 수주 상태 동기화 if (! $isAuxiliary) { $this->syncOrderStatus($workOrder, $tenantId); } } /** * 작업지시 품목에 결과 데이터 저장 */ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): void { $items = $workOrder->items; $lotNo = $this->generateLotNo($workOrder); foreach ($items as $item) { $itemResult = [ 'completed_at' => now()->toDateTimeString(), 'good_qty' => $item->quantity, // 기본값: 지시수량 전체가 양품 'defect_qty' => 0, 'defect_rate' => 0, 'lot_no' => $lotNo, 'is_inspected' => false, 'is_packaged' => false, 'worker_id' => $userId, 'memo' => null, ]; // 개별 품목 결과 데이터가 있으면 병합 if ($resultData && isset($resultData['items'][$item->id])) { $itemResult = array_merge($itemResult, $resultData['items'][$item->id]); // 불량률 재계산 $totalQty = ($itemResult['good_qty'] ?? 0) + ($itemResult['defect_qty'] ?? 0); $itemResult['defect_rate'] = $totalQty > 0 ? round(($itemResult['defect_qty'] / $totalQty) * 100, 2) : 0; } // 품목 상태도 완료로 변경 $item->status = WorkOrderItem::STATUS_COMPLETED; $options = $item->options ?? []; $options['result'] = $itemResult; $item->options = $options; $item->save(); } } /** * 보조 공정(재고생산 등) 여부 판단 */ private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool { $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []); return ! empty($options['is_auxiliary']); } /** * LOT 번호 생성 */ private function generateLotNo(WorkOrder $workOrder): string { $date = now()->format('ymd'); $prefix = 'KD-SA'; // 오늘 날짜의 마지막 LOT 번호 조회 $lastLotNo = WorkOrderItem::where('tenant_id', $workOrder->tenant_id) ->whereNotNull('options->result->lot_no') ->where('options->result->lot_no', 'like', "{$prefix}-{$date}-%") ->orderByDesc('id') ->value('options->result->lot_no'); if ($lastLotNo) { // 마지막 번호에서 시퀀스 추출 후 증가 $parts = explode('-', $lastLotNo); $seq = (int) end($parts) + 1; } else { $seq = 1; } return sprintf('%s-%s-%02d', $prefix, $date, $seq); } /** * 담당자 배정 (다중 담당자 지원) * * @param int $id 작업지시 ID * @param array $data 배정 데이터 (assignee_ids: int[], team_id?: int) */ public function assign(int $id, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($workOrder, $data, $tenantId, $userId) { // 이전 상태 기록 $beforeAssignees = $workOrder->assignees()->pluck('user_id')->toArray(); $beforePrimaryAssignee = $workOrder->assignee_id; $beforeTeam = $workOrder->team_id; // 담당자 ID 배열 처리 (단일 값도 배열로 변환) $assigneeIds = $data['assignee_ids'] ?? []; if (isset($data['assignee_id']) && ! empty($data['assignee_id'])) { // 하위 호환: 단일 assignee_id도 지원 $assigneeIds = is_array($data['assignee_id']) ? $data['assignee_id'] : [$data['assignee_id']]; } $assigneeIds = array_unique(array_filter($assigneeIds)); // 기존 담당자 삭제 후 새로 추가 $workOrder->assignees()->delete(); foreach ($assigneeIds as $index => $assigneeId) { WorkOrderAssignee::create([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrder->id, 'user_id' => $assigneeId, 'is_primary' => $index === 0, // 첫 번째가 주 담당자 ]); } // 주 담당자는 work_orders 테이블에도 설정 (하위 호환) $primaryAssigneeId = $assigneeIds[0] ?? null; $workOrder->assignee_id = $primaryAssigneeId; $workOrder->team_id = $data['team_id'] ?? $workOrder->team_id; $workOrder->updated_by = $userId; // 미배정이었으면 대기로 변경 if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED && $primaryAssigneeId) { $workOrder->status = WorkOrder::STATUS_PENDING; } $workOrder->save(); // 배정 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrder->id, 'assigned', [ 'assignee_id' => $beforePrimaryAssignee, 'assignee_ids' => $beforeAssignees, 'team_id' => $beforeTeam, ], [ 'assignee_id' => $workOrder->assignee_id, 'assignee_ids' => $assigneeIds, 'team_id' => $workOrder->team_id, ] ); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); }); } /** * 벤딩 항목 토글 */ public function toggleBendingField(int $id, string $field) { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with('process:id,process_name,process_code') ->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } if (! $workOrder->isBending()) { throw new BadRequestHttpException(__('error.work_order.not_bending_process')); } $detail = $workOrder->bendingDetail; if (! $detail) { $detail = $workOrder->bendingDetail()->create([ 'tenant_id' => $workOrder->tenant_id, ]); } if (! in_array($field, WorkOrderBendingDetail::PROCESS_FIELDS)) { throw new BadRequestHttpException(__('error.invalid_field')); } $beforeValue = $detail->{$field}; $detail->toggleField($field); // 벤딩 토글 감사 로그 $this->auditLogger->log( $workOrder->tenant_id, self::AUDIT_TARGET, $workOrder->id, 'bending_toggled', [$field => $beforeValue], [$field => $detail->{$field}] ); return $detail; } /** * 이슈 추가 */ public function addIssue(int $workOrderId, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $data['tenant_id'] = $tenantId; $data['reported_by'] = $userId; $issue = $workOrder->issues()->create($data); // 이슈 추가 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'issue_added', null, ['issue_id' => $issue->id, 'title' => $issue->title, 'priority' => $issue->priority] ); return $issue; } /** * 이슈 해결 */ public function resolveIssue(int $workOrderId, int $issueId) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $issue = $workOrder->issues()->find($issueId); if (! $issue) { throw new NotFoundHttpException(__('error.not_found')); } $issue->resolve($userId); // 이슈 해결 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'issue_resolved', ['issue_id' => $issueId, 'status' => 'open'], ['issue_id' => $issueId, 'status' => 'resolved', 'resolved_by' => $userId] ); return $issue; } /** * 작업지시번호 자동 생성 */ private function generateWorkOrderNo(int $tenantId): string { $prefix = 'WO'; $date = now()->format('Ymd'); // 오늘 날짜 기준 마지막 번호 조회 $lastNo = WorkOrder::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('work_order_no', 'like', "{$prefix}{$date}%") ->orderByDesc('work_order_no') ->value('work_order_no'); if ($lastNo) { $seq = (int) substr($lastNo, -4) + 1; } else { $seq = 1; } return sprintf('%s%s%04d', $prefix, $date, $seq); } /** * 품목 상태 변경 */ public function updateItemStatus(int $workOrderId, int $itemId, string $status) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $item = $workOrder->items()->find($itemId); if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } // 상태 유효성 검증 if (! in_array($status, WorkOrderItem::STATUSES)) { throw new BadRequestHttpException(__('error.invalid_status')); } $beforeStatus = $item->status; $item->status = $status; $item->save(); // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'item_status_changed', ['item_id' => $itemId, 'status' => $beforeStatus], ['item_id' => $itemId, 'status' => $status] ); // 작업지시 상태 자동 연동 $workOrderStatusChanged = $this->syncWorkOrderStatusFromItems($workOrder); // 품목과 함께 작업지시 상태도 반환 return [ 'item' => $item, 'work_order_status' => $workOrder->fresh()->status, 'work_order_status_changed' => $workOrderStatusChanged, ]; } /** * 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 로트별 재고) * * 작업지시 품목의 BOM 자재별로 StockLot(입고 로트)를 FIFO 순서로 반환합니다. * 동일 자재가 여러 작업지시 품목에 걸쳐 있으면 필요수량을 합산하고 로트는 중복 없이 반환합니다. * * @param int $workOrderId 작업지시 ID * @return array 자재 목록 (로트 단위) */ public function getMaterials(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with(['items.item']) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } // ── Step 1: dynamic_bom 대상 item_id 일괄 수집 (N+1 방지) ── $allDynamicItemIds = []; foreach ($workOrder->items as $woItem) { $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); $dynamicBom = $options['dynamic_bom'] ?? null; if ($dynamicBom && is_array($dynamicBom)) { $allDynamicItemIds = array_merge($allDynamicItemIds, array_column($dynamicBom, 'child_item_id')); } } // 배치 조회 (dynamic_bom 품목) $dynamicItems = []; if (! empty($allDynamicItemIds)) { $dynamicItems = \App\Models\Items\Item::where('tenant_id', $tenantId) ->whereIn('id', array_unique($allDynamicItemIds)) ->get() ->keyBy('id'); } // ── Step 2: 유니크 자재 목록 수집 ── // 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}" $uniqueMaterials = []; foreach ($workOrder->items as $woItem) { $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); $dynamicBom = $options['dynamic_bom'] ?? null; // dynamic_bom 우선 — 있으면 BOM 무시 if ($dynamicBom && is_array($dynamicBom)) { foreach ($dynamicBom as $bomEntry) { $childItemId = $bomEntry['child_item_id'] ?? null; if (! $childItemId || ! isset($dynamicItems[$childItemId])) { continue; } // 합산 키: (item_id, work_order_item_id) 쌍 $key = $childItemId.'_'.$woItem->id; // dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출 $bomQty = (float) ($bomEntry['qty'] ?? 1); $woItemQty = max(1, (float) ($woItem->quantity ?? 1)); $perNodeQty = $bomQty / $woItemQty; if (isset($uniqueMaterials[$key])) { $uniqueMaterials[$key]['required_qty'] += $perNodeQty; } else { $uniqueMaterials[$key] = [ 'item' => $dynamicItems[$childItemId], 'bom_qty' => $perNodeQty, 'required_qty' => $perNodeQty, 'work_order_item_id' => $woItem->id, 'lot_prefix' => $bomEntry['lot_prefix'] ?? null, 'part_type' => $bomEntry['part_type'] ?? null, 'category' => $bomEntry['category'] ?? null, ]; } } continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀 } // 기존 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 기준 합산 foreach ($materialItems as $matInfo) { $itemId = $matInfo['item']->id; if (isset($uniqueMaterials[$itemId])) { $uniqueMaterials[$itemId]['required_qty'] += $matInfo['required_qty']; } else { $uniqueMaterials[$itemId] = $matInfo; } } } // ── Step 3: 유니크 자재별로 StockLot 조회 ── // 배치 조회를 위해 전체 item_id 수집 $allItemIds = []; foreach ($uniqueMaterials as $matInfo) { $allItemIds[] = $matInfo['item']->id; } $allItemIds = array_unique($allItemIds); // Stock 배치 조회 (N+1 방지) $stockMap = []; if (! empty($allItemIds)) { $stocks = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) ->whereIn('item_id', $allItemIds) ->get(); foreach ($stocks as $stock) { $stockMap[$stock->item_id] = $stock; } } // StockLot 배치 조회 (N+1 방지) $lotsByStockId = []; $stockIds = array_map(fn ($s) => $s->id, $stockMap); if (! empty($stockIds)) { $allLots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) ->whereIn('stock_id', $stockIds) ->where('status', 'available') ->where('available_qty', '>', 0) ->orderBy('fifo_order', 'asc') ->get(); foreach ($allLots as $lot) { $lotsByStockId[$lot->stock_id][] = $lot; } } $materials = []; $rank = 1; foreach ($uniqueMaterials as $matInfo) { $materialItem = $matInfo['item']; $stock = $stockMap[$materialItem->id] ?? null; $lotsFound = false; // 공통 필드 (dynamic_bom 추가 필드 포함) $extraFields = []; if (isset($matInfo['work_order_item_id'])) { $extraFields = [ 'work_order_item_id' => $matInfo['work_order_item_id'], 'lot_prefix' => $matInfo['lot_prefix'], 'part_type' => $matInfo['part_type'], 'category' => $matInfo['category'], ]; } if ($stock) { $lots = $lotsByStockId[$stock->id] ?? []; foreach ($lots as $lot) { $lotsFound = true; $materials[] = array_merge([ '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'], '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++, ], $extraFields); } } // 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시) if (! $lotsFound) { $materials[] = array_merge([ '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'], 'lot_qty' => 0, 'lot_available_qty' => 0, 'lot_reserved_qty' => 0, 'receipt_date' => null, 'supplier' => null, 'fifo_rank' => $rank++, ], $extraFields); } } return $materials; } /** * 자재 투입 등록 (로트 지정 차감) * * 사용자가 선택한 로트별로 지정 수량을 차감합니다. * * @param int $workOrderId 작업지시 ID * @param array $inputs 투입 목록 [['stock_lot_id' => int, 'qty' => float], ...] * @return array 투입 결과 * * @throws \Exception 재고 부족 시 */ public function registerMaterialInput(int $workOrderId, 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')); } // work_order_item_id가 있는 항목은 registerMaterialInputForItem()으로 위임 $groupedByItem = []; $noItemInputs = []; foreach ($inputs as $input) { $woItemId = $input['work_order_item_id'] ?? null; if ($woItemId) { $groupedByItem[$woItemId][] = $input; } else { $noItemInputs[] = $input; } } // work_order_item_id가 있는 항목 → 개소별 투입으로 위임 $delegatedResults = []; foreach ($groupedByItem as $woItemId => $itemInputs) { $delegatedResults[] = $this->registerMaterialInputForItem($workOrderId, $woItemId, $itemInputs); } // work_order_item_id가 없는 항목 → 기존 방식 + WorkOrderMaterialInput 레코드 생성 if (empty($noItemInputs)) { // 전부 위임된 경우 $totalCount = array_sum(array_column($delegatedResults, 'material_count')); $allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults)); // 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환 $this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId); return [ 'work_order_id' => $workOrderId, 'material_count' => $totalCount, 'input_results' => $allResults, 'input_at' => now()->toDateTimeString(), ]; } // fallback: 첫 번째 work_order_item_id로 매핑 $fallbackWoItemId = WorkOrderItem::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->orderBy('id') ->value('id'); return DB::transaction(function () use ($noItemInputs, $tenantId, $userId, $workOrderId, $fallbackWoItemId, $delegatedResults) { $stockService = app(StockService::class); $inputResults = []; foreach ($noItemInputs 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 ); // WorkOrderMaterialInput 레코드 생성 (이력 통일) $lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId); $lotItemId = $lot?->stock?->item_id; WorkOrderMaterialInput::create([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrderId, 'work_order_item_id' => $fallbackWoItemId, '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', null, [ 'inputs' => $noItemInputs, 'input_results' => $inputResults, 'input_by' => $userId, 'input_at' => now()->toDateTimeString(), ] ); // 위임된 결과와 합산 $allResults = $inputResults; foreach ($delegatedResults as $dr) { $allResults = array_merge($allResults, $dr['input_results']); } // 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환 $this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId); return [ 'work_order_id' => $workOrderId, 'material_count' => count($allResults), 'input_results' => $allResults, 'input_at' => now()->toDateTimeString(), ]; }); } /** * 품목 상태 기반으로 작업지시 상태 자동 동기화 * * 규칙: * - 품목 중 하나라도 in_progress → 작업지시 in_progress (pending에서도 자동 전환) * - 모든 품목이 completed → 작업지시 completed * - 모든 품목이 waiting → 작업지시 waiting (단, waiting 이상인 경우만) * - 미배정(unassigned) 상태에서는 동기화하지 않음 * * @return bool 상태 변경 여부 */ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool { // 품목이 없으면 동기화하지 않음 $items = $workOrder->items()->get(); if ($items->isEmpty()) { return false; } // 미배정(unassigned) 상태에서는 동기화하지 않음 (배정 없이 작업 시작 불가) if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED) { return false; } // 품목 상태 집계 $statusCounts = $items->groupBy('status')->map->count(); $totalItems = $items->count(); $waitingCount = $statusCounts->get(WorkOrderItem::STATUS_WAITING, 0); $inProgressCount = $statusCounts->get(WorkOrderItem::STATUS_IN_PROGRESS, 0); $completedCount = $statusCounts->get(WorkOrderItem::STATUS_COMPLETED, 0); // 새 상태 결정 $newStatus = null; if ($inProgressCount > 0) { // 하나라도 진행중이면 작업지시도 진행중 $newStatus = WorkOrder::STATUS_IN_PROGRESS; } elseif ($completedCount === $totalItems) { // 모두 완료면 작업지시도 완료 $newStatus = WorkOrder::STATUS_COMPLETED; } elseif ($waitingCount === $totalItems) { // 모두 대기면 작업지시도 대기 $newStatus = WorkOrder::STATUS_WAITING; } // 상태가 변경되어야 하고, 현재와 다른 경우에만 업데이트 if ($newStatus && $newStatus !== $workOrder->status) { $oldStatus = $workOrder->status; $workOrder->status = $newStatus; // 상태에 따른 타임스탬프 업데이트 if ($newStatus === WorkOrder::STATUS_IN_PROGRESS && ! $workOrder->started_at) { $workOrder->started_at = now(); } elseif ($newStatus === WorkOrder::STATUS_COMPLETED) { $workOrder->completed_at = now(); } $workOrder->save(); // 상태 변경 감사 로그 $this->auditLogger->log( $workOrder->tenant_id, self::AUDIT_TARGET, $workOrder->id, 'status_synced_from_items', ['status' => $oldStatus], ['status' => $newStatus] ); // 완료 시 수주 상태 동기화 및 자동 출하 생성 if ($newStatus === WorkOrder::STATUS_COMPLETED) { $this->syncOrderStatus($workOrder, $workOrder->tenant_id); $this->createShipmentFromWorkOrder($workOrder, $workOrder->tenant_id, $this->apiUserId()); } return true; } return false; } // ────────────────────────────────────────────────────────────── // 공정 단계 진행 관리 // ────────────────────────────────────────────────────────────── /** * 작업지시의 공정 단계 진행 현황 조회 * * process_steps 마스터 기준으로 진행 레코드를 자동 생성(없으면)하고 반환 */ 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'), 'items', ]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $processSteps = $workOrder->process?->steps ?? collect(); if ($processSteps->isEmpty()) { return []; } $items = $workOrder->items; $result = []; 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, '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; } /** * 공정 단계 완료 토글 */ public function toggleStepProgress(int $workOrderId, int $progressId): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $progress = WorkOrderStepProgress::where('id', $progressId) ->where('work_order_id', $workOrderId) ->first(); if (! $progress) { throw new NotFoundHttpException(__('error.not_found')); } $before = ['status' => $progress->status]; $progress->toggle($userId); $after = ['status' => $progress->status]; $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'step_progress_toggled', $before, $after ); return [ 'id' => $progress->id, 'status' => $progress->status, 'is_completed' => $progress->isCompleted(), 'completed_at' => $progress->completed_at?->toDateTimeString(), 'completed_by' => $progress->completed_by, ]; } /** * 자재 투입 이력 조회 */ public function getMaterialInputHistory(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } // audit_logs에서 material_input 액션 이력 조회 $logs = DB::table('audit_logs') ->where('tenant_id', $tenantId) ->where('target_type', self::AUDIT_TARGET) ->where('target_id', $workOrderId) ->where('action', 'material_input') ->orderByDesc('created_at') ->get(); return $logs->map(function ($log) { $after = json_decode($log->after_data ?? '{}', true); return [ 'id' => $log->id, 'materials' => $after['input_results'] ?? [], 'created_at' => $log->created_at, 'actor_id' => $log->actor_id, ]; })->toArray(); } /** * 작업지시에 투입된 자재 LOT 번호 조회 (stock_transactions 기반) * * stock_transactions에서 reference_type='work_order_input'인 거래를 조회하여 * 중복 없는 LOT 번호 목록을 반환합니다. */ public function getMaterialInputLots(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $transactions = DB::table('stock_transactions') ->where('tenant_id', $tenantId) ->where('reference_type', StockTransaction::REASON_WORK_ORDER_INPUT) ->where('reference_id', $workOrderId) ->orderBy('created_at') ->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']); // 품목코드별 그룹핑 (작업일지에서 item_code → lot_no 매핑에 사용) $itemMap = []; foreach ($transactions as $tx) { $itemCode = $tx->item_code; if (! isset($itemMap[$itemCode])) { $itemMap[$itemCode] = [ 'item_code' => $itemCode, 'lot_no' => $tx->lot_no, 'item_name' => $tx->item_name, 'total_qty' => 0, 'input_count' => 0, 'first_input_at' => $tx->created_at, ]; } $itemMap[$itemCode]['total_qty'] += abs((float) $tx->qty); $itemMap[$itemCode]['input_count']++; } return array_values($itemMap); } // ────────────────────────────────────────────────────────────── // 중간검사 관련 // ────────────────────────────────────────────────────────────── /** * 품목별 중간검사 데이터 저장 */ public function storeItemInspection(int $workOrderId, int $itemId, array $data): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $item = $workOrder->items()->find($itemId); if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } $beforeData = $item->getInspectionData(); $inspectionData = $data['inspection_data']; $inspectionData['process_type'] = $data['process_type']; $inspectionData['inspected_at'] = now()->toDateTimeString(); $inspectionData['inspected_by'] = $userId; $item->setInspectionData($inspectionData); $item->save(); // 절곡 공정: 수주 단위 검사 → 동일 작업지시의 모든 item에 검사 데이터 복제 $processType = $data['process_type'] ?? ''; if (in_array($processType, ['bending', 'bending_wip'])) { $otherItems = $workOrder->items()->where('id', '!=', $itemId)->get(); foreach ($otherItems as $otherItem) { $otherItem->setInspectionData($inspectionData); $otherItem->save(); } } // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'item_inspection_saved', ['item_id' => $itemId, 'inspection_data' => $beforeData], ['item_id' => $itemId, 'inspection_data' => $inspectionData] ); return [ 'item_id' => $item->id, 'inspection_data' => $item->getInspectionData(), ]; } /** * 작업지시의 전체 품목 검사 데이터 조회 */ public function getInspectionData(int $workOrderId, array $params = []): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $query = $workOrder->items()->ordered(); // 공정 유형 필터 if (! empty($params['process_type'])) { $query->where('options->inspection_data->process_type', $params['process_type']); } $items = $query->get(); $inspectionMap = []; foreach ($items as $item) { $inspectionData = $item->getInspectionData(); if ($inspectionData) { $inspectionMap[$item->id] = [ 'item_id' => $item->id, 'item_name' => $item->item_name, 'specification' => $item->specification, 'quantity' => $item->quantity, 'sort_order' => $item->sort_order, 'options' => $item->options, 'inspection_data' => $inspectionData, ]; } } return [ 'work_order_id' => $workOrderId, 'items' => array_values($inspectionMap), 'total' => count($inspectionMap), ]; } // ────────────────────────────────────────────────────────────── // 검사 설정 (inspection-config) // ────────────────────────────────────────────────────────────── /** * 절곡 검사 기준 간격 프로파일 (5130 레거시 기준 S1/S2/S3 마감유형별) * * S1: KSS01 계열 (KQTS01 포함) * S2: KSS02 계열 (EGI 마감 포함) * S3: KWE01/KSE01 + SUS 별도마감 * * 향후 DB 테이블 또는 테넌트 설정으로 이관 가능 */ private const BENDING_GAP_PROFILES = [ 'S1' => [ 'guide_rail_wall' => [ 'name' => '가이드레일(벽면형)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '30'], ['point' => '(2)', 'design_value' => '80'], ['point' => '(3)', 'design_value' => '45'], ['point' => '(4)', 'design_value' => '40'], ], ], 'guide_rail_side' => [ 'name' => '가이드레일(측면형)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '30'], ['point' => '(2)', 'design_value' => '70'], ['point' => '(3)', 'design_value' => '45'], ['point' => '(4)', 'design_value' => '35'], ['point' => '(5)', 'design_value' => '95'], ['point' => '(6)', 'design_value' => '90'], ], ], 'bottom_bar' => [ 'name' => '하단마감재', 'gap_points' => [ ['point' => '(1)', 'design_value' => '60'], ], ], ], 'S2' => [ 'guide_rail_wall' => [ 'name' => '가이드레일(벽면형)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '30'], ['point' => '(2)', 'design_value' => '80'], ['point' => '(3)', 'design_value' => '45'], ], ], 'guide_rail_side' => [ 'name' => '가이드레일(측면형)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '30'], ['point' => '(2)', 'design_value' => '70'], ['point' => '(3)', 'design_value' => '45'], ['point' => '(4)', 'design_value' => '35'], ['point' => '(5)', 'design_value' => '95'], ], ], 'bottom_bar' => [ 'name' => '하단마감재', 'gap_points' => [ ['point' => '(1)', 'design_value' => '60'], ], ], ], 'S3' => [ 'guide_rail_wall' => [ 'name' => '가이드레일(벽면형·별도마감)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '30'], ['point' => '(2)', 'design_value' => '80'], ['point' => '(3)', 'design_value' => '45'], ['point' => '(4)', 'design_value' => '40'], ['point' => '(5)', 'design_value' => '34'], ], ], 'guide_rail_side' => [ 'name' => '가이드레일(측면형·별도마감)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '30'], ['point' => '(2)', 'design_value' => '70'], ['point' => '(3)', 'design_value' => '80'], ['point' => '(4)', 'design_value' => '45'], ['point' => '(5)', 'design_value' => '40'], ['point' => '(6)', 'design_value' => '34'], ['point' => '(7)', 'design_value' => '74'], ], ], 'bottom_bar' => [ 'name' => '하단마감재(별도마감)', 'gap_points' => [ ['point' => '(1)', 'design_value' => '60'], ['point' => '(2)', 'design_value' => '64'], ], ], ], 'common' => [ 'case_box' => [ 'name' => '케이스', 'gap_points' => [ ['point' => '(1)', 'design_value' => '550'], ['point' => '(2)', 'design_value' => '50'], ['point' => '(3)', 'design_value' => '385'], ['point' => '(4)', 'design_value' => '50'], ['point' => '(5)', 'design_value' => '410'], ], ], 'smoke_w50' => [ 'name' => '연기차단재 W50', 'gap_points' => [ ['point' => '(1)', 'design_value' => '50'], ['point' => '(2)', 'design_value' => '12'], ], ], 'smoke_w80' => [ 'name' => '연기차단재 W80', 'gap_points' => [ ['point' => '(1)', 'design_value' => '80'], ['point' => '(2)', 'design_value' => '12'], ], ], ], ]; /** * 작업지시의 검사 설정 조회 (공정 자동 판별 + 구성품 목록) * * 절곡 공정: bending_info 기반으로 검사 대상 구성품 + 간격 기준치 반환 * 기타 공정: items 빈 배열 (스크린/슬랫은 별도 구성품 없음) */ public function getInspectionConfig(int $workOrderId): array { $workOrder = WorkOrder::where('tenant_id', $this->tenantId()) ->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')]) ->findOrFail($workOrderId); $process = $workOrder->process; $processType = $this->resolveInspectionProcessType($process); $firstItem = $workOrder->items->first(); $productCode = $firstItem?->options['product_code'] ?? null; $templateId = $process?->document_template_id; $items = []; $finishingType = null; if ($processType === 'bending') { $finishingType = $this->resolveFinishingType($productCode); $items = $this->buildBendingInspectionItems($firstItem); } return [ 'work_order_id' => $workOrder->id, 'process_type' => $processType, 'product_code' => $productCode, 'finishing_type' => $finishingType, 'template_id' => $templateId, 'items' => $items, ]; } /** * 공정명 → 검사 공정 타입 변환 */ private function resolveInspectionProcessType(?Process $process): string { if (! $process) { return 'unknown'; } return match ($process->process_name) { '스크린' => 'screen', '슬랫' => 'slat', '절곡' => 'bending', default => strtolower($process->process_code ?? 'unknown'), }; } /** * 제품코드에서 마감유형(S1/S2/S3) 결정 (5130 레거시 기준) * * KSS01, KQTS01 → S1 * KSS02 (및 EGI 마감) → S2 * KWE01/KSE01 + SUS → S3 */ private function resolveFinishingType(?string $productCode): string { if (! $productCode) { return 'S1'; } // FG-{model}-{type}-{material} 형식에서 모델코드와 재질 추출 $parts = explode('-', $productCode); $modelCode = $parts[1] ?? ''; $material = $parts[3] ?? ''; // SUS 재질 + KWE/KSE 모델 → S3 (별도마감) if (stripos($material, 'SUS') !== false && (str_starts_with($modelCode, 'KWE') || str_starts_with($modelCode, 'KSE'))) { return 'S3'; } return match (true) { str_starts_with($modelCode, 'KSS01'), str_starts_with($modelCode, 'KQTS') => 'S1', str_starts_with($modelCode, 'KSS02') => 'S2', str_starts_with($modelCode, 'KWE'), str_starts_with($modelCode, 'KSE') => 'S2', // EGI마감 = S2 default => 'S2', // 기본값: S2 (5130 기준 EGI와 동일) }; } /** * 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드 * 마감유형(S1/S2/S3)에 따라 gap_points가 달라짐 */ private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array { if (! $firstItem) { return []; } $productCode = $firstItem->options['product_code'] ?? null; $finishingType = $this->resolveFinishingType($productCode); $typeProfiles = self::BENDING_GAP_PROFILES[$finishingType] ?? self::BENDING_GAP_PROFILES['S1']; $commonProfiles = self::BENDING_GAP_PROFILES['common']; $bendingInfo = $firstItem->options['bending_info'] ?? null; $items = []; // 가이드레일 벽면 (벽면형 또는 혼합형) $guideRail = $bendingInfo['guideRail'] ?? null; $hasWall = ! $bendingInfo || ($guideRail && ($guideRail['wall'] ?? false)); $hasSide = $guideRail && ($guideRail['side'] ?? false); if ($hasWall) { $profile = $typeProfiles['guide_rail_wall']; $items[] = [ 'id' => 'guide_rail_wall', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; } if ($hasSide) { $profile = $typeProfiles['guide_rail_side']; $items[] = [ 'id' => 'guide_rail_side', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; } // 하단마감재 (항상 포함, 마감유형별 gap_points 다름) $profile = $typeProfiles['bottom_bar']; $items[] = [ 'id' => 'bottom_bar', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; // 케이스 (항상 포함, 공통) $profile = $commonProfiles['case_box']; $items[] = [ 'id' => 'case_box', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; // 연기차단재 W50 (항상 포함, 공통) $profile = $commonProfiles['smoke_w50']; $items[] = [ 'id' => 'smoke_w50', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; // 연기차단재 W80 (항상 포함, 공통) $profile = $commonProfiles['smoke_w80']; $items[] = [ 'id' => 'smoke_w80', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; return $items; } // ────────────────────────────────────────────────────────────── // 검사 문서 템플릿 연동 // ────────────────────────────────────────────────────────────── /** * 작업지시의 검사용 문서 템플릿 조회 * * work_order → process → steps(needs_inspection=true) → documentTemplate 로드 * 모든 검사 단계의 템플릿을 반환 (다중 검사 단계 지원) */ public function getInspectionTemplate(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with([ 'process.documentTemplate' => fn ($q) => $q->with([ 'approvalLines', 'basicFields', 'sections.items', 'columns', 'sectionFields', ]), 'salesOrder:id,order_no,client_name,site_name', 'items:id,work_order_id,item_name,specification,quantity,unit,sort_order', ]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $process = $workOrder->process; $docTemplate = $process?->documentTemplate; if (! $docTemplate) { return [ 'work_order_id' => $workOrderId, 'has_template' => false, 'templates' => [], 'template' => null, 'work_order_info' => $this->buildWorkOrderInfo($workOrder), ]; } $documentService = app(DocumentService::class); $formattedTemplate = $documentService->formatTemplateForReact($docTemplate); return [ 'work_order_id' => $workOrderId, 'has_template' => true, 'templates' => [[ 'template_id' => $docTemplate->id, 'template_name' => $docTemplate->name, 'template' => $formattedTemplate, ]], 'template' => $formattedTemplate, 'work_order_info' => $this->buildWorkOrderInfo($workOrder), ]; } /** * 작업지시 검사 문서 resolve (기존 문서 조회 또는 생성 정보 반환) * * step_id 기반으로 해당 검사 단계의 템플릿과 기존 문서를 조회. * 기존 DRAFT/REJECTED 문서가 있으면 반환, 없으면 template만 반환. */ public function resolveInspectionDocument(int $workOrderId, array $params = []): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with([ 'process.documentTemplate' => fn ($q) => $q->with([ 'approvalLines', 'basicFields', 'sections.items', 'columns', 'sectionFields', ]), 'salesOrder:id,order_no,client_name,site_name', 'items:id,work_order_id,item_name,specification,quantity,unit,sort_order', ]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $process = $workOrder->process; $templateId = $process?->document_template_id; $docTemplate = $process?->documentTemplate; if (! $templateId || ! $docTemplate) { throw new BadRequestHttpException(__('error.work_order.no_inspection_template')); } $documentService = app(DocumentService::class); $formattedTemplate = $documentService->formatTemplateForReact($docTemplate); // 기존 문서 조회 (work_order + template, 수정 가능한 상태) $existingDocument = Document::query() ->where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', 'work_order') ->where('linkable_id', $workOrderId) ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) ->with(['data', 'attachments.file', 'approvals.user:id,name']) ->latest() ->first(); // Lazy Snapshot 대상: rendered_html이 없는 문서 (상태 무관) $snapshotDocumentId = null; $snapshotCandidate = Document::query() ->where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', 'work_order') ->where('linkable_id', $workOrderId) ->whereNull('rendered_html') ->latest() ->value('id'); if ($snapshotCandidate) { $snapshotDocumentId = $snapshotCandidate; } return [ 'work_order_id' => $workOrderId, 'template_id' => $templateId, 'template' => $formattedTemplate, 'existing_document' => $existingDocument, 'snapshot_document_id' => $snapshotDocumentId, 'work_order_info' => $this->buildWorkOrderInfo($workOrder), ]; } /** * 검사 완료 시 Document + DocumentData 생성 * * 공정(Process) 레벨의 document_template_id를 사용. * 기존 DRAFT/REJECTED 문서가 있으면 update, 없으면 create. */ public function createInspectionDocument(int $workOrderId, array $inspectionData): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } // 공정 레벨의 중간검사 양식 사용 $process = $workOrder->process; $templateId = $process?->document_template_id; if (! $templateId) { throw new BadRequestHttpException(__('error.work_order.no_inspection_template')); } return DB::transaction(function () use ($workOrder, $workOrderId, $tenantId, $templateId, $inspectionData) { // 동시 생성 방지: 동일 작업지시에 대한 락 $workOrder->lockForUpdate(); $documentService = app(DocumentService::class); // 기존 DRAFT/REJECTED 문서가 있으면 update $existingDocument = Document::query() ->where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', 'work_order') ->where('linkable_id', $workOrderId) ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) ->latest() ->first(); // ★ 원본 기반 동기화: 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(); $updateData = [ 'title' => $inspectionData['title'] ?? $existingDocument->title, 'data' => array_merge($existingBasicFields, $documentDataRecords), ]; if (isset($inspectionData['rendered_html'])) { $updateData['rendered_html'] = $inspectionData['rendered_html']; } $document = $documentService->update($existingDocument->id, $updateData); $action = 'inspection_document_updated'; } else { $documentData = [ 'template_id' => $templateId, 'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}", 'linkable_type' => 'work_order', 'linkable_id' => $workOrderId, 'data' => $documentDataRecords, 'approvers' => $inspectionData['approvers'] ?? [], ]; if (isset($inspectionData['rendered_html'])) { $documentData['rendered_html'] = $inspectionData['rendered_html']; } $document = $documentService->create($documentData); $action = 'inspection_document_created'; } // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, $action, null, ['document_id' => $document->id, 'document_no' => $document->document_no] ); return [ 'document_id' => $document->id, 'document_no' => $document->document_no, 'status' => $document->status, 'is_new' => $action === 'inspection_document_created', ]; }); } /** * 프론트 InspectionData를 정규화된 document_data 레코드로 변환 * * 정규화 형식 (NEW): * [{ section_id, column_id, row_index, field_key, field_value }] * field_key: 'value', 'n1', 'n2', 'n1_ok', 'n1_ng', 'overall_result', 'remark', 'row_judgment' * * 레거시 형식 (WorkerScreen): * [{ templateValues: { section_X_item_Y: "ok"|number }, judgment, nonConformingContent }] */ private function transformInspectionDataToDocumentRecords(array $rawItems, int $templateId): array { if (empty($rawItems)) { return []; } // 정규화 형식 감지: 첫 번째 요소에 field_key가 있으면 새 형식 if (isset($rawItems[0]['field_key'])) { return array_map(fn (array $item) => [ 'section_id' => $item['section_id'] ?? null, 'column_id' => $item['column_id'] ?? null, 'row_index' => $item['row_index'] ?? 0, 'field_key' => $item['field_key'], 'field_value' => $item['field_value'] ?? null, ], $rawItems); } // 절곡 products 배열 감지 → bending 전용 EAV 레코드 생성 $productsItem = collect($rawItems)->first(fn ($item) => isset($item['products']) && is_array($item['products'])); if ($productsItem) { return $this->transformBendingProductsToRecords($productsItem, $templateId); } // 레거시 형식: templateValues/values 기반 → 정규화 변환 return $this->normalizeOldFormatRecords($rawItems, $templateId); } /** * 절곡 products 배열 → bending 전용 EAV 레코드 변환 * * InspectionInputModal이 저장하는 products 형식: * [{ id, bendingStatus: '양호'|'불량', lengthMeasured, widthMeasured, gapPoints: [{point, designValue, measured}] }] * * 프론트엔드 TemplateInspectionContent가 기대하는 EAV field_key 형식: * b{productIdx}_ok / b{productIdx}_ng, b{productIdx}_n1, b{productIdx}_p{pointIdx}_n1 */ private function transformBendingProductsToRecords(array $item, int $templateId): array { $template = DocumentTemplate::with(['columns'])->find($templateId); if (! $template) { return []; } // 컬럼 식별 (column_type + sort_order 기반) $checkCol = $template->columns->firstWhere('column_type', 'check'); $complexCols = $template->columns->where('column_type', 'complex')->sortBy('sort_order')->values(); // complex 컬럼 순서: 길이(0), 너비(1), 간격(2) $lengthCol = $complexCols->get(0); $widthCol = $complexCols->get(1); $gapCol = $complexCols->get(2); $records = []; $products = $item['products']; foreach ($products as $productIdx => $product) { // 절곡상태 → check column if ($checkCol) { if (($product['bendingStatus'] ?? null) === '양호') { $records[] = [ 'section_id' => null, 'column_id' => $checkCol->id, 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ok", 'field_value' => 'OK', ]; } elseif (($product['bendingStatus'] ?? null) === '불량') { $records[] = [ 'section_id' => null, 'column_id' => $checkCol->id, 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ng", 'field_value' => 'NG', ]; } } // 길이 → first complex column if ($lengthCol && ! empty($product['lengthMeasured'])) { $records[] = [ 'section_id' => null, 'column_id' => $lengthCol->id, 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['lengthMeasured'], ]; } // 너비 → second complex column if ($widthCol && ! empty($product['widthMeasured'])) { $records[] = [ 'section_id' => null, 'column_id' => $widthCol->id, 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['widthMeasured'], ]; } // 간격 포인트 → third complex column (gap) if ($gapCol && ! empty($product['gapPoints'])) { foreach ($product['gapPoints'] as $pointIdx => $gp) { if (! empty($gp['measured'])) { $records[] = [ 'section_id' => null, 'column_id' => $gapCol->id, 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_p{$pointIdx}_n1", 'field_value' => (string) $gp['measured'], ]; } } } } // 전체 판정 if (isset($item['judgment'])) { $records[] = [ 'section_id' => null, 'column_id' => null, 'row_index' => 0, 'field_key' => 'overall_result', 'field_value' => (string) $item['judgment'], ]; } // 부적합 내용 if (! empty($item['nonConformingContent'])) { $records[] = [ 'section_id' => null, 'column_id' => null, 'row_index' => 0, 'field_key' => 'remark', 'field_value' => (string) $item['nonConformingContent'], ]; } return $records; } /** * 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환 */ private function normalizeOldFormatRecords(array $rawItems, int $templateId): array { $template = DocumentTemplate::with(['sections.items', 'columns'])->find($templateId); if (! $template) { return []; } // sectionItem.id → { section_id, column_id, measurement_type } 매핑 $itemMap = $this->buildItemColumnMap($template); $records = []; foreach ($rawItems as $rowIdx => $item) { $values = $item['values'] ?? $item['templateValues'] ?? []; foreach ($values as $key => $cellValue) { // section_{sectionId}_item_{itemId} 또는 item_{itemId} 형식 파싱 if (! preg_match('/^(?:section_(\d+)_)?item_(\d+)$/', $key, $m)) { continue; } $sectionId = $m[1] ? (int) $m[1] : null; $itemId = (int) $m[2]; $info = $itemMap[$itemId] ?? null; $columnId = $info['column_id'] ?? null; $sectionId = $sectionId ?? ($info['section_id'] ?? null); $expanded = $this->expandCellValue($cellValue, $info['measurement_type'] ?? ''); foreach ($expanded as $rec) { $records[] = [ 'section_id' => $sectionId, 'column_id' => $columnId, 'row_index' => $rowIdx, 'field_key' => $rec['field_key'], 'field_value' => $rec['field_value'], ]; } } // 행 판정 $judgment = $item['judgment'] ?? null; if ($judgment !== null) { $records[] = [ 'section_id' => null, 'column_id' => null, 'row_index' => $rowIdx, 'field_key' => 'row_judgment', 'field_value' => (string) $judgment, ]; } // 부적합 내용 if (! empty($item['nonConformingContent'])) { $records[] = [ 'section_id' => null, 'column_id' => null, 'row_index' => $rowIdx, 'field_key' => 'remark', 'field_value' => (string) $item['nonConformingContent'], ]; } } return $records; } /** * 템플릿 구조에서 sectionItem → (section_id, column_id) 매핑 구축 */ private function buildItemColumnMap(DocumentTemplate $template): array { $map = []; foreach ($template->sections as $section) { foreach ($section->items as $item) { $itemLabel = $this->normalizeInspectionLabel($item->getFieldValue('item') ?? ''); $columnId = null; foreach ($template->columns as $col) { $colLabel = $this->normalizeInspectionLabel($col->label); if ($itemLabel && $colLabel === $itemLabel) { $columnId = $col->id; break; } } $map[$item->id] = [ 'section_id' => $section->id, 'column_id' => $columnId, 'measurement_type' => $item->getFieldValue('measurement_type') ?? '', ]; } } return $map; } /** * CellValue를 개별 field_key/field_value 레코드로 확장 */ private function expandCellValue(mixed $cellValue, string $measurementType): array { if ($cellValue === null) { return []; } // 단순 문자열/숫자 → value 레코드 if (is_string($cellValue) || is_numeric($cellValue)) { return [['field_key' => 'value', 'field_value' => (string) $cellValue]]; } if (! is_array($cellValue)) { return [['field_key' => 'value', 'field_value' => (string) $cellValue]]; } $records = []; // measurements 배열: 복합 컬럼 데이터 if (isset($cellValue['measurements']) && is_array($cellValue['measurements'])) { foreach ($cellValue['measurements'] as $n => $val) { $nNum = $n + 1; if ($measurementType === 'checkbox') { $lower = strtolower($val ?? ''); $records[] = ['field_key' => "n{$nNum}_ok", 'field_value' => $lower === 'ok' ? 'OK' : '']; $records[] = ['field_key' => "n{$nNum}_ng", 'field_value' => $lower === 'ng' ? 'NG' : '']; } else { $records[] = ['field_key' => "n{$nNum}", 'field_value' => (string) ($val ?? '')]; } } } // value 필드: 단일 값 if (isset($cellValue['value'])) { $records[] = ['field_key' => 'value', 'field_value' => (string) $cellValue['value']]; } // text 필드: 텍스트 값 if (isset($cellValue['text'])) { $records[] = ['field_key' => 'value', 'field_value' => (string) $cellValue['text']]; } // 아무 필드도 매칭 안 되면 JSON으로 저장 return $records ?: [['field_key' => 'value', 'field_value' => json_encode($cellValue)]]; } /** * 라벨 정규화 (매칭용) */ private function normalizeInspectionLabel(string $label): string { $label = trim($label); // ①②③ 등 번호 접두사 제거 $label = preg_replace('/^[①②③④⑤⑥⑦⑧⑨⑩]+/', '', $label); return mb_strtolower(trim($label)); } /** * 작업지시 기본정보 빌드 (검사 문서 렌더링용) */ private function buildWorkOrderInfo(WorkOrder $workOrder): array { return [ 'id' => $workOrder->id, 'work_order_no' => $workOrder->work_order_no, 'project_name' => $workOrder->project_name, 'status' => $workOrder->status, 'scheduled_date' => $workOrder->scheduled_date, 'sales_order' => $workOrder->salesOrder ? [ 'order_no' => $workOrder->salesOrder->order_no, 'client_name' => $workOrder->salesOrder->client_name, 'site_name' => $workOrder->salesOrder->site_name, ] : null, 'items' => $workOrder->items?->map(fn ($item) => [ 'id' => $item->id, 'item_name' => $item->item_name, 'specification' => $item->specification, 'quantity' => $item->quantity, 'unit' => $item->unit, ])->toArray() ?? [], ]; } /** * 작업지시 검사 성적서용 데이터 조회 (전체 품목 + 검사 데이터 + 주문 정보) */ public function getInspectionReport(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with(['salesOrder', 'items' => function ($q) { $q->ordered()->with('sourceOrderItem'); }]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } // 개소(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' => [ 'id' => $workOrder->id, 'order_no' => $workOrder->order_no, 'status' => $workOrder->status, 'planned_date' => $workOrder->planned_date, 'due_date' => $workOrder->due_date, ], '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(), 'inspected_items' => $items->filter(fn ($i) => $i['inspection_data'] !== null)->count(), 'passed_items' => $items->filter(fn ($i) => ($i['inspection_data']['judgment'] ?? null) === 'pass')->count(), 'failed_items' => $items->filter(fn ($i) => ($i['inspection_data']['judgment'] ?? null) === 'fail')->count(), ], ]; } // ────────────────────────────────────────────────────────────── // 작업일지 (Work Log) // ────────────────────────────────────────────────────────────── /** * 작업일지 양식 템플릿 조회 * * 공정(Process)의 work_log_template_id 기반으로 작업일지 양식을 조회하고 * 기본필드에 작업지시 정보를 자동 매핑하여 반환 */ public function getWorkLogTemplate(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with([ 'process.workLogTemplateRelation' => fn ($q) => $q->with([ 'approvalLines', 'basicFields', 'columns', ]), 'salesOrder:id,order_no,client_name,site_name,delivery_date', 'items:id,work_order_id,item_name,specification,quantity,unit,sort_order,status', ]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $process = $workOrder->process; $docTemplate = $process?->workLogTemplateRelation; if (! $docTemplate) { return [ 'work_order_id' => $workOrderId, 'has_template' => false, 'template' => null, 'work_order_info' => $this->buildWorkOrderInfo($workOrder), ]; } $documentService = app(DocumentService::class); $formattedTemplate = $documentService->formatTemplateForReact($docTemplate); // 기본필드 자동 매핑 (발주처, 현장명, LOT NO 등) $autoValues = $this->buildWorkLogAutoValues($workOrder); return [ 'work_order_id' => $workOrderId, 'has_template' => true, 'template' => $formattedTemplate, 'auto_values' => $autoValues, 'work_order_info' => $this->buildWorkOrderInfo($workOrder), 'work_stats' => $this->calculateWorkStats($workOrder), ]; } /** * 작업일지 조회 (기존 문서가 있으면 데이터 포함) */ public function getWorkLog(int $workOrderId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with([ 'process.workLogTemplateRelation' => fn ($q) => $q->with([ 'approvalLines', 'basicFields', 'columns', ]), 'salesOrder:id,order_no,client_name,site_name,delivery_date', 'items:id,work_order_id,item_name,specification,quantity,unit,sort_order,status', ]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $process = $workOrder->process; $templateId = $process?->work_log_template_id; // 기존 작업일지 문서 조회 $document = null; if ($templateId) { $document = Document::query() ->where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', 'work_order') ->where('linkable_id', $workOrderId) ->with(['approvals.user:id,name', 'data']) ->latest() ->first(); } $docTemplate = $process?->workLogTemplateRelation; $formattedTemplate = null; if ($docTemplate) { $documentService = app(DocumentService::class); $formattedTemplate = $documentService->formatTemplateForReact($docTemplate); } return [ 'work_order_id' => $workOrderId, 'has_template' => $docTemplate !== null, 'template' => $formattedTemplate, 'document' => $document ? [ 'id' => $document->id, 'document_no' => $document->document_no, 'status' => $document->status, 'submitted_at' => $document->submitted_at, 'completed_at' => $document->completed_at, 'approvals' => $document->approvals->map(fn ($a) => [ 'id' => $a->id, 'step' => $a->step, 'role' => $a->role, 'status' => $a->status, 'user' => $a->user ? ['id' => $a->user->id, 'name' => $a->user->name] : null, 'comment' => $a->comment, 'acted_at' => $a->acted_at, ])->toArray(), 'data' => $document->data->map(fn ($d) => [ 'field_key' => $d->field_key, 'field_value' => $d->field_value, 'section_id' => $d->section_id, 'column_id' => $d->column_id, 'row_index' => $d->row_index, ])->toArray(), ] : null, 'auto_values' => $this->buildWorkLogAutoValues($workOrder), 'work_order_info' => $this->buildWorkOrderInfo($workOrder), 'work_stats' => $this->calculateWorkStats($workOrder), ]; } /** * 작업일지 생성/수정 (Document 기반) * * 기존 DRAFT/REJECTED 문서가 있으면 update, 없으면 create */ 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', 'items']) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $process = $workOrder->process; $templateId = $process?->work_log_template_id; if (! $templateId) { throw new BadRequestHttpException(__('error.work_order.no_work_log_template')); } // 템플릿의 기본필드 로드 (bf_{id} 형식으로 저장하기 위해) $template = DocumentTemplate::with('basicFields')->find($templateId); $documentService = app(DocumentService::class); // 기존 DRAFT/REJECTED 문서 확인 $existingDocument = Document::query() ->where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', 'work_order') ->where('linkable_id', $workOrderId) ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) ->latest() ->first(); // 작업일지 데이터를 document_data 레코드로 변환 $documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template); if ($existingDocument) { $updateData = [ 'title' => $workLogData['title'] ?? $existingDocument->title, 'data' => $documentDataRecords, ]; if (isset($workLogData['rendered_html'])) { $updateData['rendered_html'] = $workLogData['rendered_html']; } $document = $documentService->update($existingDocument->id, $updateData); $action = 'work_log_updated'; } else { $createData = [ 'template_id' => $templateId, 'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}", 'linkable_type' => 'work_order', 'linkable_id' => $workOrderId, 'data' => $documentDataRecords, 'approvers' => $workLogData['approvers'] ?? [], ]; if (isset($workLogData['rendered_html'])) { $createData['rendered_html'] = $workLogData['rendered_html']; } $document = $documentService->create($createData); $action = 'work_log_created'; } // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, $action, null, ['document_id' => $document->id, 'document_no' => $document->document_no] ); return [ 'document_id' => $document->id, 'document_no' => $document->document_no, 'status' => $document->status, 'is_new' => $action === 'work_log_created', ]; } /** * 작업일지 기본필드 자동 매핑값 생성 */ 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' => $orderNo, '납기일' => $deliveryStr, '작업지시번호' => $workOrder->work_order_no ?? '', '수주일' => $orderDate, '수주처' => $salesOrder?->client_name ?? '', '담당자' => '', '연락처' => '', '제품 LOT NO' => $orderNo, '생산담당자' => '', '출고예정일' => $deliveryStr, ]; } /** * 작업 통계 계산 */ private function calculateWorkStats(WorkOrder $workOrder): array { $items = $workOrder->items; if (! $items || $items->isEmpty()) { return [ 'order_qty' => 0, 'completed_qty' => 0, 'in_progress_qty' => 0, 'waiting_qty' => 0, 'progress' => 0, ]; } $total = $items->count(); $completed = $items->where('status', 'completed')->count(); $inProgress = $items->where('status', 'in_progress')->count(); $waiting = $total - $completed - $inProgress; return [ 'order_qty' => $total, 'completed_qty' => $completed, 'in_progress_qty' => $inProgress, 'waiting_qty' => $waiting, 'progress' => $total > 0 ? round(($completed / $total) * 100, 1) : 0, ]; } /** * 작업일지 데이터를 document_data 레코드로 변환 * * 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, ?DocumentTemplate $template): array { $records = []; // 1. 기본필드: bf_{id} 형식으로 저장 (mng show.blade.php 호환) if ($template && $template->basicFields) { $autoValues = $this->buildWorkLogAutoValues($workOrder); $manualData = $workLogData['basic_data'] ?? []; 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, ]; } } } // 2. 작업 통계 (자동 계산) $stats = $this->calculateWorkStats($workOrder); foreach ($stats as $key => $value) { $records[] = [ 'field_key' => "stats_{$key}", 'field_value' => (string) $value, ]; } // 3. 특이사항 if (isset($workLogData['remarks'])) { $records[] = [ 'field_key' => 'remarks', 'field_value' => $workLogData['remarks'], ]; } 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 = []; // ① dynamic_bom 우선 체크 (절곡 등 동적 BOM 사용 공정) $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); $dynamicBom = $options['dynamic_bom'] ?? null; if ($dynamicBom && is_array($dynamicBom)) { // dynamic_bom child_item_id 배치 조회 (N+1 방지) $childItemIds = array_filter(array_column($dynamicBom, 'child_item_id')); $childItems = []; if (! empty($childItemIds)) { $childItems = \App\Models\Items\Item::where('tenant_id', $tenantId) ->whereIn('id', array_unique($childItemIds)) ->get() ->keyBy('id'); } foreach ($dynamicBom as $bomEntry) { $childItemId = $bomEntry['child_item_id'] ?? null; if (! $childItemId || ! isset($childItems[$childItemId])) { continue; } // LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외 $childOptions = $childItems[$childItemId]->options ?? []; if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) { continue; } // dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출 // (작업일지 bendingInfo와 동일한 수량) $bomQty = (float) ($bomEntry['qty'] ?? 1); $woItemQty = max(1, (float) ($woItem->quantity ?? 1)); $perNodeQty = $bomQty / $woItemQty; $materialItems[] = [ 'item' => $childItems[$childItemId], 'bom_qty' => $perNodeQty, 'required_qty' => $perNodeQty, 'lot_prefix' => $bomEntry['lot_prefix'] ?? null, 'part_type' => $bomEntry['part_type'] ?? null, 'category' => $bomEntry['category'] ?? null, ]; } } // ② dynamic_bom이 없으면 정적 BOM fallback if (empty($materialItems) && $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; } // LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외 $childOptions = $childItem->options ?? []; if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) { 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별 기투입 수량 조회 (stock_lot_id + bom_group_key별 SUM) $lotInputtedRaw = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->where('work_order_item_id', $itemId) ->whereNotNull('stock_lot_id') ->selectRaw('stock_lot_id, bom_group_key, SUM(qty) as total_qty') ->groupBy('stock_lot_id', 'bom_group_key') ->get(); // bom_group_key 포함 복합키 매핑 + stock_lot_id 단순 매핑 (하위호환) $lotInputtedByGroup = []; $lotInputtedByLot = []; foreach ($lotInputtedRaw as $row) { $lotId = $row->stock_lot_id; $groupKey = $row->bom_group_key; $qty = (float) $row->total_qty; if ($groupKey) { $compositeKey = $lotId.'_'.$groupKey; $lotInputtedByGroup[$compositeKey] = ($lotInputtedByGroup[$compositeKey] ?? 0) + $qty; } $lotInputtedByLot[$lotId] = ($lotInputtedByLot[$lotId] ?? 0) + $qty; } // 자재별 LOT 조회 $materials = []; $rank = 1; foreach ($materialItems as $bomIdx => $matInfo) { $materialItem = $matInfo['item']; $alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0); $remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted); // BOM 엔트리별 고유 그룹키 (같은 item_id라도 category+partType이 다르면 별도 그룹) $bomGroupKey = $materialItem->id .'_'.($matInfo['category'] ?? '') .'_'.($matInfo['part_type'] ?? ''); $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, 'bom_group_key' => $bomGroupKey, '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_inputted_qty' => (float) ($lotInputtedByGroup[$lot->id.'_'.$bomGroupKey] ?? $lotInputtedByLot[$lot->id] ?? 0), '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++, 'lot_prefix' => $matInfo['lot_prefix'] ?? null, 'part_type' => $matInfo['part_type'] ?? null, 'category' => $matInfo['category'] ?? null, ]; } } if (! $lotsFound) { $materials[] = [ 'stock_lot_id' => null, 'item_id' => $materialItem->id, 'bom_group_key' => $bomGroupKey, '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_inputted_qty' => 0, 'lot_qty' => 0, 'lot_available_qty' => 0, 'lot_reserved_qty' => 0, 'receipt_date' => null, 'supplier' => null, 'fifo_rank' => $rank++, 'lot_prefix' => $matInfo['lot_prefix'] ?? null, 'part_type' => $matInfo['part_type'] ?? null, 'category' => $matInfo['category'] ?? null, ]; } } return $materials; } /** * 개소별 자재 투입 등록 * * @param bool $replace true면 기존 투입 이력을 삭제(재고 복원) 후 새로 등록 */ public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs, bool $replace = false): 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, $replace) { $stockService = app(StockService::class); $inputResults = []; // replace 모드: 기존 투입 이력 삭제 + 재고 복원 if ($replace) { $existingInputs = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->where('work_order_item_id', $itemId) ->get(); foreach ($existingInputs as $existing) { $stockService->increaseToLot( stockLotId: $existing->stock_lot_id, qty: (float) $existing->qty, reason: 'work_order_input_replace', referenceId: $workOrderId ); $existing->delete(); } } foreach ($inputs as $input) { $stockLotId = $input['stock_lot_id'] ?? null; $qty = (float) ($input['qty'] ?? 0); $bomGroupKey = $input['bom_group_key'] ?? null; if (! $stockLotId || $qty <= 0) { continue; } // 기존 재고 차감 로직 재사용 $result = $stockService->decreaseFromLot( stockLotId: $stockLotId, qty: $qty, reason: 'work_order_input', referenceId: $workOrderId ); // 로트의 품목 ID 조회 (Eager Loading으로 N+1 방지) $lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId); $lotItemId = $lot?->stock?->item_id; // 개소별 매핑 레코드 생성 WorkOrderMaterialInput::create([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrderId, 'work_order_item_id' => $itemId, 'stock_lot_id' => $stockLotId, 'item_id' => $lotItemId ?? 0, 'bom_group_key' => $bomGroupKey, '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]; }); } }