diff --git a/app/Http/Controllers/Api/V1/AuditChecklistController.php b/app/Http/Controllers/Api/V1/AuditChecklistController.php new file mode 100644 index 0000000..4fb26d7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AuditChecklistController.php @@ -0,0 +1,112 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + /** + * 점검표 생성 (카테고리+항목 일괄) + */ + public function store(AuditChecklistStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + /** + * 점검표 상세 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 점검표 수정 + */ + public function update(AuditChecklistUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 점검표 완료 처리 + */ + public function complete(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->complete($id); + }, __('message.updated')); + } + + /** + * 항목 완료/미완료 토글 + */ + public function toggleItem(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->toggleItem($id); + }, __('message.updated')); + } + + /** + * 항목별 기준 문서 조회 + */ + public function itemDocuments(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->itemDocuments($id); + }, __('message.fetched')); + } + + /** + * 기준 문서 연결 + */ + public function attachDocument(Request $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->attachDocument($id, $request->validate([ + 'title' => 'required|string|max:200', + 'version' => 'nullable|string|max:20', + 'date' => 'nullable|date', + 'document_id' => 'nullable|integer|exists:documents,id', + ])); + }, __('message.created')); + } + + /** + * 기준 문서 연결 해제 + */ + public function detachDocument(int $id, int $docId) + { + return ApiResponse::handle(function () use ($id, $docId) { + $this->service->detachDocument($id, $docId); + + return null; + }, __('message.deleted')); + } +} diff --git a/app/Http/Requests/Qms/AuditChecklistStoreRequest.php b/app/Http/Requests/Qms/AuditChecklistStoreRequest.php new file mode 100644 index 0000000..7d3e64c --- /dev/null +++ b/app/Http/Requests/Qms/AuditChecklistStoreRequest.php @@ -0,0 +1,39 @@ + 'required|integer|min:2020|max:2100', + 'quarter' => 'required|integer|in:1,2,3,4', + 'type' => 'nullable|string|max:30', + 'categories' => 'required|array|min:1', + 'categories.*.title' => 'required|string|max:200', + 'categories.*.sort_order' => 'nullable|integer|min:0', + 'categories.*.items' => 'required|array|min:1', + 'categories.*.items.*.name' => 'required|string|max:200', + 'categories.*.items.*.description' => 'nullable|string', + 'categories.*.items.*.sort_order' => 'nullable|integer|min:0', + ]; + } + + public function messages(): array + { + return [ + 'categories.required' => __('validation.required', ['attribute' => '카테고리']), + 'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']), + 'categories.*.items.required' => __('validation.required', ['attribute' => '점검 항목']), + 'categories.*.items.*.name.required' => __('validation.required', ['attribute' => '항목명']), + ]; + } +} diff --git a/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php b/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php new file mode 100644 index 0000000..f68811c --- /dev/null +++ b/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php @@ -0,0 +1,28 @@ + 'sometimes|array|min:1', + 'categories.*.id' => 'nullable|integer|exists:audit_checklist_categories,id', + 'categories.*.title' => 'required|string|max:200', + 'categories.*.sort_order' => 'nullable|integer|min:0', + 'categories.*.items' => 'required|array|min:1', + 'categories.*.items.*.id' => 'nullable|integer|exists:audit_checklist_items,id', + 'categories.*.items.*.name' => 'required|string|max:200', + 'categories.*.items.*.description' => 'nullable|string', + 'categories.*.items.*.sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Models/Qualitys/AuditChecklist.php b/app/Models/Qualitys/AuditChecklist.php new file mode 100644 index 0000000..af878d5 --- /dev/null +++ b/app/Models/Qualitys/AuditChecklist.php @@ -0,0 +1,57 @@ + 'integer', + 'quarter' => 'integer', + 'options' => 'array', + ]; + + public function categories(): HasMany + { + return $this->hasMany(AuditChecklistCategory::class, 'checklist_id')->orderBy('sort_order'); + } + + public function isDraft(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/Qualitys/AuditChecklistCategory.php b/app/Models/Qualitys/AuditChecklistCategory.php new file mode 100644 index 0000000..d0f9a76 --- /dev/null +++ b/app/Models/Qualitys/AuditChecklistCategory.php @@ -0,0 +1,35 @@ + 'integer', + 'options' => 'array', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(AuditChecklist::class, 'checklist_id'); + } + + public function items(): HasMany + { + return $this->hasMany(AuditChecklistItem::class, 'category_id')->orderBy('sort_order'); + } +} diff --git a/app/Models/Qualitys/AuditChecklistItem.php b/app/Models/Qualitys/AuditChecklistItem.php new file mode 100644 index 0000000..b94b7bd --- /dev/null +++ b/app/Models/Qualitys/AuditChecklistItem.php @@ -0,0 +1,47 @@ + 'boolean', + 'completed_at' => 'datetime', + 'sort_order' => 'integer', + 'options' => 'array', + ]; + + public function category(): BelongsTo + { + return $this->belongsTo(AuditChecklistCategory::class, 'category_id'); + } + + public function completedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + public function standardDocuments(): HasMany + { + return $this->hasMany(AuditStandardDocument::class, 'checklist_item_id'); + } +} diff --git a/app/Models/Qualitys/AuditStandardDocument.php b/app/Models/Qualitys/AuditStandardDocument.php new file mode 100644 index 0000000..3dcd63c --- /dev/null +++ b/app/Models/Qualitys/AuditStandardDocument.php @@ -0,0 +1,37 @@ + 'date', + 'options' => 'array', + ]; + + public function checklistItem(): BelongsTo + { + return $this->belongsTo(AuditChecklistItem::class, 'checklist_item_id'); + } + + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } +} diff --git a/app/Services/AuditChecklistService.php b/app/Services/AuditChecklistService.php new file mode 100644 index 0000000..2c3f48e --- /dev/null +++ b/app/Services/AuditChecklistService.php @@ -0,0 +1,392 @@ +where('year', (int) $params['year']); + } + if (! empty($params['quarter'])) { + $query->where('quarter', (int) $params['quarter']); + } + if (! empty($params['type'])) { + $query->where('type', $params['type']); + } + + $query->orderByDesc('year')->orderByDesc('quarter'); + $perPage = (int) ($params['per_page'] ?? 20); + $paginated = $query->paginate($perPage); + + $items = $paginated->getCollection()->map(fn ($checklist) => $this->transformListItem($checklist)); + + return [ + 'items' => $items, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 점검표 생성 (카테고리+항목 일괄) + */ + public function store(array $data): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 중복 체크 + $exists = AuditChecklist::where('year', $data['year']) + ->where('quarter', $data['quarter']) + ->where('type', $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.duplicate', ['attribute' => '해당 분기 점검표'])); + } + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $checklist = AuditChecklist::create([ + 'tenant_id' => $tenantId, + 'year' => $data['year'], + 'quarter' => $data['quarter'], + 'type' => $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL, + 'status' => AuditChecklist::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $this->syncCategories($checklist, $data['categories'], $tenantId); + + return $this->show($checklist->id); + }); + } + + /** + * 점검표 상세 (카테고리→항목→문서 중첩) + */ + public function show(int $id): array + { + $checklist = AuditChecklist::with([ + 'categories.items.standardDocuments.document', + ])->findOrFail($id); + + return $this->transformDetail($checklist); + } + + /** + * 점검표 수정 + */ + public function update(int $id, array $data): array + { + $tenantId = $this->tenantId(); + + $checklist = AuditChecklist::findOrFail($id); + + if ($checklist->isCompleted()) { + throw new BadRequestHttpException('완료된 점검표는 수정할 수 없습니다.'); + } + + return DB::transaction(function () use ($checklist, $data, $tenantId) { + $checklist->update([ + 'updated_by' => $this->apiUserId(), + ]); + + if (isset($data['categories'])) { + $this->syncCategories($checklist, $data['categories'], $tenantId); + } + + return $this->show($checklist->id); + }); + } + + /** + * 점검표 완료 처리 + */ + public function complete(int $id): array + { + $checklist = AuditChecklist::with('categories.items')->findOrFail($id); + + // 미완료 항목 확인 + $totalItems = 0; + $completedItems = 0; + foreach ($checklist->categories as $category) { + foreach ($category->items as $item) { + $totalItems++; + if ($item->is_completed) { + $completedItems++; + } + } + } + + if ($completedItems < $totalItems) { + throw new BadRequestHttpException("미완료 항목이 있습니다. ({$completedItems}/{$totalItems})"); + } + + $checklist->update([ + 'status' => AuditChecklist::STATUS_COMPLETED, + 'updated_by' => $this->apiUserId(), + ]); + + return $this->show($checklist->id); + } + + /** + * 항목 완료/미완료 토글 + */ + public function toggleItem(int $itemId): array + { + $item = AuditChecklistItem::findOrFail($itemId); + $userId = $this->apiUserId(); + + DB::transaction(function () use ($item, $userId) { + $item->lockForUpdate(); + + $newCompleted = ! $item->is_completed; + $item->update([ + 'is_completed' => $newCompleted, + 'completed_at' => $newCompleted ? now() : null, + 'completed_by' => $newCompleted ? $userId : null, + ]); + + // 점검표 상태 자동 업데이트: draft → in_progress + $category = $item->category; + $checklist = $category->checklist; + if ($checklist->isDraft()) { + $checklist->update([ + 'status' => AuditChecklist::STATUS_IN_PROGRESS, + 'updated_by' => $userId, + ]); + } + }); + + $item->refresh(); + + return [ + 'id' => (string) $item->id, + 'name' => $item->name, + 'is_completed' => $item->is_completed, + 'completed_at' => $item->completed_at?->toIso8601String(), + ]; + } + + /** + * 항목별 기준 문서 조회 + */ + public function itemDocuments(int $itemId): array + { + $item = AuditChecklistItem::findOrFail($itemId); + + return $item->standardDocuments()->with('document')->get() + ->map(fn ($doc) => $this->transformStandardDocument($doc)) + ->all(); + } + + /** + * 기준 문서 연결 + */ + public function attachDocument(int $itemId, array $data): array + { + $item = AuditChecklistItem::findOrFail($itemId); + $tenantId = $this->tenantId(); + + $doc = AuditStandardDocument::create([ + 'tenant_id' => $tenantId, + 'checklist_item_id' => $item->id, + 'title' => $data['title'], + 'version' => $data['version'] ?? null, + 'date' => $data['date'] ?? null, + 'document_id' => $data['document_id'] ?? null, + ]); + + $doc->load('document'); + + return $this->transformStandardDocument($doc); + } + + /** + * 기준 문서 연결 해제 + */ + public function detachDocument(int $itemId, int $docId): void + { + $doc = AuditStandardDocument::where('checklist_item_id', $itemId) + ->where('id', $docId) + ->firstOrFail(); + + $doc->delete(); + } + + // ===== Private: Sync & Transform ===== + + private function syncCategories(AuditChecklist $checklist, array $categoriesData, int $tenantId): void + { + // 기존 카테고리 ID 추적 (삭제 감지용) + $existingCategoryIds = $checklist->categories()->pluck('id')->all(); + $keptCategoryIds = []; + + foreach ($categoriesData as $catIdx => $catData) { + if (! empty($catData['id'])) { + // 기존 카테고리 업데이트 + $category = AuditChecklistCategory::findOrFail($catData['id']); + $category->update([ + 'title' => $catData['title'], + 'sort_order' => $catData['sort_order'] ?? $catIdx, + ]); + $keptCategoryIds[] = $category->id; + } else { + // 새 카테고리 생성 + $category = AuditChecklistCategory::create([ + 'tenant_id' => $tenantId, + 'checklist_id' => $checklist->id, + 'title' => $catData['title'], + 'sort_order' => $catData['sort_order'] ?? $catIdx, + ]); + $keptCategoryIds[] = $category->id; + } + + // 하위 항목 동기화 + $this->syncItems($category, $catData['items'] ?? [], $tenantId); + } + + // 삭제된 카테고리 제거 (cascade로 items도 삭제) + $deletedIds = array_diff($existingCategoryIds, $keptCategoryIds); + if (! empty($deletedIds)) { + AuditChecklistCategory::whereIn('id', $deletedIds)->delete(); + } + } + + private function syncItems(AuditChecklistCategory $category, array $itemsData, int $tenantId): void + { + $existingItemIds = $category->items()->pluck('id')->all(); + $keptItemIds = []; + + foreach ($itemsData as $itemIdx => $itemData) { + if (! empty($itemData['id'])) { + $item = AuditChecklistItem::findOrFail($itemData['id']); + $item->update([ + 'name' => $itemData['name'], + 'description' => $itemData['description'] ?? null, + 'sort_order' => $itemData['sort_order'] ?? $itemIdx, + ]); + $keptItemIds[] = $item->id; + } else { + $item = AuditChecklistItem::create([ + 'tenant_id' => $tenantId, + 'category_id' => $category->id, + 'name' => $itemData['name'], + 'description' => $itemData['description'] ?? null, + 'sort_order' => $itemData['sort_order'] ?? $itemIdx, + ]); + $keptItemIds[] = $item->id; + } + } + + $deletedIds = array_diff($existingItemIds, $keptItemIds); + if (! empty($deletedIds)) { + AuditChecklistItem::whereIn('id', $deletedIds)->delete(); + } + } + + private function transformListItem(AuditChecklist $checklist): array + { + $total = 0; + $completed = 0; + foreach ($checklist->categories as $category) { + foreach ($category->items as $item) { + $total++; + if ($item->is_completed) { + $completed++; + } + } + } + + return [ + 'id' => (string) $checklist->id, + 'year' => $checklist->year, + 'quarter' => $checklist->quarter, + 'type' => $checklist->type, + 'status' => $checklist->status, + 'progress' => [ + 'completed' => $completed, + 'total' => $total, + ], + ]; + } + + private function transformDetail(AuditChecklist $checklist): array + { + $total = 0; + $completed = 0; + + $categories = $checklist->categories->map(function ($category) use (&$total, &$completed) { + $subItems = $category->items->map(function ($item) use (&$total, &$completed) { + $total++; + if ($item->is_completed) { + $completed++; + } + + return [ + 'id' => (string) $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'is_completed' => $item->is_completed, + 'completed_at' => $item->completed_at?->toIso8601String(), + 'sort_order' => $item->sort_order, + 'standard_documents' => $item->standardDocuments->map( + fn ($doc) => $this->transformStandardDocument($doc) + )->all(), + ]; + })->all(); + + return [ + 'id' => (string) $category->id, + 'title' => $category->title, + 'sort_order' => $category->sort_order, + 'sub_items' => $subItems, + ]; + })->all(); + + return [ + 'id' => (string) $checklist->id, + 'year' => $checklist->year, + 'quarter' => $checklist->quarter, + 'type' => $checklist->type, + 'status' => $checklist->status, + 'progress' => [ + 'completed' => $completed, + 'total' => $total, + ], + 'categories' => $categories, + ]; + } + + private function transformStandardDocument(AuditStandardDocument $doc): array + { + $file = $doc->document; + + return [ + 'id' => (string) $doc->id, + 'title' => $doc->title, + 'version' => $doc->version ?? '-', + 'date' => $doc->date?->toDateString() ?? '', + 'file_name' => $file?->original_name ?? null, + 'file_url' => $file ? "/api/v1/documents/{$file->id}/download" : null, + ]; + } +} diff --git a/database/migrations/2026_03_10_110000_create_audit_checklists_tables.php b/database/migrations/2026_03_10_110000_create_audit_checklists_tables.php new file mode 100644 index 0000000..1c9e332 --- /dev/null +++ b/database/migrations/2026_03_10_110000_create_audit_checklists_tables.php @@ -0,0 +1,90 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->smallInteger('year')->unsigned()->comment('연도'); + $table->tinyInteger('quarter')->unsigned()->comment('분기 1~4'); + $table->string('type', 30)->default('standard_manual')->comment('심사유형'); + $table->string('status', 20)->default('draft')->comment('draft/in_progress/completed'); + $table->json('options')->nullable()->comment('추가 설정'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'year', 'quarter', 'type'], 'uq_audit_checklists_tenant_period'); + $table->index(['tenant_id', 'status'], 'idx_audit_checklists_status'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + }); + + // 2) 점검표 카테고리 + Schema::create('audit_checklist_categories', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('checklist_id')->comment('점검표ID'); + $table->string('title', 200)->comment('카테고리명'); + $table->unsignedInteger('sort_order')->default(0)->comment('정렬순서'); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index(['checklist_id', 'sort_order'], 'idx_audit_categories_sort'); + $table->foreign('checklist_id')->references('id')->on('audit_checklists')->onDelete('cascade'); + }); + + // 3) 점검표 세부 항목 + Schema::create('audit_checklist_items', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('category_id')->comment('카테고리ID'); + $table->string('name', 200)->comment('항목명'); + $table->text('description')->nullable()->comment('항목 설명'); + $table->boolean('is_completed')->default(false)->comment('완료여부'); + $table->timestamp('completed_at')->nullable()->comment('완료일시'); + $table->unsignedBigInteger('completed_by')->nullable()->comment('완료처리자'); + $table->unsignedInteger('sort_order')->default(0)->comment('정렬순서'); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index(['category_id', 'sort_order'], 'idx_audit_items_sort'); + $table->index(['category_id', 'is_completed'], 'idx_audit_items_completed'); + $table->foreign('category_id')->references('id')->on('audit_checklist_categories')->onDelete('cascade'); + $table->foreign('completed_by')->references('id')->on('users')->onDelete('set null'); + }); + + // 4) 기준 문서 연결 + Schema::create('audit_standard_documents', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('checklist_item_id')->comment('점검항목ID'); + $table->string('title', 200)->comment('문서명'); + $table->string('version', 20)->nullable()->comment('버전'); + $table->date('date')->nullable()->comment('시행일'); + $table->unsignedBigInteger('document_id')->nullable()->comment('EAV 파일 FK'); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index('checklist_item_id', 'idx_audit_std_docs_item'); + $table->foreign('checklist_item_id')->references('id')->on('audit_checklist_items')->onDelete('cascade'); + $table->foreign('document_id')->references('id')->on('documents')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_standard_documents'); + Schema::dropIfExists('audit_checklist_items'); + Schema::dropIfExists('audit_checklist_categories'); + Schema::dropIfExists('audit_checklists'); + } +}; diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index 8100eb7..5edb7ca 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -7,6 +7,7 @@ * - 실적신고 */ +use App\Http\Controllers\Api\V1\AuditChecklistController; use App\Http\Controllers\Api\V1\PerformanceReportController; use App\Http\Controllers\Api\V1\QmsLotAuditController; use App\Http\Controllers\Api\V1\QualityDocumentController; @@ -48,3 +49,16 @@ Route::get('/documents/{type}/{id}', [QmsLotAuditController::class, 'documentDetail'])->whereNumber('id')->name('v1.qms.lot-audit.documents.detail'); Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); }); + +// QMS 기준/매뉴얼 심사 (1일차) +Route::prefix('qms')->group(function () { + Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index'); + Route::post('/checklists', [AuditChecklistController::class, 'store'])->name('v1.qms.checklists.store'); + Route::get('/checklists/{id}', [AuditChecklistController::class, 'show'])->whereNumber('id')->name('v1.qms.checklists.show'); + Route::put('/checklists/{id}', [AuditChecklistController::class, 'update'])->whereNumber('id')->name('v1.qms.checklists.update'); + Route::patch('/checklists/{id}/complete', [AuditChecklistController::class, 'complete'])->whereNumber('id')->name('v1.qms.checklists.complete'); + Route::patch('/checklist-items/{id}/toggle', [AuditChecklistController::class, 'toggleItem'])->whereNumber('id')->name('v1.qms.checklist-items.toggle'); + Route::get('/checklist-items/{id}/documents', [AuditChecklistController::class, 'itemDocuments'])->whereNumber('id')->name('v1.qms.checklist-items.documents'); + Route::post('/checklist-items/{id}/documents', [AuditChecklistController::class, 'attachDocument'])->whereNumber('id')->name('v1.qms.checklist-items.documents.attach'); + Route::delete('/checklist-items/{id}/documents/{docId}', [AuditChecklistController::class, 'detachDocument'])->whereNumber('id')->whereNumber('docId')->name('v1.qms.checklist-items.documents.detach'); +});