where('type', $type) ->first(); if (! $template) { throw new NotFoundHttpException(__('error.not_found')); } // 각 항목별 파일 수 포함 $fileCounts = File::query() ->where('document_type', self::DOCUMENT_TYPE) ->where('document_id', $template->id) ->whereNull('deleted_at') ->selectRaw('field_key, COUNT(*) as count') ->groupBy('field_key') ->pluck('count', 'field_key') ->toArray(); return [ 'id' => $template->id, 'name' => $template->name, 'type' => $template->type, 'categories' => $template->categories, 'options' => $template->options, 'file_counts' => $fileCounts, 'updated_at' => $template->updated_at?->toIso8601String(), 'updated_by' => $template->updater?->name, ]; } /** * 템플릿 저장 (전체 덮어쓰기) */ public function save(int $id, array $data): array { $template = ChecklistTemplate::findOrFail($id); $before = $template->toArray(); // 삭제된 항목의 파일 처리 $oldSubItemIds = $template->getAllSubItemIds(); $newSubItemIds = $this->extractSubItemIds($data['categories']); $removedIds = array_diff($oldSubItemIds, $newSubItemIds); DB::transaction(function () use ($template, $data, $removedIds) { // 템플릿 업데이트 $template->update([ 'name' => $data['name'] ?? $template->name, 'categories' => $data['categories'], 'options' => $data['options'] ?? $template->options, 'updated_by' => $this->apiUserId(), ]); // 삭제된 항목의 파일 → soft delete if (! empty($removedIds)) { $orphanFiles = File::query() ->where('document_type', self::DOCUMENT_TYPE) ->where('document_id', $template->id) ->whereIn('field_key', $removedIds) ->get(); foreach ($orphanFiles as $file) { $file->softDeleteFile($this->apiUserId()); } } }); $template->refresh(); $this->auditLogger->log( $this->tenantId(), self::AUDIT_TARGET, $template->id, 'updated', $before, $template->toArray() ); return $this->getByType($template->type); } /** * 항목 완료 토글 */ public function toggleItem(int $id, string $subItemId): array { $template = ChecklistTemplate::findOrFail($id); $categories = $template->categories; $toggled = null; foreach ($categories as &$category) { if (empty($category['subItems'])) { continue; } foreach ($category['subItems'] as &$subItem) { if ($subItem['id'] === $subItemId) { $subItem['is_completed'] = ! ($subItem['is_completed'] ?? false); $subItem['completed_at'] = $subItem['is_completed'] ? now()->toIso8601String() : null; $toggled = $subItem; break 2; } } unset($subItem); } unset($category); if (! $toggled) { throw new NotFoundHttpException(__('error.not_found')); } $template->update([ 'categories' => $categories, 'updated_by' => $this->apiUserId(), ]); return [ 'id' => $toggled['id'], 'name' => $toggled['name'], 'is_completed' => $toggled['is_completed'], 'completed_at' => $toggled['completed_at'], ]; } /** * 항목별 파일 목록 조회 */ public function getDocuments(int $templateId, ?string $subItemId = null): array { $query = File::query() ->where('document_type', self::DOCUMENT_TYPE) ->where('document_id', $templateId) ->with('uploader:id,name'); if ($subItemId) { $query->where('field_key', $subItemId); } $files = $query->orderBy('field_key')->orderByDesc('id')->get(); return $files->map(fn (File $file) => [ 'id' => $file->id, 'field_key' => $file->field_key, 'display_name' => $file->display_name ?? $file->original_name, 'file_size' => $file->file_size, 'mime_type' => $file->mime_type, 'uploaded_by' => $file->uploader?->name, 'created_at' => $file->created_at?->toIso8601String(), ])->toArray(); } /** * 파일 업로드 (polymorphic) */ public function uploadDocument(int $templateId, string $subItemId, $uploadedFile): array { $template = ChecklistTemplate::findOrFail($templateId); $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 저장 경로: {tenant_id}/checklist-templates/{year}/{month}/{stored_name} $date = now(); $storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension(); $filePath = sprintf( '%d/checklist-templates/%s/%s/%s', $tenantId, $date->format('Y'), $date->format('m'), $storedName ); // 파일 저장 Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname())); // DB 레코드 생성 $file = File::create([ 'tenant_id' => $tenantId, 'document_type' => self::DOCUMENT_TYPE, 'document_id' => $template->id, 'field_key' => $subItemId, 'display_name' => $uploadedFile->getClientOriginalName(), 'stored_name' => $storedName, 'file_path' => $filePath, 'file_size' => $uploadedFile->getSize(), 'mime_type' => $uploadedFile->getClientMimeType(), 'uploaded_by' => $userId, 'created_by' => $userId, ]); return [ 'id' => $file->id, 'field_key' => $file->field_key, 'display_name' => $file->display_name, 'file_size' => $file->file_size, 'mime_type' => $file->mime_type, 'created_at' => $file->created_at?->toIso8601String(), ]; } /** * 파일 삭제 * - 교체(replace=true): hard delete (물리 파일 + DB) * - 일반 삭제: soft delete (휴지통) */ public function deleteDocument(int $fileId, bool $replace = false): void { $file = File::query() ->where('document_type', self::DOCUMENT_TYPE) ->findOrFail($fileId); if ($replace) { $file->permanentDelete(); } else { $file->softDeleteFile($this->apiUserId()); } } /** * categories JSON에서 sub_item_id 목록 추출 */ private function extractSubItemIds(array $categories): array { $ids = []; foreach ($categories as $category) { foreach ($category['subItems'] ?? [] as $subItem) { $ids[] = $subItem['id']; } } return $ids; } }