tenantId(); $perPage = $params['per_page'] ?? 20; $page = $params['page'] ?? 1; $query = NonconformingReport::query() ->where('tenant_id', $tenantId) ->with(['creator:id,name', 'item:id,name', 'order:id,order_number']); // 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } if (! empty($params['nc_type'])) { $query->where('nc_type', $params['nc_type']); } if (! empty($params['from_date'])) { $query->where('occurred_at', '>=', $params['from_date']); } if (! empty($params['to_date'])) { $query->where('occurred_at', '<=', $params['to_date']); } // 검색 if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('nc_number', 'like', "%{$search}%") ->orWhere('site_name', 'like', "%{$search}%") ->orWhereHas('item', function ($q2) use ($search) { $q2->where('name', 'like', "%{$search}%"); }); }); } $query->orderByDesc('occurred_at')->orderByDesc('id'); return $query->paginate($perPage, ['*'], 'page', $page); } /** * 단건 조회 */ public function show(int $id): NonconformingReport { return NonconformingReport::query() ->where('tenant_id', $this->tenantId()) ->with([ 'items', 'order:id,order_number,site_name', 'item:id,name', 'department:id,name', 'creator:id,name', 'actionManager:id,name', 'relatedEmployee:id,name', 'files', ]) ->findOrFail($id); } /** * 등록 (items 일괄 저장) */ public function store(array $data): NonconformingReport { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 채번 $ncNumber = $this->generateNcNumber($tenantId); // 비용 계산 $items = $data['items'] ?? []; $materialCost = $data['material_cost'] ?? $this->sumItemAmounts($items); $shippingCost = $data['shipping_cost'] ?? 0; $constructionCost = $data['construction_cost'] ?? 0; $otherCost = $data['other_cost'] ?? 0; $totalCost = $materialCost + $shippingCost + $constructionCost + $otherCost; $report = NonconformingReport::create([ 'tenant_id' => $tenantId, 'nc_number' => $ncNumber, 'status' => NonconformingReport::STATUS_RECEIVED, 'nc_type' => $data['nc_type'], 'occurred_at' => $data['occurred_at'], 'confirmed_at' => $data['confirmed_at'] ?? null, 'site_name' => $data['site_name'] ?? null, 'department_id' => $data['department_id'] ?? null, 'order_id' => $data['order_id'] ?? null, 'item_id' => $data['item_id'] ?? null, 'defect_quantity' => $data['defect_quantity'] ?? null, 'unit' => $data['unit'] ?? null, 'defect_description' => $data['defect_description'] ?? null, 'cause_analysis' => $data['cause_analysis'] ?? null, 'corrective_action' => $data['corrective_action'] ?? null, 'action_completed_at' => $data['action_completed_at'] ?? null, 'action_manager_id' => $data['action_manager_id'] ?? null, 'related_employee_id' => $data['related_employee_id'] ?? null, 'material_cost' => $materialCost, 'shipping_cost' => $shippingCost, 'construction_cost' => $constructionCost, 'other_cost' => $otherCost, 'total_cost' => $totalCost, 'remarks' => $data['remarks'] ?? null, 'drawing_location' => $data['drawing_location'] ?? null, 'created_by' => $userId, ]); // 자재 상세 내역 저장 $this->syncItems($report, $items, $tenantId); return $this->show($report->id); }); } /** * 수정 (items sync) */ public function update(int $id, array $data): NonconformingReport { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $data, $tenantId, $userId) { $report = NonconformingReport::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if ($report->isClosed()) { abort(403, __('error.nonconforming.closed_cannot_edit')); } // items가 전달되면 자재비 재계산 $hasItems = array_key_exists('items', $data); if ($hasItems) { $this->syncItems($report, $data['items'] ?? [], $tenantId); $data['material_cost'] = (int) $report->items()->sum('amount'); } // 비용 합계 재계산 $materialCost = $data['material_cost'] ?? $report->material_cost; $shippingCost = $data['shipping_cost'] ?? $report->shipping_cost; $constructionCost = $data['construction_cost'] ?? $report->construction_cost; $otherCost = $data['other_cost'] ?? $report->other_cost; $data['total_cost'] = $materialCost + $shippingCost + $constructionCost + $otherCost; $data['updated_by'] = $userId; // items 키는 모델 필드가 아니므로 제거 unset($data['items']); $report->update($data); return $this->show($report->id); }); } /** * 삭제 (소프트) */ public function destroy(int $id): void { $report = NonconformingReport::query() ->where('tenant_id', $this->tenantId()) ->findOrFail($id); if ($report->isClosed()) { abort(403, __('error.nonconforming.closed_cannot_delete')); } $report->deleted_by = $this->apiUserId(); $report->save(); $report->delete(); } /** * 상태 변경 */ public function changeStatus(int $id, string $newStatus): NonconformingReport { $report = NonconformingReport::query() ->where('tenant_id', $this->tenantId()) ->findOrFail($id); $this->validateStatusTransition($report, $newStatus); $report->status = $newStatus; $report->updated_by = $this->apiUserId(); $report->save(); return $this->show($report->id); } /** * 상태별 통계 */ public function stats(array $params): array { $tenantId = $this->tenantId(); $statusCounts = NonconformingReport::query() ->where('tenant_id', $tenantId) ->selectRaw('status, COUNT(*) as count') ->groupBy('status') ->pluck('count', 'status') ->toArray(); $totalCost = NonconformingReport::query() ->where('tenant_id', $tenantId) ->sum('total_cost'); return [ 'by_status' => [ 'RECEIVED' => $statusCounts['RECEIVED'] ?? 0, 'ANALYZING' => $statusCounts['ANALYZING'] ?? 0, 'RESOLVED' => $statusCounts['RESOLVED'] ?? 0, 'CLOSED' => $statusCounts['CLOSED'] ?? 0, ], 'total_count' => array_sum($statusCounts), 'total_cost' => (int) $totalCost, ]; } // ── private ── /** * 채번: NC-YYYYMMDD-NNN */ private function generateNcNumber(int $tenantId): string { $prefix = 'NC'; $date = now()->format('Ymd'); $pattern = "{$prefix}-{$date}-"; $lastNumber = NonconformingReport::withTrashed() ->where('tenant_id', $tenantId) ->where('nc_number', 'like', "{$pattern}%") ->orderByDesc('nc_number') ->value('nc_number'); $seq = $lastNumber ? ((int) substr($lastNumber, -3) + 1) : 1; return sprintf('%s-%s-%03d', $prefix, $date, $seq); } /** * 자재 상세 내역 동기화 (삭제 후 재생성) */ private function syncItems(NonconformingReport $report, array $items, int $tenantId): void { $report->items()->delete(); foreach ($items as $index => $itemData) { $quantity = $itemData['quantity'] ?? 0; $unitPrice = $itemData['unit_price'] ?? 0; NonconformingReportItem::create([ 'tenant_id' => $tenantId, 'nonconforming_report_id' => $report->id, 'item_id' => $itemData['item_id'] ?? null, 'item_name' => $itemData['item_name'], 'specification' => $itemData['specification'] ?? null, 'quantity' => $quantity, 'unit_price' => $unitPrice, 'amount' => (int) ($quantity * $unitPrice), 'sort_order' => $index, 'remarks' => $itemData['remarks'] ?? null, ]); } } /** * items 배열에서 금액 합계 계산 */ private function sumItemAmounts(array $items): int { $total = 0; foreach ($items as $item) { $qty = $item['quantity'] ?? 0; $price = $item['unit_price'] ?? 0; $total += (int) ($qty * $price); } return $total; } /** * 상태 전이 검증 */ private function validateStatusTransition(NonconformingReport $report, string $newStatus): void { $allowed = [ NonconformingReport::STATUS_RECEIVED => [NonconformingReport::STATUS_ANALYZING], NonconformingReport::STATUS_ANALYZING => [NonconformingReport::STATUS_RESOLVED], NonconformingReport::STATUS_RESOLVED => [NonconformingReport::STATUS_CLOSED], NonconformingReport::STATUS_CLOSED => [], ]; $current = $report->status; if (! in_array($newStatus, $allowed[$current] ?? [])) { abort(422, __('error.nonconforming.invalid_status_transition', [ 'from' => NonconformingReport::STATUSES[$current] ?? $current, 'to' => NonconformingReport::STATUSES[$newStatus] ?? $newStatus, ])); } // ANALYZING → RESOLVED: 원인분석 + 시정조치 필수 if ($newStatus === NonconformingReport::STATUS_RESOLVED) { if (empty($report->cause_analysis) || empty($report->corrective_action)) { abort(422, __('error.nonconforming.analysis_required')); } } } }