From 12373edf8ce92f7dafb164e9eb73e52a1fccd1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 20:04:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[QMS]=20=EC=A0=90=EA=B2=80=ED=91=9C=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B4=80=EB=A6=AC=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checklist_templates 테이블 마이그레이션 + 기본 시딩 - ChecklistTemplate 모델 (BelongsToTenant, Auditable, SoftDeletes) - ChecklistTemplateService: 조회/저장/파일 업로드/삭제 - SaveChecklistTemplateRequest: 중첩 JSON 검증 - ChecklistTemplateController: 5개 엔드포인트 - 라우트 등록 (quality/checklist-templates, quality/qms-documents) --- .../Api/V1/ChecklistTemplateController.php | 82 +++++++ .../Quality/SaveChecklistTemplateRequest.php | 40 ++++ app/Models/Qualitys/ChecklistTemplate.php | 76 +++++++ app/Services/ChecklistTemplateService.php | 214 ++++++++++++++++++ ...74640_create_checklist_templates_table.php | 110 +++++++++ routes/api/v1/quality.php | 14 ++ 6 files changed, 536 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/ChecklistTemplateController.php create mode 100644 app/Http/Requests/Quality/SaveChecklistTemplateRequest.php create mode 100644 app/Models/Qualitys/ChecklistTemplate.php create mode 100644 app/Services/ChecklistTemplateService.php create mode 100644 database/migrations/2026_03_11_174640_create_checklist_templates_table.php diff --git a/app/Http/Controllers/Api/V1/ChecklistTemplateController.php b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php new file mode 100644 index 0000000..a749df7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php @@ -0,0 +1,82 @@ +query('type', 'day1_audit'); + + return $this->service->getByType($type); + }, __('message.fetched')); + } + + /** + * 템플릿 저장 (전체 덮어쓰기) + */ + public function update(SaveChecklistTemplateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->save($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 항목별 파일 목록 조회 + */ + public function documents(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $templateId = (int) $request->query('template_id'); + $subItemId = $request->query('sub_item_id'); + + return $this->service->getDocuments($templateId, $subItemId); + }, __('message.fetched')); + } + + /** + * 파일 업로드 + */ + public function uploadDocument(Request $request) + { + $request->validate([ + 'template_id' => ['required', 'integer'], + 'sub_item_id' => ['required', 'string', 'max:50'], + 'file' => ['required', 'file', 'max:10240'], // 10MB + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->uploadDocument( + (int) $request->input('template_id'), + $request->input('sub_item_id'), + $request->file('file') + ); + }, __('message.created')); + } + + /** + * 파일 삭제 + */ + public function deleteDocument(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN); + $this->service->deleteDocument($id, $replace); + + return 'success'; + }, __('message.deleted')); + } +} diff --git a/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php b/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php new file mode 100644 index 0000000..a37c696 --- /dev/null +++ b/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php @@ -0,0 +1,40 @@ + ['nullable', 'string', 'max:255'], + 'categories' => ['required', 'array', 'min:1'], + 'categories.*.id' => ['required', 'string', 'max:50'], + 'categories.*.title' => ['required', 'string', 'max:255'], + 'categories.*.subItems' => ['required', 'array'], + 'categories.*.subItems.*.id' => ['required', 'string', 'max:50'], + 'categories.*.subItems.*.name' => ['required', 'string', 'max:255'], + 'options' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'categories.required' => __('validation.required', ['attribute' => '카테고리']), + 'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]), + 'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']), + 'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']), + 'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']), + 'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']), + 'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']), + ]; + } +} diff --git a/app/Models/Qualitys/ChecklistTemplate.php b/app/Models/Qualitys/ChecklistTemplate.php new file mode 100644 index 0000000..3201593 --- /dev/null +++ b/app/Models/Qualitys/ChecklistTemplate.php @@ -0,0 +1,76 @@ + 'array', + 'options' => 'array', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'updated_by'); + } + + /** + * 점검항목별 연결 파일 (files 테이블 polymorphic) + * document_type = 'checklist_template', document_id = this.id + * field_key = sub_item_id (e.g. 'cat-1-1') + */ + public function documents(): MorphMany + { + return $this->morphMany(File::class, 'document', 'document_type', 'document_id'); + } + + /** + * 특정 항목의 파일 조회 + */ + public function documentsForItem(string $subItemId) + { + return $this->documents()->where('field_key', $subItemId); + } + + /** + * categories JSON에서 모든 sub_item_id 추출 + */ + public function getAllSubItemIds(): array + { + $ids = []; + foreach ($this->categories ?? [] as $category) { + foreach ($category['subItems'] ?? [] as $subItem) { + $ids[] = $subItem['id']; + } + } + + return $ids; + } +} diff --git a/app/Services/ChecklistTemplateService.php b/app/Services/ChecklistTemplateService.php new file mode 100644 index 0000000..a0ca96e --- /dev/null +++ b/app/Services/ChecklistTemplateService.php @@ -0,0 +1,214 @@ +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( + self::AUDIT_TARGET, + $template->id, + 'updated', + $before, + $template->toArray(), + $this->apiUserId() + ); + + return $this->getByType($template->type); + } + + /** + * 항목별 파일 목록 조회 + */ + 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('tenant')->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; + } +} diff --git a/database/migrations/2026_03_11_174640_create_checklist_templates_table.php b/database/migrations/2026_03_11_174640_create_checklist_templates_table.php new file mode 100644 index 0000000..881f58d --- /dev/null +++ b/database/migrations/2026_03_11_174640_create_checklist_templates_table.php @@ -0,0 +1,110 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->string('name', 255)->default('품질인정심사 점검표')->comment('템플릿명'); + $table->string('type', 50)->default('day1_audit')->comment('심사유형: day1_audit, day2_lot 등'); + $table->json('categories')->comment('카테고리/항목 JSON'); + $table->json('options')->nullable()->comment('확장 속성'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'type'], 'uq_checklist_templates_tenant_type'); + $table->index(['tenant_id', 'type'], 'idx_checklist_templates_tenant_type'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->foreign('created_by')->references('id')->on('users')->onDelete('set null'); + $table->foreign('updated_by')->references('id')->on('users')->onDelete('set null'); + }); + + // 기존 테넌트에 기본 템플릿 시딩 + $this->seedDefaultTemplates(); + } + + public function down(): void + { + Schema::dropIfExists('checklist_templates'); + } + + private function seedDefaultTemplates(): void + { + $defaultCategories = json_encode([ + [ + 'id' => 'cat-1', + 'title' => '원재료 품질관리 기준', + 'subItems' => [ + ['id' => 'cat-1-1', 'name' => '수입검사 기준 확인'], + ['id' => 'cat-1-2', 'name' => '불합격품 처리 기준 확인'], + ['id' => 'cat-1-3', 'name' => '자재 보관 기준 확인'], + ], + ], + [ + 'id' => 'cat-2', + 'title' => '제조공정 관리 기준', + 'subItems' => [ + ['id' => 'cat-2-1', 'name' => '작업표준서 확인'], + ['id' => 'cat-2-2', 'name' => '공정검사 기준 확인'], + ['id' => 'cat-2-3', 'name' => '부적합품 처리 기준 확인'], + ], + ], + [ + 'id' => 'cat-3', + 'title' => '제품 품질관리 기준', + 'subItems' => [ + ['id' => 'cat-3-1', 'name' => '제품검사 기준 확인'], + ['id' => 'cat-3-2', 'name' => '출하검사 기준 확인'], + ['id' => 'cat-3-3', 'name' => '클레임 처리 기준 확인'], + ], + ], + [ + 'id' => 'cat-4', + 'title' => '제조설비 관리', + 'subItems' => [ + ['id' => 'cat-4-1', 'name' => '설비관리 기준 확인'], + ['id' => 'cat-4-2', 'name' => '설비점검 이력 확인'], + ], + ], + [ + 'id' => 'cat-5', + 'title' => '검사설비 관리', + 'subItems' => [ + ['id' => 'cat-5-1', 'name' => '검사설비 관리 기준 확인'], + ['id' => 'cat-5-2', 'name' => '교정 이력 확인'], + ], + ], + [ + 'id' => 'cat-6', + 'title' => '문서 및 인증 관리', + 'subItems' => [ + ['id' => 'cat-6-1', 'name' => '문서관리 기준 확인'], + ['id' => 'cat-6-2', 'name' => 'KS/인증 관리 현황 확인'], + ], + ], + ], JSON_UNESCAPED_UNICODE); + + $tenantIds = DB::table('tenants')->pluck('id'); + $now = now(); + + foreach ($tenantIds as $tenantId) { + DB::table('checklist_templates')->insert([ + 'tenant_id' => $tenantId, + 'name' => '품질인정심사 점검표', + 'type' => 'day1_audit', + 'categories' => $defaultCategories, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +}; diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index 5edb7ca..b266e15 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -8,6 +8,7 @@ */ use App\Http\Controllers\Api\V1\AuditChecklistController; +use App\Http\Controllers\Api\V1\ChecklistTemplateController; use App\Http\Controllers\Api\V1\PerformanceReportController; use App\Http\Controllers\Api\V1\QmsLotAuditController; use App\Http\Controllers\Api\V1\QualityDocumentController; @@ -50,6 +51,19 @@ Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); }); +// QMS 점검표 템플릿 관리 +Route::prefix('quality/checklist-templates')->group(function () { + Route::get('', [ChecklistTemplateController::class, 'show'])->name('v1.quality.checklist-templates.show'); + Route::put('/{id}', [ChecklistTemplateController::class, 'update'])->whereNumber('id')->name('v1.quality.checklist-templates.update'); +}); + +// QMS 점검표 문서 (파일) 관리 +Route::prefix('quality/qms-documents')->group(function () { + Route::get('', [ChecklistTemplateController::class, 'documents'])->name('v1.quality.qms-documents.index'); + Route::post('', [ChecklistTemplateController::class, 'uploadDocument'])->name('v1.quality.qms-documents.store'); + Route::delete('/{id}', [ChecklistTemplateController::class, 'deleteDocument'])->whereNumber('id')->name('v1.quality.qms-documents.destroy'); +}); + // QMS 기준/매뉴얼 심사 (1일차) Route::prefix('qms')->group(function () { Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index');