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')); } // 상태 전이 규칙 검증 if (! $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: $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] ); return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); }); } /** * 작업지시 품목에 결과 데이터 저장 */ 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, ]; } /** * 품목 상태 기반으로 작업지시 상태 자동 동기화 * * 규칙: * - 품목 중 하나라도 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] ); return true; } return false; } }