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']; $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:id,order_no,client_id,client_name', 'salesOrder.client:id,name', 'process:id,process_name,process_code', ]); // 검색어 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) if ($processId !== null) { $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 ($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(); return [ 'total' => array_sum($counts), '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, ]; } /** * 단건 조회 */ 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:id,order_no,site_name,client_id', 'salesOrder.client:id,name', 'process:id,process_name,process_code,work_steps', 'items', 'bendingDetail', 'issues' => fn ($q) => $q->orderByDesc('created_at'), ]) ->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')->find($salesOrderId); if ($salesOrder && $salesOrder->items->isNotEmpty()) { foreach ($salesOrder->items as $index => $orderItem) { $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, ]); } } } 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 자재 목록과 실제 재고 정보를 반환합니다. * 품목의 BOM 정보를 기반으로 필요 자재를 추출하고, 각 자재의 실제 재고를 조회합니다. * * @param int $workOrderId 작업지시 ID * @return array 자재 목록 (item_id, material_code, material_name, unit, required_qty, current_stock, available_qty, fifo_rank) */ 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')); } $materials = []; $rank = 1; $stockService = app(StockService::class); // 작업지시 품목들의 BOM에서 자재 추출 foreach ($workOrder->items as $woItem) { // item_id가 있으면 해당 Item의 BOM 조회 if ($woItem->item_id) { $item = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($woItem->item_id); if ($item && ! empty($item->bom)) { // 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; } // 필요 수량 계산 (BOM 수량 × 작업지시 수량) $requiredQty = $bomQty * ($woItem->quantity ?? 1); // 실제 재고 조회 $stockInfo = $stockService->getAvailableStock($childItemId); $materials[] = [ 'item_id' => $childItemId, 'work_order_item_id' => $woItem->id, 'material_code' => $childItem->code, 'material_name' => $childItem->name, 'specification' => $childItem->specification, 'unit' => $childItem->unit ?? 'EA', 'bom_qty' => $bomQty, 'required_qty' => $requiredQty, 'current_stock' => $stockInfo['stock_qty'] ?? 0, 'available_qty' => $stockInfo['available_qty'] ?? 0, 'reserved_qty' => $stockInfo['reserved_qty'] ?? 0, 'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= $requiredQty, 'fifo_rank' => $rank++, ]; } } } // BOM이 없는 경우, 품목 자체를 자재로 취급 (Fallback) if (empty($materials) && $woItem->item_id) { $stockInfo = $stockService->getAvailableStock($woItem->item_id); $materials[] = [ 'item_id' => $woItem->item_id, 'work_order_item_id' => $woItem->id, 'material_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, 'material_name' => $woItem->item_name, 'specification' => $woItem->specification, 'unit' => $woItem->unit ?? 'EA', 'bom_qty' => 1, 'required_qty' => $woItem->quantity ?? 1, 'current_stock' => $stockInfo['stock_qty'] ?? 0, 'available_qty' => $stockInfo['available_qty'] ?? 0, 'reserved_qty' => $stockInfo['reserved_qty'] ?? 0, 'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= ($woItem->quantity ?? 1), 'fifo_rank' => $rank++, ]; } } return $materials; } /** * 자재 투입 등록 (재고 차감 포함) * * 작업지시에 자재 투입을 등록하고 재고를 차감합니다. * FIFO 기반으로 가장 오래된 LOT부터 차감합니다. * * @param int $workOrderId 작업지시 ID * @param array $materials 투입할 자재 목록 [['item_id' => int, 'qty' => float], ...] * @return array 투입 결과 * * @throws \Exception 재고 부족 시 */ public function registerMaterialInput(int $workOrderId, array $materials): 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 ($materials, $tenantId, $userId, $workOrderId) { $stockService = app(StockService::class); $inputResults = []; foreach ($materials as $material) { $itemId = $material['item_id'] ?? null; $qty = (float) ($material['qty'] ?? 0); if (! $itemId || $qty <= 0) { continue; } // FIFO 기반 재고 차감 try { $deductedLots = $stockService->decreaseFIFO( itemId: $itemId, qty: $qty, reason: 'work_order_input', referenceId: $workOrderId ); $inputResults[] = [ 'item_id' => $itemId, 'qty' => $qty, 'status' => 'success', 'deducted_lots' => $deductedLots, ]; } catch (\Exception $e) { // 재고 부족 등의 오류는 전체 트랜잭션 롤백 throw $e; } } // 자재 투입 감사 로그 $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $workOrderId, 'material_input', null, [ 'materials' => $materials, '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; } }