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', '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']); $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')->find($salesOrderId); if ($salesOrder && $salesOrder->items->isNotEmpty()) { foreach ($salesOrder->items as $index => $orderItem) { // 수주 품목 + 노드에서 options 조합 $nodeOptions = $orderItem->node?->options ?? []; $options = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, '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) { $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); } return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); }); } /** * 작업지시 완료 시 자동 출하 생성 * * 작업지시가 완료(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; } $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] ); } /** * 작업지시 품목에 결과 데이터 저장 */ 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(); } } /** * 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')); } // Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산) $uniqueMaterials = []; foreach ($workOrder->items as $woItem) { $materialItems = []; // BOM이 있으면 자식 품목들을 자재로 사용 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면 required_qty 합산) foreach ($materialItems as $matInfo) { $itemId = $matInfo['item']->id; if (isset($uniqueMaterials[$itemId])) { $uniqueMaterials[$itemId]['required_qty'] += $matInfo['required_qty']; } else { $uniqueMaterials[$itemId] = $matInfo; } } } // Phase 2: 유니크 자재별로 StockLot 조회 $materials = []; $rank = 1; foreach ($uniqueMaterials as $matInfo) { $materialItem = $matInfo['item']; $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) ->where('item_id', $materialItem->id) ->first(); $lotsFound = false; if ($stock) { $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) ->where('stock_id', $stock->id) ->where('status', 'available') ->where('available_qty', '>', 0) ->orderBy('fifo_order', 'asc') ->get(); foreach ($lots as $lot) { $lotsFound = true; $materials[] = [ 'stock_lot_id' => $lot->id, 'item_id' => $materialItem->id, 'lot_no' => $lot->lot_no, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, 'specification' => $materialItem->specification, 'unit' => $lot->unit ?? $materialItem->unit ?? 'EA', 'bom_qty' => $matInfo['bom_qty'], 'required_qty' => $matInfo['required_qty'], 'lot_qty' => (float) $lot->qty, 'lot_available_qty' => (float) $lot->available_qty, 'lot_reserved_qty' => (float) $lot->reserved_qty, 'receipt_date' => $lot->receipt_date, 'supplier' => $lot->supplier, 'fifo_rank' => $rank++, ]; } } // 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시) if (! $lotsFound) { $materials[] = [ 'stock_lot_id' => null, 'item_id' => $materialItem->id, 'lot_no' => null, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, 'specification' => $materialItem->specification, 'unit' => $materialItem->unit ?? 'EA', 'bom_qty' => $matInfo['bom_qty'], 'required_qty' => $matInfo['required_qty'], 'lot_qty' => 0, 'lot_available_qty' => 0, 'lot_reserved_qty' => 0, 'receipt_date' => null, 'supplier' => null, 'fifo_rank' => $rank++, ]; } } 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')); } return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) { $stockService = app(StockService::class); $inputResults = []; foreach ($inputs as $input) { $stockLotId = $input['stock_lot_id'] ?? null; $qty = (float) ($input['qty'] ?? 0); if (! $stockLotId || $qty <= 0) { continue; } // 특정 로트에서 재고 차감 $result = $stockService->decreaseFromLot( stockLotId: $stockLotId, qty: $qty, reason: 'work_order_input', referenceId: $workOrderId ); $inputResults[] = [ 'stock_lot_id' => $stockLotId, 'qty' => $qty, 'status' => 'success', 'deducted_lot' => $result, ]; } // 자재 투입 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'material_input', null, [ 'inputs' => $inputs, 'input_results' => $inputResults, 'input_by' => $userId, 'input_at' => now()->toDateTimeString(), ] ); return [ 'work_order_id' => $workOrderId, 'material_count' => count($inputResults), 'input_results' => $inputResults, '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']); // LOT 번호별 그룹핑 (동일 LOT에서 여러번 투입 가능) $lotMap = []; foreach ($transactions as $tx) { $lotNo = $tx->lot_no; if (! isset($lotMap[$lotNo])) { $lotMap[$lotNo] = [ 'lot_no' => $lotNo, 'item_code' => $tx->item_code, 'item_name' => $tx->item_name, 'total_qty' => 0, 'input_count' => 0, 'first_input_at' => $tx->created_at, ]; } $lotMap[$lotNo]['total_qty'] += abs((float) $tx->qty); $lotMap[$lotNo]['input_count']++; } return array_values($lotMap); } // ────────────────────────────────────────────────────────────── // 중간검사 관련 // ────────────────────────────────────────────────────────────── /** * 품목별 중간검사 데이터 저장 */ 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(); // 감사 로그 $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), ]; } // ────────────────────────────────────────────────────────────── // 검사 문서 템플릿 연동 // ────────────────────────────────────────────────────────────── /** * 작업지시의 검사용 문서 템플릿 조회 * * 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(); return [ 'work_order_id' => $workOrderId, 'template_id' => $templateId, 'template' => $formattedTemplate, 'existing_document' => $existingDocument, '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')); } $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(); $document = $documentService->update($existingDocument->id, [ 'title' => $inspectionData['title'] ?? $existingDocument->title, 'data' => array_merge($existingBasicFields, $documentDataRecords), ]); $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'] ?? [], ]; $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); } // 레거시 형식: templateValues/values 기반 → 정규화 변환 return $this->normalizeOldFormatRecords($rawItems, $templateId); } /** * 레거시 형식(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(['order', 'items' => function ($q) { $q->ordered(); }]) ->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $items = $workOrder->items->map(function ($item) { return [ 'id' => $item->id, 'item_name' => $item->item_name, 'specification' => $item->specification, 'quantity' => $item->quantity, 'sort_order' => $item->sort_order, 'status' => $item->status, 'options' => $item->options, 'inspection_data' => $item->getInspectionData(), ]; }); 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->order ? [ 'id' => $workOrder->order->id, 'order_no' => $workOrder->order->order_no, 'client_name' => $workOrder->order->client_name ?? null, 'site_name' => $workOrder->order->site_name ?? null, 'order_date' => $workOrder->order->order_date ?? null, ] : null, '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) { $document = $documentService->update($existingDocument->id, [ 'title' => $workLogData['title'] ?? $existingDocument->title, 'data' => $documentDataRecords, ]); $action = 'work_log_updated'; } else { $document = $documentService->create([ 'template_id' => $templateId, 'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}", 'linkable_type' => 'work_order', 'linkable_id' => $workOrderId, 'data' => $documentDataRecords, 'approvers' => $workLogData['approvers'] ?? [], ]); $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 = []; if ($woItem->item_id) { $item = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($woItem->item_id); if ($item && ! empty($item->bom)) { foreach ($item->bom as $bomItem) { $childItemId = $bomItem['child_item_id'] ?? null; $bomQty = (float) ($bomItem['qty'] ?? 1); if (! $childItemId) { continue; } $childItem = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($childItemId); if (! $childItem) { continue; } $materialItems[] = [ 'item' => $childItem, 'bom_qty' => $bomQty, 'required_qty' => $bomQty * ($woItem->quantity ?? 1), ]; } } } // BOM이 없으면 품목 자체를 자재로 사용 if (empty($materialItems) && $woItem->item_id && $woItem->item) { $materialItems[] = [ 'item' => $woItem->item, 'bom_qty' => 1, 'required_qty' => $woItem->quantity ?? 1, ]; } // 이미 투입된 수량 조회 (item_id별 SUM) $inputtedQties = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->where('work_order_item_id', $itemId) ->selectRaw('item_id, SUM(qty) as total_qty') ->groupBy('item_id') ->pluck('total_qty', 'item_id'); // 자재별 LOT 조회 $materials = []; $rank = 1; foreach ($materialItems as $matInfo) { $materialItem = $matInfo['item']; $alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0); $remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted); $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) ->where('item_id', $materialItem->id) ->first(); $lotsFound = false; if ($stock) { $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) ->where('stock_id', $stock->id) ->where('status', 'available') ->where('available_qty', '>', 0) ->orderBy('fifo_order', 'asc') ->get(); foreach ($lots as $lot) { $lotsFound = true; $materials[] = [ 'stock_lot_id' => $lot->id, 'item_id' => $materialItem->id, 'lot_no' => $lot->lot_no, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, 'specification' => $materialItem->specification, 'unit' => $lot->unit ?? $materialItem->unit ?? 'EA', 'bom_qty' => $matInfo['bom_qty'], 'required_qty' => $matInfo['required_qty'], 'already_inputted' => $alreadyInputted, 'remaining_required_qty' => $remainingRequired, 'lot_qty' => (float) $lot->qty, 'lot_available_qty' => (float) $lot->available_qty, 'lot_reserved_qty' => (float) $lot->reserved_qty, 'receipt_date' => $lot->receipt_date, 'supplier' => $lot->supplier, 'fifo_rank' => $rank++, ]; } } if (! $lotsFound) { $materials[] = [ 'stock_lot_id' => null, 'item_id' => $materialItem->id, 'lot_no' => null, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, 'specification' => $materialItem->specification, 'unit' => $materialItem->unit ?? 'EA', 'bom_qty' => $matInfo['bom_qty'], 'required_qty' => $matInfo['required_qty'], 'already_inputted' => $alreadyInputted, 'remaining_required_qty' => $remainingRequired, 'lot_qty' => 0, 'lot_available_qty' => 0, 'lot_reserved_qty' => 0, 'receipt_date' => null, 'supplier' => null, 'fifo_rank' => $rank++, ]; } } return $materials; } /** * 개소별 자재 투입 등록 */ public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $woItem = WorkOrderItem::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->find($itemId); if (! $woItem) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) { $stockService = app(StockService::class); $inputResults = []; foreach ($inputs as $input) { $stockLotId = $input['stock_lot_id'] ?? null; $qty = (float) ($input['qty'] ?? 0); if (! $stockLotId || $qty <= 0) { continue; } // 기존 재고 차감 로직 재사용 $result = $stockService->decreaseFromLot( stockLotId: $stockLotId, qty: $qty, reason: 'work_order_input', referenceId: $workOrderId ); // 로트의 품목 ID 조회 $lot = \App\Models\Tenants\StockLot::find($stockLotId); $lotItemId = $lot ? ($lot->stock->item_id ?? null) : null; // 개소별 매핑 레코드 생성 WorkOrderMaterialInput::create([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrderId, 'work_order_item_id' => $itemId, 'stock_lot_id' => $stockLotId, 'item_id' => $lotItemId ?? 0, 'qty' => $qty, 'input_by' => $userId, 'input_at' => now(), ]); $inputResults[] = [ 'stock_lot_id' => $stockLotId, 'qty' => $qty, 'status' => 'success', 'deducted_lot' => $result, ]; } // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'material_input_for_item', null, [ 'work_order_item_id' => $itemId, 'inputs' => $inputs, 'input_results' => $inputResults, 'input_by' => $userId, 'input_at' => now()->toDateTimeString(), ] ); return [ 'work_order_id' => $workOrderId, 'work_order_item_id' => $itemId, 'material_count' => count($inputResults), 'input_results' => $inputResults, 'input_at' => now()->toDateTimeString(), ]; }); } /** * 개소별 자재 투입 이력 조회 */ public function getMaterialInputsForItem(int $workOrderId, int $itemId): array { $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } $woItem = WorkOrderItem::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->find($itemId); if (! $woItem) { throw new NotFoundHttpException(__('error.not_found')); } $inputs = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->where('work_order_item_id', $itemId) ->with(['stockLot', 'item', 'inputBy']) ->orderBy('input_at', 'desc') ->get(); return $inputs->map(function ($input) { return [ 'id' => $input->id, 'stock_lot_id' => $input->stock_lot_id, 'lot_no' => $input->stockLot?->lot_no, 'item_id' => $input->item_id, 'material_code' => $input->item?->code, 'material_name' => $input->item?->name, 'qty' => (float) $input->qty, 'unit' => $input->item?->unit ?? 'EA', 'input_by' => $input->input_by, 'input_by_name' => $input->inputBy?->name, 'input_at' => $input->input_at?->toDateTimeString(), ]; })->toArray(); } /** * 개소별 자재 투입 삭제 (재고 복원) */ public function deleteMaterialInput(int $workOrderId, int $inputId): void { $tenantId = $this->tenantId(); $input = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->find($inputId); if (! $input) { throw new NotFoundHttpException(__('error.not_found')); } DB::transaction(function () use ($input, $tenantId, $workOrderId) { // 재고 복원 $stockService = app(StockService::class); $stockService->increaseToLot( stockLotId: $input->stock_lot_id, qty: (float) $input->qty, reason: 'work_order_input_cancel', referenceId: $workOrderId ); // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'material_input_deleted', [ 'input_id' => $input->id, 'stock_lot_id' => $input->stock_lot_id, 'qty' => (float) $input->qty, 'work_order_item_id' => $input->work_order_item_id, ], null ); $input->delete(); }); } /** * 개소별 자재 투입 수량 수정 (재고 차이 반영) */ public function updateMaterialInput(int $workOrderId, int $inputId, float $newQty): array { $tenantId = $this->tenantId(); $input = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId) ->find($inputId); if (! $input) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($input, $newQty, $tenantId, $workOrderId) { $oldQty = (float) $input->qty; $diff = $newQty - $oldQty; if (abs($diff) < 0.001) { return ['id' => $input->id, 'qty' => $oldQty, 'changed' => false]; } $stockService = app(StockService::class); if ($diff > 0) { // 수량 증가 → 추가 차감 $stockService->decreaseFromLot( stockLotId: $input->stock_lot_id, qty: $diff, reason: 'work_order_input', referenceId: $workOrderId ); } else { // 수량 감소 → 차이만큼 복원 $stockService->increaseToLot( stockLotId: $input->stock_lot_id, qty: abs($diff), reason: 'work_order_input_adjust', referenceId: $workOrderId ); } // 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'material_input_updated', ['input_id' => $input->id, 'qty' => $oldQty], ['input_id' => $input->id, 'qty' => $newQty] ); $input->qty = $newQty; $input->save(); return ['id' => $input->id, 'qty' => $newQty, 'changed' => true]; }); } }