From 047524c19f36ed204bf3d307b3725612d48c5612 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 02:04:29 +0900 Subject: [PATCH] =?UTF-8?q?deploy:=202026-03-11=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: QMS 감사 체크리스트 (AuditChecklist CRUD, 카테고리/항목/표준문서 모델, 마이그레이션) - feat: QMS LOT 감사 (QmsLotAudit 컨트롤러/서비스, 확인/문서상세 FormRequest) - fix: CalendarService, MemberService 수정 - chore: QualityDocumentLocation options 컬럼 추가, tenant_id 마이그레이션, 품질 더미데이터 시더 --- .../Api/V1/AuditChecklistController.php | 112 ++++ .../Api/V1/QmsLotAuditController.php | 65 ++ .../Qms/AuditChecklistStoreRequest.php | 39 ++ .../Qms/AuditChecklistUpdateRequest.php | 28 + .../Qms/QmsLotAuditConfirmRequest.php | 28 + .../Qms/QmsLotAuditDocumentDetailRequest.php | 34 + .../Requests/Qms/QmsLotAuditIndexRequest.php | 23 + app/Models/Qualitys/AuditChecklist.php | 57 ++ .../Qualitys/AuditChecklistCategory.php | 35 + app/Models/Qualitys/AuditChecklistItem.php | 47 ++ app/Models/Qualitys/AuditStandardDocument.php | 37 ++ .../Qualitys/QualityDocumentLocation.php | 2 + app/Services/AuditChecklistService.php | 392 +++++++++++ app/Services/CalendarService.php | 150 ++++- app/Services/MemberService.php | 1 + app/Services/QmsLotAuditService.php | 517 +++++++++++++++ ..._options_to_quality_document_locations.php | 22 + ..._110000_create_audit_checklists_tables.php | 90 +++ ...dd_tenant_id_to_document_detail_tables.php | 46 ++ database/seeders/QualityDummyDataSeeder.php | 608 ++++++++++++++++++ routes/api/v1/quality.php | 24 + 21 files changed, 2356 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/V1/AuditChecklistController.php create mode 100644 app/Http/Controllers/Api/V1/QmsLotAuditController.php create mode 100644 app/Http/Requests/Qms/AuditChecklistStoreRequest.php create mode 100644 app/Http/Requests/Qms/AuditChecklistUpdateRequest.php create mode 100644 app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php create mode 100644 app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php create mode 100644 app/Http/Requests/Qms/QmsLotAuditIndexRequest.php create mode 100644 app/Models/Qualitys/AuditChecklist.php create mode 100644 app/Models/Qualitys/AuditChecklistCategory.php create mode 100644 app/Models/Qualitys/AuditChecklistItem.php create mode 100644 app/Models/Qualitys/AuditStandardDocument.php create mode 100644 app/Services/AuditChecklistService.php create mode 100644 app/Services/QmsLotAuditService.php create mode 100644 database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php create mode 100644 database/migrations/2026_03_10_110000_create_audit_checklists_tables.php create mode 100644 database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php create mode 100644 database/seeders/QualityDummyDataSeeder.php 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/Controllers/Api/V1/QmsLotAuditController.php b/app/Http/Controllers/Api/V1/QmsLotAuditController.php new file mode 100644 index 0000000..72afcce --- /dev/null +++ b/app/Http/Controllers/Api/V1/QmsLotAuditController.php @@ -0,0 +1,65 @@ +service->index($request->validated()); + }, __('message.fetched')); + } + + /** + * 품질관리서 상세 — 수주/개소 목록 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 수주 루트별 8종 서류 목록 + */ + public function routeDocuments(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->routeDocuments($id); + }, __('message.fetched')); + } + + /** + * 서류 상세 조회 (2단계 로딩) + */ + public function documentDetail(QmsLotAuditDocumentDetailRequest $request, string $type, int $id) + { + return ApiResponse::handle(function () use ($type, $id) { + return $this->service->documentDetail($type, $id); + }, __('message.fetched')); + } + + /** + * 개소별 로트 심사 확인 토글 + */ + public function confirm(QmsLotAuditConfirmRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->confirm($id, $request->validated()); + }, __('message.updated')); + } +} 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/Http/Requests/Qms/QmsLotAuditConfirmRequest.php b/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php new file mode 100644 index 0000000..f2c3dff --- /dev/null +++ b/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php @@ -0,0 +1,28 @@ + 'required|boolean', + ]; + } + + public function messages(): array + { + return [ + 'confirmed.required' => __('validation.required', ['attribute' => '확인 상태']), + 'confirmed.boolean' => __('validation.boolean', ['attribute' => '확인 상태']), + ]; + } +} diff --git a/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php b/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php new file mode 100644 index 0000000..560609a --- /dev/null +++ b/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php @@ -0,0 +1,34 @@ +merge([ + 'type' => $this->route('type'), + ]); + } + + public function rules(): array + { + return [ + 'type' => 'required|string|in:import,order,log,report,confirmation,shipping,product,quality', + ]; + } + + public function messages(): array + { + return [ + 'type.in' => __('validation.in', ['attribute' => '서류 타입']), + ]; + } +} diff --git a/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php b/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php new file mode 100644 index 0000000..e881467 --- /dev/null +++ b/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php @@ -0,0 +1,23 @@ + 'nullable|integer|min:2020|max:2100', + 'quarter' => 'nullable|integer|in:1,2,3,4', + 'q' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Models/Qualitys/AuditChecklist.php b/app/Models/Qualitys/AuditChecklist.php new file mode 100644 index 0000000..d620270 --- /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/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php index fd362bc..b5ae001 100644 --- a/app/Models/Qualitys/QualityDocumentLocation.php +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -26,10 +26,12 @@ class QualityDocumentLocation extends Model 'inspection_data', 'document_id', 'inspection_status', + 'options', ]; protected $casts = [ 'inspection_data' => 'array', + 'options' => 'array', ]; public function qualityDocument() 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/app/Services/CalendarService.php b/app/Services/CalendarService.php index 9d44415..798e950 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -4,9 +4,12 @@ use App\Models\Construction\Contract; use App\Models\Production\WorkOrder; +use App\Models\Orders\Order; use App\Models\Tenants\Bill; +use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Leave; use App\Models\Tenants\Schedule; +use App\Models\Tenants\Shipment; use Illuminate\Support\Collection; /** @@ -26,7 +29,7 @@ class CalendarService extends Service * * @param string $startDate 조회 시작일 (Y-m-d) * @param string $endDate 조회 종료일 (Y-m-d) - * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|null=전체) + * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|expected_expense|delivery|shipment|null=전체) * @param string|null $departmentFilter 부서 필터 (all|department|personal) */ public function getSchedules( @@ -73,6 +76,27 @@ public function getSchedules( ); } + // 매입 결제 예정일 + if ($type === null || $type === 'expected_expense') { + $schedules = $schedules->merge( + $this->getExpectedExpenseSchedules($tenantId, $startDate, $endDate) + ); + } + + // 수주 납기일 + if ($type === null || $type === 'delivery') { + $schedules = $schedules->merge( + $this->getDeliverySchedules($tenantId, $startDate, $endDate) + ); + } + + // 출고 예정일 + if ($type === null || $type === 'shipment') { + $schedules = $schedules->merge( + $this->getShipmentSchedules($tenantId, $startDate, $endDate) + ); + } + // startDate 기준 정렬 $sortedSchedules = $schedules ->sortBy('startDate') @@ -382,4 +406,128 @@ private function getBillSchedules( ]; }); } + + /** + * 매입 결제 예정일 조회 + */ + private function getExpectedExpenseSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $expenses = ExpectedExpense::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('expected_payment_date') + ->where('expected_payment_date', '>=', $startDate) + ->where('expected_payment_date', '<=', $endDate) + ->where('payment_status', '!=', 'paid') + ->with(['client:id,name']) + ->orderBy('expected_payment_date') + ->limit(100) + ->get(); + + return $expenses->map(function ($expense) { + $clientName = $expense->client?->name ?? $expense->client_name ?? ''; + + return [ + 'id' => 'expense_'.$expense->id, + 'title' => '[결제] '.$clientName.' '.number_format($expense->amount).'원', + 'startDate' => $expense->expected_payment_date->format('Y-m-d'), + 'endDate' => $expense->expected_payment_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'expected_expense', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } + + /** + * 수주 납기일 조회 + */ + private function getDeliverySchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $activeStatuses = [ + 'CONFIRMED', + 'IN_PROGRESS', + 'IN_PRODUCTION', + 'PRODUCED', + 'SHIPPING', + ]; + + $orders = Order::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('delivery_date') + ->where('delivery_date', '>=', $startDate) + ->where('delivery_date', '<=', $endDate) + ->whereIn('status_code', $activeStatuses) + ->with(['client:id,name']) + ->orderBy('delivery_date') + ->limit(100) + ->get(); + + return $orders->map(function ($order) { + $clientName = $order->client?->name ?? $order->client_name ?? ''; + $siteName = $order->site_name ?? $order->order_no; + + return [ + 'id' => 'delivery_'.$order->id, + 'title' => '[납기] '.$clientName.' '.$siteName, + 'startDate' => $order->delivery_date->format('Y-m-d'), + 'endDate' => $order->delivery_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'delivery', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } + + /** + * 출고 예정일 조회 + */ + private function getShipmentSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $shipments = Shipment::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('scheduled_date') + ->where('scheduled_date', '>=', $startDate) + ->where('scheduled_date', '<=', $endDate) + ->whereIn('status', ['scheduled', 'ready']) + ->with(['client:id,name', 'order:id,site_name']) + ->orderBy('scheduled_date') + ->limit(100) + ->get(); + + return $shipments->map(function ($shipment) { + $clientName = $shipment->client?->name ?? $shipment->customer_name ?? ''; + $siteName = $shipment->site_name ?? $shipment->order?->site_name ?? $shipment->shipment_no; + + return [ + 'id' => 'shipment_'.$shipment->id, + 'title' => '[출고] '.$clientName.' '.$siteName, + 'startDate' => $shipment->scheduled_date->format('Y-m-d'), + 'endDate' => $shipment->scheduled_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'shipment', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } } diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index be607e6..adcb1ef 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -231,6 +231,7 @@ public static function getUserInfoForLogin(int $userId): array $dept = DB::table('departments')->where('id', $profile->department_id)->first(); if ($dept) { $userInfo['department'] = $dept->name; + $userInfo['department_id'] = $dept->id; } } diff --git a/app/Services/QmsLotAuditService.php b/app/Services/QmsLotAuditService.php new file mode 100644 index 0000000..dbd367c --- /dev/null +++ b/app/Services/QmsLotAuditService.php @@ -0,0 +1,517 @@ + fn ($q) => $q->whereNull('parent_id'), + 'documentOrders.order.nodes.items.item', + 'locations', + 'performanceReport', + ]) + ->where('status', QualityDocument::STATUS_COMPLETED); + + // 연도 필터 + if (! empty($params['year'])) { + $year = (int) $params['year']; + $query->where(function ($q) use ($year) { + $q->whereHas('performanceReport', fn ($pr) => $pr->where('year', $year)) + ->orWhereDoesntHave('performanceReport'); + }); + } + + // 분기 필터 + if (! empty($params['quarter'])) { + $quarter = (int) $params['quarter']; + $query->whereHas('performanceReport', fn ($pr) => $pr->where('quarter', $quarter)); + } + + // 검색어 필터 + if (! empty($params['q'])) { + $term = trim($params['q']); + $query->where(function ($q) use ($term) { + $q->where('quality_doc_number', 'like', "%{$term}%") + ->orWhere('site_name', 'like', "%{$term}%"); + }); + } + + $query->orderByDesc('id'); + $perPage = (int) ($params['per_page'] ?? 20); + $paginated = $query->paginate($perPage); + + $items = $paginated->getCollection()->map(fn ($doc) => $this->transformReportToFrontend($doc)); + + return [ + 'items' => $items, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 품질관리서 상세 — 수주/개소 목록 (RouteItem[]) + */ + public function show(int $id): array + { + $doc = QualityDocument::with([ + 'documentOrders.order', + 'documentOrders.locations.orderItem', + ])->findOrFail($id); + + return $doc->documentOrders->map(fn ($docOrder) => $this->transformRouteToFrontend($docOrder, $doc))->values()->all(); + } + + /** + * 수주 루트별 8종 서류 목록 (Document[]) + */ + public function routeDocuments(int $qualityDocumentOrderId): array + { + $docOrder = QualityDocumentOrder::with([ + 'order.workOrders.process', + 'locations', + 'qualityDocument', + ])->findOrFail($qualityDocumentOrderId); + + $order = $docOrder->order; + $qualityDoc = $docOrder->qualityDocument; + $workOrders = $order->workOrders; + + $documents = []; + + // 1. 수입검사 성적서 (IQC): WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) + $investedLotIds = WorkOrderMaterialInput::whereIn('work_order_id', $workOrders->pluck('id')) + ->pluck('stock_lot_id') + ->unique(); + + $investedLotNos = StockLot::whereIn('id', $investedLotIds) + ->whereNotNull('lot_no') + ->pluck('lot_no') + ->unique(); + + $iqcInspections = Inspection::where('inspection_type', 'IQC') + ->whereIn('lot_no', $investedLotNos) + ->where('status', 'completed') + ->get(); + + $documents[] = $this->formatDocument('import', '수입검사 성적서', $iqcInspections); + + // 2. 수주서 + $documents[] = $this->formatDocument('order', '수주서', collect([$order])); + + // 3. 작업일지 (subType: process.process_name 기반) + $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders); + + // 4. 중간검사 성적서 (PQC) + $pqcInspections = Inspection::where('inspection_type', 'PQC') + ->whereIn('work_order_id', $workOrders->pluck('id')) + ->where('status', 'completed') + ->with('workOrder.process') + ->get(); + + $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder'); + + // 5. 납품확인서 + $shipments = $order->shipments()->get(); + $documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments); + + // 6. 출고증 + $documents[] = $this->formatDocument('shipping', '출고증', $shipments); + + // 7. 제품검사 성적서 + $locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id); + $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc); + + // 8. 품질관리서 + $documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc])); + + return $documents; + } + + /** + * 서류 상세 조회 (2단계 로딩 — 모달 렌더링용) + */ + public function documentDetail(string $type, int $id): array + { + return match ($type) { + 'import' => $this->getInspectionDetail($id, 'IQC'), + 'order' => $this->getOrderDetail($id), + 'log' => $this->getWorkOrderLogDetail($id), + 'report' => $this->getInspectionDetail($id, 'PQC'), + 'confirmation', 'shipping' => $this->getShipmentDetail($id), + 'product' => $this->getLocationDetail($id), + 'quality' => $this->getQualityDocDetail($id), + default => throw new NotFoundHttpException(__('error.not_found')), + }; + } + + /** + * 개소별 로트 심사 확인 토글 + */ + public function confirm(int $locationId, array $data): array + { + $location = QualityDocumentLocation::findOrFail($locationId); + $confirmed = (bool) $data['confirmed']; + $userId = $this->apiUserId(); + + DB::transaction(function () use ($location, $confirmed, $userId) { + $location->lockForUpdate(); + + $options = $location->options ?? []; + $options['lot_audit_confirmed'] = $confirmed; + $options['lot_audit_confirmed_at'] = $confirmed ? now()->toIso8601String() : null; + $options['lot_audit_confirmed_by'] = $confirmed ? $userId : null; + $location->options = $options; + $location->save(); + }); + + $location->refresh(); + + return [ + 'id' => (string) $location->id, + 'name' => $this->buildLocationName($location), + 'location' => $this->buildLocationCode($location), + 'is_completed' => (bool) data_get($location->options, 'lot_audit_confirmed', false), + ]; + } + + // ===== Private: Transform Methods ===== + + private function transformReportToFrontend(QualityDocument $doc): array + { + $performanceReport = $doc->performanceReport; + $confirmedCount = $doc->locations->filter(function ($loc) { + return data_get($loc->options, 'lot_audit_confirmed', false); + })->count(); + + return [ + 'id' => (string) $doc->id, + 'code' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'item' => $this->getFgProductName($doc), + 'route_count' => $confirmedCount, + 'total_routes' => $doc->locations->count(), + 'quarter' => $performanceReport + ? $performanceReport->year.'년 '.$performanceReport->quarter.'분기' + : '', + 'year' => $performanceReport?->year ?? now()->year, + 'quarter_num' => $performanceReport?->quarter ?? 0, + ]; + } + + /** + * BOM 최상위(FG) 제품명 추출 + * Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name + */ + private function getFgProductName(QualityDocument $doc): string + { + $firstDocOrder = $doc->documentOrders->first(); + if (! $firstDocOrder) { + return ''; + } + + $order = $firstDocOrder->order; + if (! $order) { + return ''; + } + + // eager loaded with whereNull('parent_id') filter + $rootNode = $order->nodes->first(); + if (! $rootNode) { + return ''; + } + + $representativeItem = $rootNode->items->first(); + + return $representativeItem?->item?->name ?? ''; + } + + private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array + { + return [ + 'id' => (string) $docOrder->id, + 'code' => $docOrder->order->order_no, + 'date' => $docOrder->order->received_at?->toDateString(), + 'site' => $docOrder->order->site_name ?? '', + 'location_count' => $docOrder->locations->count(), + 'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [ + 'id' => (string) $loc->id, + 'name' => $qualityDoc->quality_doc_number.'-'.str_pad($idx + 1, 2, '0', STR_PAD_LEFT), + 'location' => $this->buildLocationCode($loc), + 'is_completed' => (bool) data_get($loc->options, 'lot_audit_confirmed', false), + ])->all(), + ]; + } + + private function buildLocationName(QualityDocumentLocation $location): string + { + $qualityDoc = $location->qualityDocument; + if (! $qualityDoc) { + return ''; + } + + // location의 순번을 구하기 위해 같은 문서의 location 목록 조회 + $locations = QualityDocumentLocation::where('quality_document_id', $qualityDoc->id) + ->orderBy('id') + ->pluck('id'); + + $index = $locations->search($location->id); + + return $qualityDoc->quality_doc_number.'-'.str_pad(($index !== false ? $index + 1 : 1), 2, '0', STR_PAD_LEFT); + } + + private function buildLocationCode(QualityDocumentLocation $location): string + { + $orderItem = $location->orderItem; + if (! $orderItem) { + return ''; + } + + return trim(($orderItem->floor_code ?? '').' '.($orderItem->symbol_code ?? '')); + } + + // ===== Private: Document Format Helpers ===== + + private function formatDocument(string $type, string $title, $collection): array + { + return [ + 'id' => $type, + 'type' => $type, + 'title' => $title, + 'count' => $collection->count(), + 'items' => $collection->values()->map(fn ($item) => $this->formatDocumentItem($type, $item))->all(), + ]; + } + + private function formatDocumentWithSubType(string $type, string $title, $collection, ?string $workOrderRelation = null): array + { + return [ + 'id' => $type, + 'type' => $type, + 'title' => $title, + 'count' => $collection->count(), + 'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) { + $formatted = $this->formatDocumentItem($type, $item); + + // subType: process.process_name 기반 + $workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item; + if ($workOrder instanceof WorkOrder) { + $processName = $workOrder->process?->process_name; + $formatted['sub_type'] = $this->mapProcessToSubType($processName); + } + + return $formatted; + })->all(), + ]; + } + + private function formatDocumentItem(string $type, $item): array + { + return match ($type) { + 'import', 'report' => [ + 'id' => (string) $item->id, + 'title' => $item->inspection_no ?? '', + 'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '', + 'code' => $item->inspection_no ?? '', + ], + 'order' => [ + 'id' => (string) $item->id, + 'title' => $item->order_no, + 'date' => $item->received_at?->toDateString() ?? '', + 'code' => $item->order_no, + ], + 'log' => [ + 'id' => (string) $item->id, + 'title' => $item->project_name ?? '작업일지', + 'date' => $item->created_at?->toDateString() ?? '', + 'code' => $item->id, + ], + 'confirmation', 'shipping' => [ + 'id' => (string) $item->id, + 'title' => $item->shipment_no ?? '', + 'date' => $item->scheduled_date?->toDateString() ?? '', + 'code' => $item->shipment_no ?? '', + ], + 'product' => [ + 'id' => (string) $item->id, + 'title' => '제품검사 성적서', + 'date' => $item->updated_at?->toDateString() ?? '', + 'code' => '', + ], + 'quality' => [ + 'id' => (string) $item->id, + 'title' => $item->quality_doc_number ?? '', + 'date' => $item->received_date?->toDateString() ?? '', + 'code' => $item->quality_doc_number ?? '', + ], + default => [ + 'id' => (string) $item->id, + 'title' => '', + 'date' => '', + ], + }; + } + + /** + * process_name → subType 매핑 + */ + private function mapProcessToSubType(?string $processName): ?string + { + if (! $processName) { + return null; + } + + $name = mb_strtolower($processName); + + return match (true) { + str_contains($name, 'screen') || str_contains($name, '스크린') => 'screen', + str_contains($name, 'bending') || str_contains($name, '절곡') => 'bending', + str_contains($name, 'slat') || str_contains($name, '슬랫') => 'slat', + str_contains($name, 'jointbar') || str_contains($name, '조인트바') || str_contains($name, 'joint') => 'jointbar', + default => null, + }; + } + + // ===== Private: Document Detail Methods (2단계 로딩) ===== + + private function getInspectionDetail(int $id, string $type): array + { + $inspection = Inspection::where('inspection_type', $type) + ->with(['item', 'workOrder.process']) + ->findOrFail($id); + + return [ + 'type' => $type === 'IQC' ? 'import' : 'report', + 'data' => [ + 'id' => $inspection->id, + 'inspection_no' => $inspection->inspection_no, + 'inspection_type' => $inspection->inspection_type, + 'status' => $inspection->status, + 'result' => $inspection->result, + 'request_date' => $inspection->request_date?->toDateString(), + 'inspection_date' => $inspection->inspection_date?->toDateString(), + 'lot_no' => $inspection->lot_no, + 'item_name' => $inspection->item?->name, + 'process_name' => $inspection->workOrder?->process?->process_name, + 'meta' => $inspection->meta, + 'items' => $inspection->items, + 'attachments' => $inspection->attachments, + 'extra' => $inspection->extra, + ], + ]; + } + + private function getOrderDetail(int $id): array + { + $order = Order::with(['nodes' => fn ($q) => $q->whereNull('parent_id')])->findOrFail($id); + + return [ + 'type' => 'order', + 'data' => [ + 'id' => $order->id, + 'order_no' => $order->order_no, + 'status' => $order->status, + 'received_at' => $order->received_at?->toDateString(), + 'site_name' => $order->site_name, + 'nodes_count' => $order->nodes->count(), + ], + ]; + } + + private function getWorkOrderLogDetail(int $id): array + { + $workOrder = WorkOrder::with('process')->findOrFail($id); + + return [ + 'type' => 'log', + 'data' => [ + 'id' => $workOrder->id, + 'project_name' => $workOrder->project_name, + 'status' => $workOrder->status, + 'process_name' => $workOrder->process?->process_name, + 'options' => $workOrder->options, + 'created_at' => $workOrder->created_at?->toDateString(), + ], + ]; + } + + private function getShipmentDetail(int $id): array + { + $shipment = Shipment::findOrFail($id); + + return [ + 'type' => 'shipping', + 'data' => [ + 'id' => $shipment->id, + 'shipment_no' => $shipment->shipment_no, + 'status' => $shipment->status, + 'scheduled_date' => $shipment->scheduled_date?->toDateString(), + 'customer_name' => $shipment->customer_name, + 'site_name' => $shipment->site_name, + 'delivery_address' => $shipment->delivery_address, + 'delivery_method' => $shipment->delivery_method, + 'vehicle_no' => $shipment->vehicle_no, + 'driver_name' => $shipment->driver_name, + 'remarks' => $shipment->remarks, + ], + ]; + } + + private function getLocationDetail(int $id): array + { + $location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id); + + return [ + 'type' => 'product', + 'data' => [ + 'id' => $location->id, + 'inspection_status' => $location->inspection_status, + 'inspection_data' => $location->inspection_data, + 'post_width' => $location->post_width, + 'post_height' => $location->post_height, + 'floor_code' => $location->orderItem?->floor_code, + 'symbol_code' => $location->orderItem?->symbol_code, + 'document_id' => $location->document_id, + ], + ]; + } + + private function getQualityDocDetail(int $id): array + { + $doc = QualityDocument::with(['client', 'inspector:id,name'])->findOrFail($id); + + return [ + 'type' => 'quality', + 'data' => [ + 'id' => $doc->id, + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'status' => $doc->status, + 'received_date' => $doc->received_date?->toDateString(), + 'client_name' => $doc->client?->name, + 'inspector_name' => $doc->inspector?->name, + 'options' => $doc->options, + ], + ]; + } +} diff --git a/database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php b/database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php new file mode 100644 index 0000000..7f99a24 --- /dev/null +++ b/database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php @@ -0,0 +1,22 @@ +json('options')->nullable()->after('inspection_status')->comment('QMS 심사 확인 등 추가 데이터'); + }); + } + + public function down(): void + { + Schema::table('quality_document_locations', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; 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/database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php b/database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php new file mode 100644 index 0000000..b34858e --- /dev/null +++ b/database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php @@ -0,0 +1,46 @@ +unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트 ID'); + $table->index('tenant_id'); + }); + + // 부모 테이블(documents)에서 tenant_id 채우기 + DB::statement(" + UPDATE {$table} t + JOIN documents d ON t.document_id = d.id + SET t.tenant_id = d.tenant_id + "); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tables = ['document_data', 'document_approvals', 'document_attachments']; + + foreach ($tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } + } +}; \ No newline at end of file diff --git a/database/seeders/QualityDummyDataSeeder.php b/database/seeders/QualityDummyDataSeeder.php new file mode 100644 index 0000000..63c007c --- /dev/null +++ b/database/seeders/QualityDummyDataSeeder.php @@ -0,0 +1,608 @@ +where('tenant_id', $tenantId) + ->where('quality_doc_number', 'like', 'KD-QD-202604-%') + ->count(); + + if ($existing > 0) { + $this->command->info(' ⚠ quality_documents: 이미 '.$existing.'개 존재 (스킵)'); + + return; + } + + DB::transaction(function () use ($tenantId, $userId, $now) { + // ============================================================ + // Page 1: 제품검사/품질관리서 (quality_documents + orders + locations) + // ============================================================ + $this->command->info('📋 Page 1: 제품검사/품질관리서 더미 데이터 생성...'); + + $qualityDocs = [ + [ + 'quality_doc_number' => 'KD-QD-202604-0001', + 'site_name' => '강남 르네상스 오피스텔 신축공사', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[0], + 'inspector_id' => $userId, + 'received_date' => '2026-02-15', + 'options' => json_encode([ + 'manager' => ['name' => '김현수', 'phone' => '010-1111-2222'], + 'contractor' => ['name' => '이건설', 'phone' => '02-3333-4444', 'address' => '서울시 강남구 역삼동', 'company' => '대한건설(주)'], + 'inspection' => ['end_date' => '2026-03-05', 'start_date' => '2026-03-03', 'request_date' => '2026-02-28'], + 'supervisor' => ['name' => '박감리', 'phone' => '02-5555-6666', 'office' => '한국감리사무소', 'address' => '서울시 서초구'], + 'site_address' => ['detail' => '강남 르네상스 오피스텔 B1~15F', 'address' => '서울시 강남구 역삼동 123-45', 'postal_code' => '06241'], + 'construction_site' => ['name' => '강남 르네상스 오피스텔 신축공사', 'lot_number' => '123-45', 'land_location' => '서울시 강남구 역삼동'], + 'material_distributor' => ['ceo' => '최대표', 'phone' => '02-7777-8888', 'address' => '서울시 송파구', 'company' => '경동자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0002', + 'site_name' => '판교 테크노밸리 물류센터', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[1], + 'inspector_id' => $userId, + 'received_date' => '2026-02-20', + 'options' => json_encode([ + 'manager' => ['name' => '정우성', 'phone' => '010-2222-3333'], + 'contractor' => ['name' => '박시공', 'phone' => '031-4444-5555', 'address' => '경기도 성남시 분당구', 'company' => '판교건설(주)'], + 'inspection' => ['end_date' => '2026-03-08', 'start_date' => '2026-03-06', 'request_date' => '2026-03-01'], + 'supervisor' => ['name' => '이감리', 'phone' => '031-6666-7777', 'office' => '성남감리사무소', 'address' => '경기도 성남시 분당구'], + 'site_address' => ['detail' => '판교 테크노밸리 3단지 물류동', 'address' => '경기도 성남시 분당구 판교동 678-9', 'postal_code' => '13487'], + 'construction_site' => ['name' => '판교 테크노밸리 물류센터 신축', 'lot_number' => '678-9', 'land_location' => '경기도 성남시 분당구 판교동'], + 'material_distributor' => ['ceo' => '김대표', 'phone' => '031-8888-9999', 'address' => '경기도 용인시', 'company' => '한국자재유통(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0003', + 'site_name' => '잠실 롯데월드타워 리모델링', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[2], + 'inspector_id' => $userId, + 'received_date' => '2026-02-25', + 'options' => json_encode([ + 'manager' => ['name' => '송민호', 'phone' => '010-3333-4444'], + 'contractor' => ['name' => '최시공', 'phone' => '02-5555-6666', 'address' => '서울시 송파구 잠실동', 'company' => '잠실건설(주)'], + 'inspection' => ['end_date' => '2026-03-12', 'start_date' => '2026-03-10', 'request_date' => '2026-03-05'], + 'supervisor' => ['name' => '강감리', 'phone' => '02-7777-8888', 'office' => '송파감리사무소', 'address' => '서울시 송파구'], + 'site_address' => ['detail' => '잠실 롯데월드타워 15~20F', 'address' => '서울시 송파구 잠실동 29', 'postal_code' => '05551'], + 'construction_site' => ['name' => '잠실 롯데월드타워 리모델링 공사', 'lot_number' => '29', 'land_location' => '서울시 송파구 잠실동'], + 'material_distributor' => ['ceo' => '한대표', 'phone' => '02-9999-0000', 'address' => '서울시 강동구', 'company' => '동부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0004', + 'site_name' => '마곡 LG사이언스파크 증축', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[3], + 'inspector_id' => $userId, + 'received_date' => '2026-03-01', + 'options' => json_encode([ + 'manager' => ['name' => '윤서연', 'phone' => '010-4444-5555'], + 'contractor' => ['name' => '임시공', 'phone' => '02-1234-5678', 'address' => '서울시 강서구 마곡동', 'company' => '마곡종합건설(주)'], + 'inspection' => ['end_date' => '2026-03-15', 'start_date' => '2026-03-13', 'request_date' => '2026-03-08'], + 'supervisor' => ['name' => '오감리', 'phone' => '02-2345-6789', 'office' => '강서감리사무소', 'address' => '서울시 강서구'], + 'site_address' => ['detail' => 'LG사이언스파크 E동 증축', 'address' => '서울시 강서구 마곡동 757', 'postal_code' => '07796'], + 'construction_site' => ['name' => '마곡 LG사이언스파크 증축공사', 'lot_number' => '757', 'land_location' => '서울시 강서구 마곡동'], + 'material_distributor' => ['ceo' => '장대표', 'phone' => '02-3456-7890', 'address' => '서울시 영등포구', 'company' => '서부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0005', + 'site_name' => '인천 송도 스마트시티 아파트', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[3], + 'inspector_id' => $userId, + 'received_date' => '2026-03-05', + 'options' => json_encode([ + 'manager' => ['name' => '안재현', 'phone' => '010-5555-6666'], + 'contractor' => ['name' => '배시공', 'phone' => '032-1111-2222', 'address' => '인천시 연수구 송도동', 'company' => '송도종합건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => '2026-03-18', 'request_date' => '2026-03-10'], + 'supervisor' => ['name' => '황감리', 'phone' => '032-3333-4444', 'office' => '인천감리사무소', 'address' => '인천시 연수구'], + 'site_address' => ['detail' => '송도 스마트시티 A블록 101~105동', 'address' => '인천시 연수구 송도동 100-1', 'postal_code' => '21990'], + 'construction_site' => ['name' => '인천 송도 스마트시티 아파트 신축', 'lot_number' => '100-1', 'land_location' => '인천시 연수구 송도동'], + 'material_distributor' => ['ceo' => '서대표', 'phone' => '032-5555-6666', 'address' => '인천시 남동구', 'company' => '인천자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0006', + 'site_name' => '화성 동탄2 행복주택 단지', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[4], + 'inspector_id' => $userId, + 'received_date' => '2026-03-06', + 'options' => json_encode([ + 'manager' => ['name' => '류준열', 'phone' => '010-6666-7777'], + 'contractor' => ['name' => '조시공', 'phone' => '031-2222-3333', 'address' => '경기도 화성시 동탄', 'company' => '동탄건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => '2026-03-12'], + 'supervisor' => ['name' => '문감리', 'phone' => '031-4444-5555', 'office' => '화성감리사무소', 'address' => '경기도 화성시'], + 'site_address' => ['detail' => '동탄2 행복주택 A1~A5동', 'address' => '경기도 화성시 동탄면 200-3', 'postal_code' => '18450'], + 'construction_site' => ['name' => '화성 동탄2 행복주택 단지 신축공사', 'lot_number' => '200-3', 'land_location' => '경기도 화성시 동탄면'], + 'material_distributor' => ['ceo' => '남대표', 'phone' => '031-6666-7777', 'address' => '경기도 오산시', 'company' => '경기자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0007', + 'site_name' => '세종시 정부청사 별관', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[0], + 'inspector_id' => null, + 'received_date' => '2026-03-07', + 'options' => json_encode([ + 'manager' => ['name' => '김세종', 'phone' => '010-7777-8888'], + 'contractor' => ['name' => '정시공', 'phone' => '044-1111-2222', 'address' => '세종시 어진동', 'company' => '세종건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '고감리', 'phone' => '044-3333-4444', 'office' => '세종감리사무소', 'address' => '세종시 나성동'], + 'site_address' => ['detail' => '정부세종청사 별관동', 'address' => '세종시 어진동 850', 'postal_code' => '30113'], + 'construction_site' => ['name' => '세종시 정부청사 별관 신축공사', 'lot_number' => '850', 'land_location' => '세종시 어진동'], + 'material_distributor' => ['ceo' => '윤대표', 'phone' => '044-5555-6666', 'address' => '대전시 유성구', 'company' => '중부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0008', + 'site_name' => '부산 해운대 엘시티 주상복합', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[1], + 'inspector_id' => null, + 'received_date' => '2026-03-08', + 'options' => json_encode([ + 'manager' => ['name' => '이부산', 'phone' => '010-8888-9999'], + 'contractor' => ['name' => '노시공', 'phone' => '051-1111-2222', 'address' => '부산시 해운대구', 'company' => '해운대건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '차감리', 'phone' => '051-3333-4444', 'office' => '부산감리사무소', 'address' => '부산시 해운대구'], + 'site_address' => ['detail' => '해운대 엘시티 B동 전층', 'address' => '부산시 해운대구 우동 1478', 'postal_code' => '48060'], + 'construction_site' => ['name' => '부산 해운대 엘시티 주상복합 리모델링', 'lot_number' => '1478', 'land_location' => '부산시 해운대구 우동'], + 'material_distributor' => ['ceo' => '백대표', 'phone' => '051-5555-6666', 'address' => '부산시 사하구', 'company' => '남부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0009', + 'site_name' => '수원 광교 복합문화센터', + 'status' => 'draft', + 'client_id' => self::CLIENT_IDS[4], + 'inspector_id' => null, + 'received_date' => '2026-03-09', + 'options' => json_encode([ + 'manager' => ['name' => '한지민', 'phone' => '010-5555-6666'], + 'contractor' => ['name' => '', 'phone' => '', 'address' => '', 'company' => ''], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '', 'phone' => '', 'office' => '', 'address' => ''], + 'site_address' => ['detail' => '광교 복합문화센터 전관', 'address' => '경기도 수원시 영통구 광교동 200', 'postal_code' => '16508'], + 'construction_site' => ['name' => '수원 광교 복합문화센터 신축공사', 'lot_number' => '200', 'land_location' => '경기도 수원시 영통구 광교동'], + 'material_distributor' => ['ceo' => '', 'phone' => '', 'address' => '', 'company' => ''], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0010', + 'site_name' => '대구 수성 의료복합단지', + 'status' => 'draft', + 'client_id' => self::CLIENT_IDS[2], + 'inspector_id' => null, + 'received_date' => '2026-03-10', + 'options' => json_encode([ + 'manager' => ['name' => '박대구', 'phone' => '010-9999-0000'], + 'contractor' => ['name' => '', 'phone' => '', 'address' => '', 'company' => ''], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '', 'phone' => '', 'office' => '', 'address' => ''], + 'site_address' => ['detail' => '수성 의료복합단지 본관', 'address' => '대구시 수성구 범어동 350', 'postal_code' => '42020'], + 'construction_site' => ['name' => '대구 수성 의료복합단지 신축공사', 'lot_number' => '350', 'land_location' => '대구시 수성구 범어동'], + 'material_distributor' => ['ceo' => '', 'phone' => '', 'address' => '', 'company' => ''], + ]), + ], + ]; + + $qualityDocIds = []; + foreach ($qualityDocs as $doc) { + $qualityDocIds[] = DB::table('quality_documents')->insertGetId(array_merge($doc, [ + 'tenant_id' => $tenantId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + $this->command->info(' ✅ quality_documents: '.count($qualityDocIds).'개 생성'); + + // completed/in_progress 문서에 수주 연결 (draft 제외) + $docOrderMapping = [ + $qualityDocIds[0] => [self::ORDER_IDS[0], self::ORDER_IDS[1]], // completed + $qualityDocIds[1] => [self::ORDER_IDS[2], self::ORDER_IDS[3], self::ORDER_IDS[4]], // completed + $qualityDocIds[2] => [self::ORDER_IDS[5], self::ORDER_IDS[6]], // completed + $qualityDocIds[3] => [self::ORDER_IDS[7], self::ORDER_IDS[8]], // completed + $qualityDocIds[4] => [self::ORDER_IDS[9], self::ORDER_IDS[0]], // in_progress + $qualityDocIds[5] => [self::ORDER_IDS[1], self::ORDER_IDS[2]], // in_progress + $qualityDocIds[6] => [self::ORDER_IDS[3]], // in_progress + $qualityDocIds[7] => [self::ORDER_IDS[4], self::ORDER_IDS[5]], // in_progress + ]; + + $qdoMap = []; + $qdoCount = 0; + foreach ($docOrderMapping as $docId => $orderIds) { + foreach ($orderIds as $orderId) { + $qdoId = DB::table('quality_document_orders')->insertGetId([ + 'quality_document_id' => $docId, + 'order_id' => $orderId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $qdoMap[$docId][$orderId] = $qdoId; + $qdoCount++; + } + } + $this->command->info(' ✅ quality_document_orders: '.$qdoCount.'개 생성'); + + // 각 수주별 order_items 조회 후 locations 생성 + $inspectionDataSets = [ + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'fail', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '방화문', 'guideRailGap' => 'N/A', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'N/A', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '스크린셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'pass', 'material' => 'fail', 'impactTest' => 'pass', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'fail']), + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '절곡셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + ]; + + $completedDocIds = array_slice($qualityDocIds, 0, 4); // 처음 4개가 completed + $locationCount = 0; + foreach ($qdoMap as $docId => $orderMap) { + foreach ($orderMap as $orderId => $qdoId) { + $orderItemIds = DB::table('order_items') + ->where('order_id', $orderId) + ->pluck('id') + ->take(4) + ->toArray(); + + if (empty($orderItemIds)) { + // order_items가 없으면 order_nodes → order_items 경로 시도 + $nodeIds = DB::table('order_nodes') + ->where('order_id', $orderId) + ->pluck('id'); + $orderItemIds = DB::table('order_items') + ->whereIn('order_node_id', $nodeIds) + ->pluck('id') + ->take(4) + ->toArray(); + } + + if (empty($orderItemIds)) { + $this->command->warn(' ⚠ order_id='.$orderId.'에 order_items 없음 (스킵)'); + + continue; + } + + foreach ($orderItemIds as $idx => $orderItemId) { + $isCompleted = in_array($docId, $completedDocIds); + $inspectionStatus = $isCompleted + ? ($idx === 1 ? 'fail' : 'pass') + : 'pending'; + + $inspectionData = $isCompleted + ? $inspectionDataSets[$idx % count($inspectionDataSets)] + : null; + + $postWidth = ($isCompleted || $idx < 2) ? rand(1800, 3200) : null; + $postHeight = ($isCompleted || $idx < 2) ? rand(2100, 3800) : null; + $changeReason = ($isCompleted && $idx === 0) ? '현장 사정으로 규격 변경' : null; + + $locOptions = null; + if ($isCompleted) { + $locOptions = json_encode([ + 'lot_audit_confirmed' => $idx !== 1, + 'lot_audit_confirmed_at' => $idx !== 1 ? $now->toDateTimeString() : null, + 'lot_audit_confirmed_by' => $idx !== 1 ? $userId : null, + ]); + } + + DB::table('quality_document_locations')->insert([ + 'quality_document_id' => $docId, + 'quality_document_order_id' => $qdoId, + 'order_item_id' => $orderItemId, + 'post_width' => $postWidth, + 'post_height' => $postHeight, + 'change_reason' => $changeReason, + 'inspection_data' => $inspectionData, + 'document_id' => null, + 'inspection_status' => $inspectionStatus, + 'options' => $locOptions, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $locationCount++; + } + } + } + $this->command->info(' ✅ quality_document_locations: '.$locationCount.'개 생성'); + + // ============================================================ + // Page 2: 실적신고 (performance_reports) + // ============================================================ + $this->command->info('📊 Page 2: 실적신고 더미 데이터 생성...'); + + $existingReports = DB::table('performance_reports') + ->where('tenant_id', $tenantId) + ->whereIn('quality_document_id', $qualityDocIds) + ->count(); + + if ($existingReports > 0) { + $this->command->info(' ⚠ performance_reports: 이미 '.$existingReports.'개 존재 (스킵)'); + } else { + $performanceReports = [ + [ + 'quality_document_id' => $qualityDocIds[0], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'confirmed', + 'confirmed_date' => '2026-03-06', + 'confirmed_by' => $userId, + 'memo' => '1분기 검사 완료 - 강남 르네상스 건', + ], + [ + 'quality_document_id' => $qualityDocIds[1], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'confirmed', + 'confirmed_date' => '2026-03-09', + 'confirmed_by' => $userId, + 'memo' => '판교 물류센터 건 - 확인 완료', + ], + [ + 'quality_document_id' => $qualityDocIds[2], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'unconfirmed', + 'confirmed_date' => null, + 'confirmed_by' => null, + 'memo' => '잠실 리모델링 건 - 확인 대기중', + ], + [ + 'quality_document_id' => $qualityDocIds[3], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'confirmed', + 'confirmed_date' => '2026-03-16', + 'confirmed_by' => $userId, + 'memo' => '마곡 LG사이언스파크 건 - 확인 완료', + ], + [ + 'quality_document_id' => $qualityDocIds[4], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'pending', + 'confirmed_date' => null, + 'confirmed_by' => null, + 'memo' => '송도 아파트 건 - 검사 진행중', + ], + [ + 'quality_document_id' => $qualityDocIds[5], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'pending', + 'confirmed_date' => null, + 'confirmed_by' => null, + 'memo' => '동탄 행복주택 건 - 검사 진행중', + ], + ]; + + foreach ($performanceReports as $report) { + DB::table('performance_reports')->insert(array_merge($report, [ + 'tenant_id' => $tenantId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + $this->command->info(' ✅ performance_reports: '.count($performanceReports).'개 생성'); + } + + // ============================================================ + // Page 3: 품질인정심사 (audit_checklists + categories + items + standard_documents) + // ============================================================ + $this->command->info('🏅 Page 3: 품질인정심사 더미 데이터 생성...'); + + $existingChecklist = DB::table('audit_checklists') + ->where('tenant_id', $tenantId) + ->where('year', 2026) + ->where('type', 'standard_manual') + ->first(); + + if ($existingChecklist) { + $this->command->info(' ⚠ audit_checklists: 이미 존재 (스킵)'); + } else { + // Q1 점검표 (in_progress) + $this->seedChecklist($tenantId, $userId, $now, 2026, 1, 'in_progress', '정기심사'); + // Q2 점검표 (draft) + $this->seedChecklist($tenantId, $userId, $now, 2026, 2, 'draft', '중간심사'); + } + + $this->command->info(''); + $this->command->info('🎉 품질 더미 데이터 생성 완료!'); + }); + } + + private function seedChecklist(int $tenantId, int $userId, Carbon $now, int $year, int $quarter, string $status, string $auditType): void + { + $checklistId = DB::table('audit_checklists')->insertGetId([ + 'tenant_id' => $tenantId, + 'year' => $year, + 'quarter' => $quarter, + 'type' => 'standard_manual', + 'status' => $status, + 'options' => json_encode([ + 'audit_date' => $year.'-'.str_pad($quarter * 3, 2, '0', STR_PAD_LEFT).'-15', + 'auditor' => '한국품질인증원', + 'audit_type' => $auditType, + ]), + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $this->command->info(' ✅ audit_checklists: '.$year.' Q'.$quarter.' ('.$status.') 생성'); + + $isActive = $status !== 'draft'; + + $categoriesData = [ + [ + 'title' => '품질경영 시스템', + 'sort_order' => 1, + 'items' => [ + ['name' => '품질방침 수립 및 공표', 'description' => '최고경영자의 품질방침 수립, 문서화 및 전직원 공표 여부', 'is_completed' => $isActive, + 'docs' => [['title' => '품질매뉴얼 Rev.5', 'version' => '5.0', 'date' => '2025-12-01'], ['title' => '품질방침 선언서', 'version' => '3.0', 'date' => '2025-06-15']]], + ['name' => '품질목표 설정 및 관리', 'description' => '연간 품질목표 설정, 실행계획 수립 및 주기적 모니터링', 'is_completed' => $isActive, + 'docs' => [['title' => $year.'년 품질목표 관리대장', 'version' => '1.0', 'date' => $year.'-01-05'], ['title' => '품질목표 관리절차서', 'version' => '2.1', 'date' => '2025-09-10']]], + ['name' => '내부심사 계획 및 실시', 'description' => '연간 내부심사 계획 수립 및 실시 기록', 'is_completed' => false, + 'docs' => [['title' => '내부심사 절차서', 'version' => '4.0', 'date' => '2025-03-20'], ['title' => $year.'년 내부심사 계획서', 'version' => '1.0', 'date' => $year.'-01-15'], ['title' => '내부심사 보고서 양식', 'version' => '2.0', 'date' => '2025-07-01']]], + ['name' => '경영검토 실시', 'description' => '최고경영자 주관 경영검토 실시 및 기록 유지', 'is_completed' => false, + 'docs' => [['title' => '경영검토 절차서', 'version' => '3.2', 'date' => '2025-08-01'], ['title' => '2025년 하반기 경영검토 회의록', 'version' => '1.0', 'date' => '2025-12-20']]], + ['name' => '문서 및 기록 관리 체계', 'description' => '품질문서 체계(매뉴얼, 절차서, 지침서) 수립 및 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '문서관리 절차서', 'version' => '4.5', 'date' => '2025-10-01'], ['title' => '기록관리 절차서', 'version' => '4.0', 'date' => '2025-03-10']]], + ], + ], + [ + 'title' => '설계 및 개발', + 'sort_order' => 2, + 'items' => [ + ['name' => '설계 입력 관리', 'description' => '설계 입력 요구사항 식별, 문서화 및 검토', 'is_completed' => $isActive, + 'docs' => [['title' => '설계관리 절차서', 'version' => '4.1', 'date' => '2025-10-15'], ['title' => '설계입력 검토서 양식', 'version' => '2.0', 'date' => '2025-05-20']]], + ['name' => '설계 출력 관리', 'description' => '설계 출력물 문서화 및 입력 요구사항 충족 확인', 'is_completed' => $isActive, + 'docs' => [['title' => '설계출력 검증서 양식', 'version' => '2.0', 'date' => '2025-06-15'], ['title' => '도면 관리기준서', 'version' => '3.0', 'date' => '2025-08-20']]], + ['name' => '설계 검증 및 유효성 확인', 'description' => '설계 출력물에 대한 검증/유효성 확인 절차 운영', 'is_completed' => false, + 'docs' => [['title' => '설계검증 절차서', 'version' => '3.0', 'date' => '2025-04-10'], ['title' => '설계유효성확인 체크리스트', 'version' => '1.5', 'date' => '2025-11-01'], ['title' => '시제품 시험성적서 양식', 'version' => '2.0', 'date' => '2025-06-30']]], + ['name' => '설계 변경 관리', 'description' => '설계 변경 요청, 승인 및 이력 관리', 'is_completed' => false, + 'docs' => [['title' => '설계변경 관리절차서', 'version' => '2.3', 'date' => '2025-07-15'], ['title' => '설계변경 요청서(ECR) 양식', 'version' => '1.0', 'date' => '2025-01-10']]], + ['name' => 'FMEA 및 위험 분석', 'description' => '설계 고장모드 영향분석(FMEA) 실시 및 관리', 'is_completed' => false, + 'docs' => [['title' => 'FMEA 절차서', 'version' => '2.0', 'date' => '2025-09-01'], ['title' => 'DFMEA 양식', 'version' => '1.5', 'date' => '2025-11-20']]], + ], + ], + [ + 'title' => '구매 관리', + 'sort_order' => 3, + 'items' => [ + ['name' => '협력업체 평가 및 선정', 'description' => '협력업체 초기평가, 정기평가 기준 및 실시 기록', 'is_completed' => $isActive, + 'docs' => [['title' => '협력업체 관리절차서', 'version' => '5.0', 'date' => '2025-09-01'], ['title' => '2025년 협력업체 평가결과', 'version' => '1.0', 'date' => '2025-12-15']]], + ['name' => '수입검사 절차', 'description' => '구매 자재 수입검사 기준 및 합/불합격 처리 절차', 'is_completed' => $isActive, + 'docs' => [['title' => '수입검사 절차서', 'version' => '3.1', 'date' => '2025-08-20'], ['title' => '수입검사 기준서', 'version' => '4.0', 'date' => '2025-11-10'], ['title' => '자재별 검사항목 목록', 'version' => '2.0', 'date' => '2025-10-01']]], + ['name' => '부적합 자재 처리', 'description' => '수입검사 불합격 자재의 격리, 반품, 특채 처리', 'is_completed' => false, + 'docs' => [['title' => '부적합품 관리절차서', 'version' => '3.0', 'date' => '2025-05-15'], ['title' => '특채 요청서 양식', 'version' => '1.2', 'date' => '2025-09-20']]], + ['name' => '구매문서 관리', 'description' => '구매 사양서, 발주서 등 구매문서 관리 체계', 'is_completed' => false, + 'docs' => [['title' => '구매관리 절차서', 'version' => '4.2', 'date' => '2025-07-01']]], + ['name' => '입고 및 자재 보관 관리', 'description' => '입고 검수, 자재 보관 조건 및 선입선출 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '자재관리 절차서', 'version' => '3.5', 'date' => '2025-08-01'], ['title' => '창고관리 기준서', 'version' => '2.0', 'date' => '2025-10-15']]], + ], + ], + [ + 'title' => '제조 공정 관리', + 'sort_order' => 4, + 'items' => [ + ['name' => '공정 관리 계획', 'description' => '제조 공정별 관리항목, 관리기준, 검사방법 수립', 'is_completed' => $isActive, + 'docs' => [['title' => '공정관리 절차서', 'version' => '4.0', 'date' => '2025-07-10'], ['title' => 'QC공정도', 'version' => '3.0', 'date' => '2025-09-15']]], + ['name' => '작업표준서 관리', 'description' => '공정별 작업표준서 작성, 배포 및 최신본 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '작업표준서 관리절차', 'version' => '2.5', 'date' => '2025-06-20'], ['title' => '방화셔터 조립 작업표준서', 'version' => '5.0', 'date' => '2025-11-01']]], + ['name' => '공정검사 실시', 'description' => '제조 공정 중 품질검사 기준 및 기록 관리', 'is_completed' => false, + 'docs' => [['title' => '공정검사 절차서', 'version' => '3.5', 'date' => '2025-10-20'], ['title' => '공정검사 체크시트', 'version' => '2.0', 'date' => '2025-11-15']]], + ['name' => '부적합품 관리', 'description' => '공정 중 발생한 부적합품 식별, 격리, 처리', 'is_completed' => false, + 'docs' => [['title' => '부적합품 관리절차서', 'version' => '3.0', 'date' => '2025-05-15'], ['title' => '부적합 처리대장 양식', 'version' => '2.0', 'date' => '2025-08-01']]], + ], + ], + [ + 'title' => '검사 및 시험', + 'sort_order' => 5, + 'items' => [ + ['name' => '최종검사 및 시험', 'description' => '완제품 출하 전 최종검사 기준 및 기록', 'is_completed' => false, + 'docs' => [['title' => '최종검사 절차서', 'version' => '4.0', 'date' => '2025-06-01'], ['title' => '방화셔터 시험성적서 양식', 'version' => '3.0', 'date' => '2025-08-10'], ['title' => '제품검사 기준서', 'version' => '5.1', 'date' => '2025-12-05']]], + ['name' => '검사·측정 장비 관리', 'description' => '검사장비 교정, 유지보수 계획 및 이력 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '계측기 관리절차서', 'version' => '2.8', 'date' => '2025-04-15'], ['title' => $year.'년 교정계획표', 'version' => '1.0', 'date' => $year.'-01-10']]], + ['name' => '시정 및 예방조치', 'description' => '부적합 발생 시 시정조치, 재발방지 및 예방조치 관리', 'is_completed' => false, + 'docs' => [['title' => '시정예방조치 절차서', 'version' => '3.3', 'date' => '2025-09-25'], ['title' => '시정조치 보고서 양식', 'version' => '2.0', 'date' => '2025-05-01']]], + ['name' => '품질기록 관리', 'description' => '품질기록 식별, 보관, 보호, 검색, 폐기 절차', 'is_completed' => $isActive, + 'docs' => [['title' => '기록관리 절차서', 'version' => '4.0', 'date' => '2025-03-10'], ['title' => '기록보존 기간표', 'version' => '2.5', 'date' => '2025-07-20']]], + ['name' => '출하 및 인도 관리', 'description' => '완제품 출하검사, 포장, 운송 및 인도 절차', 'is_completed' => false, + 'docs' => [['title' => '출하관리 절차서', 'version' => '3.0', 'date' => '2025-05-20'], ['title' => '포장 및 운송 기준서', 'version' => '2.5', 'date' => '2025-09-10']]], + ], + ], + [ + 'title' => '고객만족 및 지속적 개선', + 'sort_order' => 6, + 'items' => [ + ['name' => '고객 불만 처리', 'description' => '고객 불만 접수, 처리, 회신 및 재발방지 체계', 'is_completed' => $isActive, + 'docs' => [['title' => '고객불만 처리절차서', 'version' => '3.0', 'date' => '2025-04-01'], ['title' => '고객불만 처리대장 양식', 'version' => '2.0', 'date' => '2025-07-15']]], + ['name' => '고객만족도 조사', 'description' => '정기적 고객만족도 조사 실시 및 결과 분석', 'is_completed' => false, + 'docs' => [['title' => '고객만족도 조사절차서', 'version' => '2.0', 'date' => '2025-06-01'], ['title' => '2025년 고객만족도 조사결과', 'version' => '1.0', 'date' => '2025-12-30']]], + ['name' => '지속적 개선 활동', 'description' => '품질개선 과제 발굴, 실행 및 효과 확인', 'is_completed' => false, + 'docs' => [['title' => '지속적개선 절차서', 'version' => '2.5', 'date' => '2025-08-15'], ['title' => '개선활동 보고서 양식', 'version' => '1.5', 'date' => '2025-10-20']]], + ], + ], + ]; + + $categoryCount = 0; + $itemCount = 0; + $docCount = 0; + + foreach ($categoriesData as $catData) { + $categoryId = DB::table('audit_checklist_categories')->insertGetId([ + 'tenant_id' => $tenantId, + 'checklist_id' => $checklistId, + 'title' => $catData['title'], + 'sort_order' => $catData['sort_order'], + 'options' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $categoryCount++; + + foreach ($catData['items'] as $itemIdx => $itemData) { + $completedAt = $itemData['is_completed'] ? $now->copy()->subDays(rand(1, 15)) : null; + + $itemId = DB::table('audit_checklist_items')->insertGetId([ + 'tenant_id' => $tenantId, + 'category_id' => $categoryId, + 'name' => $itemData['name'], + 'description' => $itemData['description'], + 'is_completed' => $itemData['is_completed'], + 'completed_at' => $completedAt, + 'completed_by' => $itemData['is_completed'] ? $userId : null, + 'sort_order' => $itemIdx + 1, + 'options' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $itemCount++; + + foreach ($itemData['docs'] as $docData) { + DB::table('audit_standard_documents')->insert([ + 'tenant_id' => $tenantId, + 'checklist_item_id' => $itemId, + 'title' => $docData['title'], + 'version' => $docData['version'], + 'date' => $docData['date'], + 'document_id' => null, + 'options' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $docCount++; + } + } + } + + $this->command->info(' ✅ audit_checklist_categories: '.$categoryCount.'개 생성'); + $this->command->info(' ✅ audit_checklist_items: '.$itemCount.'개 생성'); + $this->command->info(' ✅ audit_standard_documents: '.$docCount.'개 생성'); + } +} diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index edebe9d..5edb7ca 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -7,7 +7,9 @@ * - 실적신고 */ +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; use Illuminate\Support\Facades\Route; @@ -38,3 +40,25 @@ Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm'); Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo'); }); + +// QMS 로트 추적 심사 +Route::prefix('qms/lot-audit')->group(function () { + Route::get('/reports', [QmsLotAuditController::class, 'index'])->name('v1.qms.lot-audit.reports'); + Route::get('/reports/{id}', [QmsLotAuditController::class, 'show'])->whereNumber('id')->name('v1.qms.lot-audit.reports.show'); + Route::get('/routes/{id}/documents', [QmsLotAuditController::class, 'routeDocuments'])->whereNumber('id')->name('v1.qms.lot-audit.routes.documents'); + 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'); +});