From a6e29bc1f361dfdffae0c8e08261784e9793da08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 20:29:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=ED=92=88=EC=A7=88=EA=B4=80=EB=A6=AC]?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=20API=20=EA=B5=AC=ED=98=84=20-?= =?UTF-8?q?=20=ED=92=88=EC=A7=88=EA=B4=80=EB=A6=AC=EC=84=9C=20+=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=81=EC=8B=A0=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품질관리서(quality_documents) CRUD API 14개 엔드포인트 - 실적신고(performance_reports) 관리 API 6개 엔드포인트 - DB 마이그레이션 4개 테이블 (quality_documents, quality_document_orders, quality_document_locations, performance_reports) - 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 - stats() ambiguous column 버그 수정 (JOIN 시 테이블 접두사 추가) - missing() status_code 컬럼명/값 수정 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/PerformanceReportController.php | 59 ++ .../Api/V1/QualityDocumentController.php | 127 +++ .../PerformanceReportConfirmRequest.php | 29 + .../Quality/PerformanceReportMemoRequest.php | 30 + .../Quality/QualityDocumentStoreRequest.php | 43 + .../Quality/QualityDocumentUpdateRequest.php | 36 + app/Models/Qualitys/PerformanceReport.php | 76 ++ app/Models/Qualitys/QualityDocument.php | 131 +++ .../Qualitys/QualityDocumentLocation.php | 57 ++ app/Models/Qualitys/QualityDocumentOrder.php | 31 + app/Services/PerformanceReportService.php | 258 ++++++ app/Services/QualityDocumentService.php | 748 ++++++++++++++++++ ..._180001_create_quality_documents_table.php | 39 + ...2_create_quality_document_orders_table.php | 25 + ...reate_quality_document_locations_table.php | 31 + ...80004_create_performance_reports_table.php | 37 + lang/ko/error.php | 9 + routes/api.php | 1 + routes/api/v1/quality.php | 40 + 19 files changed, 1807 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/PerformanceReportController.php create mode 100644 app/Http/Controllers/Api/V1/QualityDocumentController.php create mode 100644 app/Http/Requests/Quality/PerformanceReportConfirmRequest.php create mode 100644 app/Http/Requests/Quality/PerformanceReportMemoRequest.php create mode 100644 app/Http/Requests/Quality/QualityDocumentStoreRequest.php create mode 100644 app/Http/Requests/Quality/QualityDocumentUpdateRequest.php create mode 100644 app/Models/Qualitys/PerformanceReport.php create mode 100644 app/Models/Qualitys/QualityDocument.php create mode 100644 app/Models/Qualitys/QualityDocumentLocation.php create mode 100644 app/Models/Qualitys/QualityDocumentOrder.php create mode 100644 app/Services/PerformanceReportService.php create mode 100644 app/Services/QualityDocumentService.php create mode 100644 database/migrations/2026_03_05_180001_create_quality_documents_table.php create mode 100644 database/migrations/2026_03_05_180002_create_quality_document_orders_table.php create mode 100644 database/migrations/2026_03_05_180003_create_quality_document_locations_table.php create mode 100644 database/migrations/2026_03_05_180004_create_performance_reports_table.php create mode 100644 routes/api/v1/quality.php diff --git a/app/Http/Controllers/Api/V1/PerformanceReportController.php b/app/Http/Controllers/Api/V1/PerformanceReportController.php new file mode 100644 index 0000000..75d40ea --- /dev/null +++ b/app/Http/Controllers/Api/V1/PerformanceReportController.php @@ -0,0 +1,59 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function confirm(PerformanceReportConfirmRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->confirm($request->validated()['ids']); + }, __('message.updated')); + } + + public function unconfirm(PerformanceReportConfirmRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->unconfirm($request->validated()['ids']); + }, __('message.updated')); + } + + public function updateMemo(PerformanceReportMemoRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $request->validated(); + + return $this->service->updateMemo($data['ids'], $data['memo']); + }, __('message.updated')); + } + + public function missing(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->missing($request->all()); + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/QualityDocumentController.php b/app/Http/Controllers/Api/V1/QualityDocumentController.php new file mode 100644 index 0000000..82cb42f --- /dev/null +++ b/app/Http/Controllers/Api/V1/QualityDocumentController.php @@ -0,0 +1,127 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function calendar(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->calendar($request->all()); + }, __('message.fetched')); + } + + public function availableOrders(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->availableOrders($request->all()); + }, __('message.fetched')); + } + + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + public function store(QualityDocumentStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + public function update(QualityDocumentUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.deleted')); + } + + public function complete(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->complete($id); + }, __('message.updated')); + } + + public function attachOrders(Request $request, int $id) + { + $request->validate([ + 'order_ids' => ['required', 'array', 'min:1'], + 'order_ids.*' => ['required', 'integer'], + ]); + + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->attachOrders($id, $request->input('order_ids')); + }, __('message.updated')); + } + + public function detachOrder(int $id, int $orderId) + { + return ApiResponse::handle(function () use ($id, $orderId) { + return $this->service->detachOrder($id, $orderId); + }, __('message.updated')); + } + + public function inspectLocation(Request $request, int $id, int $locId) + { + $request->validate([ + 'post_width' => ['nullable', 'integer'], + 'post_height' => ['nullable', 'integer'], + 'change_reason' => ['nullable', 'string', 'max:500'], + 'inspection_status' => ['nullable', 'string', 'in:pending,completed'], + ]); + + return ApiResponse::handle(function () use ($request, $id, $locId) { + return $this->service->inspectLocation($id, $locId, $request->all()); + }, __('message.updated')); + } + + public function requestDocument(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->requestDocument($id); + }, __('message.fetched')); + } + + public function resultDocument(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->resultDocument($id); + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php b/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php new file mode 100644 index 0000000..de0b74b --- /dev/null +++ b/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php @@ -0,0 +1,29 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:performance_reports,id'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']), + 'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]), + ]; + } +} diff --git a/app/Http/Requests/Quality/PerformanceReportMemoRequest.php b/app/Http/Requests/Quality/PerformanceReportMemoRequest.php new file mode 100644 index 0000000..76fab6d --- /dev/null +++ b/app/Http/Requests/Quality/PerformanceReportMemoRequest.php @@ -0,0 +1,30 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:performance_reports,id'], + 'memo' => ['required', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']), + 'memo.required' => __('validation.required', ['attribute' => '메모']), + ]; + } +} diff --git a/app/Http/Requests/Quality/QualityDocumentStoreRequest.php b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php new file mode 100644 index 0000000..ae450f4 --- /dev/null +++ b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php @@ -0,0 +1,43 @@ + ['required', 'string', 'max:200'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'inspector_id' => ['nullable', 'integer', 'exists:users,id'], + 'received_date' => ['nullable', 'date'], + 'options' => ['nullable', 'array'], + 'options.manager' => ['nullable', 'array'], + 'options.manager.name' => ['nullable', 'string', 'max:50'], + 'options.manager.phone' => ['nullable', 'string', 'max:30'], + 'options.inspection' => ['nullable', 'array'], + 'options.inspection.request_date' => ['nullable', 'date'], + 'options.inspection.start_date' => ['nullable', 'date'], + 'options.inspection.end_date' => ['nullable', 'date'], + 'options.site_address' => ['nullable', 'array'], + 'options.construction_site' => ['nullable', 'array'], + 'options.material_distributor' => ['nullable', 'array'], + 'options.contractor' => ['nullable', 'array'], + 'options.supervisor' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'site_name.required' => __('validation.required', ['attribute' => '현장명']), + ]; + } +} diff --git a/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php new file mode 100644 index 0000000..58c7a93 --- /dev/null +++ b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php @@ -0,0 +1,36 @@ + ['sometimes', 'string', 'max:200'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'inspector_id' => ['nullable', 'integer', 'exists:users,id'], + 'received_date' => ['nullable', 'date'], + 'options' => ['nullable', 'array'], + 'options.manager' => ['nullable', 'array'], + 'options.manager.name' => ['nullable', 'string', 'max:50'], + 'options.manager.phone' => ['nullable', 'string', 'max:30'], + 'options.inspection' => ['nullable', 'array'], + 'options.inspection.request_date' => ['nullable', 'date'], + 'options.inspection.start_date' => ['nullable', 'date'], + 'options.inspection.end_date' => ['nullable', 'date'], + 'options.site_address' => ['nullable', 'array'], + 'options.construction_site' => ['nullable', 'array'], + 'options.material_distributor' => ['nullable', 'array'], + 'options.contractor' => ['nullable', 'array'], + 'options.supervisor' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Models/Qualitys/PerformanceReport.php b/app/Models/Qualitys/PerformanceReport.php new file mode 100644 index 0000000..3c03cc9 --- /dev/null +++ b/app/Models/Qualitys/PerformanceReport.php @@ -0,0 +1,76 @@ + 'date', + 'year' => 'integer', + 'quarter' => 'integer', + ]; + + // ===== Relationships ===== + + public function qualityDocument() + { + return $this->belongsTo(QualityDocument::class); + } + + public function confirmer() + { + return $this->belongsTo(User::class, 'confirmed_by'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ===== Status Helpers ===== + + public function isUnconfirmed(): bool + { + return $this->confirmation_status === self::STATUS_UNCONFIRMED; + } + + public function isConfirmed(): bool + { + return $this->confirmation_status === self::STATUS_CONFIRMED; + } + + public function isReported(): bool + { + return $this->confirmation_status === self::STATUS_REPORTED; + } +} diff --git a/app/Models/Qualitys/QualityDocument.php b/app/Models/Qualitys/QualityDocument.php new file mode 100644 index 0000000..bcbc104 --- /dev/null +++ b/app/Models/Qualitys/QualityDocument.php @@ -0,0 +1,131 @@ + 'array', + 'received_date' => 'date', + ]; + + // ===== Relationships ===== + + public function client() + { + return $this->belongsTo(Client::class, 'client_id'); + } + + public function inspector() + { + return $this->belongsTo(User::class, 'inspector_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function documentOrders() + { + return $this->hasMany(QualityDocumentOrder::class); + } + + public function locations() + { + return $this->hasMany(QualityDocumentLocation::class); + } + + public function performanceReport() + { + return $this->hasOne(PerformanceReport::class); + } + + // ===== 채번 ===== + + public static function generateDocNumber(int $tenantId): string + { + $prefix = 'KD-QD'; + $yearMonth = now()->format('Ym'); + + $lastNo = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%") + ->orderByDesc('quality_doc_number') + ->value('quality_doc_number'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq); + } + + // ===== Status Helpers ===== + + public function isReceived(): bool + { + return $this->status === self::STATUS_RECEIVED; + } + + public function isInProgress(): bool + { + return $this->status === self::STATUS_IN_PROGRESS; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public static function mapStatusToFrontend(string $status): string + { + return match ($status) { + self::STATUS_RECEIVED => 'reception', + self::STATUS_IN_PROGRESS => 'in_progress', + self::STATUS_COMPLETED => 'completed', + default => $status, + }; + } + + public static function mapStatusFromFrontend(string $status): string + { + return match ($status) { + 'reception' => self::STATUS_RECEIVED, + default => $status, + }; + } +} diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php new file mode 100644 index 0000000..1f7cecd --- /dev/null +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -0,0 +1,57 @@ +belongsTo(QualityDocument::class); + } + + public function qualityDocumentOrder() + { + return $this->belongsTo(QualityDocumentOrder::class); + } + + public function orderItem() + { + return $this->belongsTo(OrderItem::class); + } + + public function document() + { + return $this->belongsTo(Document::class); + } + + public function isPending(): bool + { + return $this->inspection_status === self::STATUS_PENDING; + } + + public function isCompleted(): bool + { + return $this->inspection_status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/Qualitys/QualityDocumentOrder.php b/app/Models/Qualitys/QualityDocumentOrder.php new file mode 100644 index 0000000..4d804f7 --- /dev/null +++ b/app/Models/Qualitys/QualityDocumentOrder.php @@ -0,0 +1,31 @@ +belongsTo(QualityDocument::class); + } + + public function order() + { + return $this->belongsTo(Order::class); + } + + public function locations() + { + return $this->hasMany(QualityDocumentLocation::class); + } +} diff --git a/app/Services/PerformanceReportService.php b/app/Services/PerformanceReportService.php new file mode 100644 index 0000000..80b343b --- /dev/null +++ b/app/Services/PerformanceReportService.php @@ -0,0 +1,258 @@ +tenantId(); + $perPage = (int) ($params['per_page'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $year = $params['year'] ?? null; + $quarter = $params['quarter'] ?? null; + $confirmStatus = $params['confirm_status'] ?? null; + + $query = PerformanceReport::query() + ->where('performance_reports.tenant_id', $tenantId) + ->with(['qualityDocument.client', 'qualityDocument.locations', 'confirmer:id,name']); + + if ($q !== '') { + $query->whereHas('qualityDocument', function ($qq) use ($q) { + $qq->where('quality_doc_number', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + if ($year !== null) { + $query->where('year', $year); + } + if ($quarter !== null) { + $query->where('quarter', $quarter); + } + if ($confirmStatus !== null) { + $query->where('confirmation_status', $confirmStatus); + } + + $query->orderByDesc('performance_reports.id'); + $paginated = $query->paginate($perPage); + + $transformedData = $paginated->getCollection()->map(fn ($report) => $this->transformToFrontend($report)); + + return [ + 'items' => $transformedData, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 통계 조회 + */ + public function stats(array $params = []): array + { + $tenantId = $this->tenantId(); + + $query = PerformanceReport::where('performance_reports.tenant_id', $tenantId); + + if (! empty($params['year'])) { + $query->where('performance_reports.year', $params['year']); + } + if (! empty($params['quarter'])) { + $query->where('performance_reports.quarter', $params['quarter']); + } + + $counts = (clone $query) + ->select('confirmation_status', DB::raw('count(*) as count')) + ->groupBy('confirmation_status') + ->pluck('count', 'confirmation_status') + ->toArray(); + + $totalLocations = (clone $query) + ->join('quality_documents', 'quality_documents.id', '=', 'performance_reports.quality_document_id') + ->join('quality_document_locations', 'quality_document_locations.quality_document_id', '=', 'quality_documents.id') + ->count('quality_document_locations.id'); + + return [ + 'total_count' => array_sum($counts), + 'confirmed_count' => $counts[PerformanceReport::STATUS_CONFIRMED] ?? 0, + 'unconfirmed_count' => $counts[PerformanceReport::STATUS_UNCONFIRMED] ?? 0, + 'total_locations' => $totalLocations, + ]; + } + + /** + * 일괄 확정 + */ + public function confirm(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + $reports = PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->with(['qualityDocument']) + ->get(); + + $errors = []; + foreach ($reports as $report) { + if ($report->isConfirmed() || $report->isReported()) { + continue; + } + + // 필수정보 검증 + $requiredInfo = $this->qualityDocumentService->calculateRequiredInfo($report->qualityDocument); + if ($requiredInfo !== '완료') { + $errors[] = [ + 'id' => $report->id, + 'quality_doc_number' => $report->qualityDocument->quality_doc_number, + 'reason' => $requiredInfo, + ]; + + continue; + } + + $report->update([ + 'confirmation_status' => PerformanceReport::STATUS_CONFIRMED, + 'confirmed_date' => now()->toDateString(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); + } + + if (! empty($errors)) { + throw new BadRequestHttpException(json_encode([ + 'message' => __('error.quality.confirm_failed'), + 'errors' => $errors, + ])); + } + + return ['confirmed_count' => count($ids) - count($errors)]; + }); + } + + /** + * 일괄 확정 해제 + */ + public function unconfirm(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED) + ->update([ + 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, + 'confirmed_date' => null, + 'confirmed_by' => null, + 'updated_by' => $userId, + ]); + + return ['unconfirmed_count' => count($ids)]; + }); + } + + /** + * 일괄 메모 업데이트 + */ + public function updateMemo(array $ids, string $memo) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'memo' => $memo, + 'updated_by' => $userId, + ]); + + return ['updated_count' => count($ids)]; + } + + /** + * 누락체크 (출고완료 but 제품검사 미등록) + */ + public function missing(array $params): array + { + $tenantId = $this->tenantId(); + + // 품질관리서가 등록된 수주 ID + $registeredOrderIds = DB::table('quality_document_orders') + ->join('quality_documents', 'quality_documents.id', '=', 'quality_document_orders.quality_document_id') + ->where('quality_documents.tenant_id', $tenantId) + ->pluck('quality_document_orders.order_id'); + + // 출고완료 상태이지만 품질관리서 미등록 수주 + $query = DB::table('orders') + ->where('tenant_id', $tenantId) + ->whereNotIn('id', $registeredOrderIds) + ->where('status_code', 'SHIPPED'); // TODO: 출고완료 상태 추가 시 상수 확인 + + if (! empty($params['year'])) { + $query->whereYear('created_at', $params['year']); + } + if (! empty($params['quarter'])) { + $quarter = (int) $params['quarter']; + $startMonth = ($quarter - 1) * 3 + 1; + $endMonth = $quarter * 3; + $query->whereMonth('created_at', '>=', $startMonth) + ->whereMonth('created_at', '<=', $endMonth); + } + + return $query->orderByDesc('id') + ->limit(100) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'order_number' => $order->order_no ?? '', + 'site_name' => $order->site_name ?? '', + 'client' => '', // 별도 조인 필요 + 'delivery_date' => $order->delivery_date ?? '', + ]) + ->toArray(); + } + + /** + * DB → 프론트엔드 변환 + */ + private function transformToFrontend(PerformanceReport $report): array + { + $doc = $report->qualityDocument; + + return [ + 'id' => $report->id, + 'quality_doc_number' => $doc?->quality_doc_number ?? '', + 'created_date' => $report->created_at?->format('Y-m-d') ?? '', + 'site_name' => $doc?->site_name ?? '', + 'client' => $doc?->client?->name ?? '', + 'location_count' => $doc?->locations?->count() ?? 0, + 'required_info' => $doc ? $this->qualityDocumentService->calculateRequiredInfo($doc) : '', + 'confirm_status' => $report->confirmation_status === PerformanceReport::STATUS_CONFIRMED ? 'confirmed' : 'unconfirmed', + 'confirm_date' => $report->confirmed_date?->format('Y-m-d'), + 'memo' => $report->memo ?? '', + 'year' => $report->year, + 'quarter' => $report->quarter, + ]; + } +} diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php new file mode 100644 index 0000000..66af795 --- /dev/null +++ b/app/Services/QualityDocumentService.php @@ -0,0 +1,748 @@ +tenantId(); + $perPage = (int) ($params['per_page'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $status = $params['status'] ?? null; + $dateFrom = $params['date_from'] ?? null; + $dateTo = $params['date_to'] ?? null; + + $query = QualityDocument::query() + ->where('tenant_id', $tenantId) + ->with(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations']); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('quality_doc_number', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + if ($status !== null) { + $dbStatus = QualityDocument::mapStatusFromFrontend($status); + $query->where('status', $dbStatus); + } + + if ($dateFrom !== null) { + $query->where('received_date', '>=', $dateFrom); + } + if ($dateTo !== null) { + $query->where('received_date', '<=', $dateTo); + } + + $query->orderByDesc('id'); + $paginated = $query->paginate($perPage); + + $transformedData = $paginated->getCollection()->map(fn ($doc) => $this->transformToFrontend($doc)); + + return [ + 'items' => $transformedData, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 통계 조회 + */ + public function stats(array $params = []): array + { + $tenantId = $this->tenantId(); + + $query = QualityDocument::where('tenant_id', $tenantId); + + if (! empty($params['date_from'])) { + $query->where('received_date', '>=', $params['date_from']); + } + if (! empty($params['date_to'])) { + $query->where('received_date', '<=', $params['date_to']); + } + + $counts = (clone $query) + ->select('status', DB::raw('count(*) as count')) + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + return [ + 'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0, + 'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0, + 'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0, + ]; + } + + /** + * 캘린더 스케줄 조회 + */ + public function calendar(array $params): array + { + $tenantId = $this->tenantId(); + $year = (int) ($params['year'] ?? now()->year); + $month = (int) ($params['month'] ?? now()->month); + + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = date('Y-m-t', strtotime($startDate)); + + $query = QualityDocument::query() + ->where('tenant_id', $tenantId) + ->with(['inspector:id,name']); + + // options JSON 내 inspection.start_date / inspection.end_date 기준 필터링 + // received_date 기준으로 범위 필터 (options JSON은 직접 SQL 필터 어려우므로) + $query->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('received_date', [$startDate, $endDate]); + }); + + if (! empty($params['status'])) { + $dbStatus = QualityDocument::mapStatusFromFrontend($params['status']); + $query->where('status', $dbStatus); + } + + return $query->orderBy('received_date') + ->get() + ->map(function (QualityDocument $doc) { + $options = $doc->options ?? []; + $inspection = $options['inspection'] ?? []; + + return [ + 'id' => $doc->id, + 'start_date' => $inspection['start_date'] ?? $doc->received_date?->format('Y-m-d'), + 'end_date' => $inspection['end_date'] ?? $doc->received_date?->format('Y-m-d'), + 'inspector' => $doc->inspector?->name ?? '', + 'site_name' => $doc->site_name, + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + ]; + }) + ->values() + ->toArray(); + } + + /** + * 단건 조회 + */ + public function show(int $id) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'creator:id,name', + 'documentOrders.order', + 'locations.orderItem.node', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $this->transformToFrontend($doc, true); + } + + /** + * 생성 + */ + public function store(array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $data['tenant_id'] = $tenantId; + $data['quality_doc_number'] = QualityDocument::generateDocNumber($tenantId); + $data['status'] = QualityDocument::STATUS_RECEIVED; + $data['created_by'] = $userId; + + $doc = QualityDocument::create($data); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'created', + null, + $doc->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); + }); + } + + /** + * 수정 + */ + public function update(int $id, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $beforeData = $doc->toArray(); + + return DB::transaction(function () use ($doc, $data, $userId, $beforeData) { + $data['updated_by'] = $userId; + + // options는 기존 값과 병합 + if (isset($data['options'])) { + $existingOptions = $doc->options ?? []; + $data['options'] = array_replace_recursive($existingOptions, $data['options']); + } + + $doc->update($data); + + $this->auditLogger->log( + $doc->tenant_id, + self::AUDIT_TARGET, + $doc->id, + 'updated', + $beforeData, + $doc->fresh()->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations'])); + }); + } + + /** + * 삭제 + */ + public function destroy(int $id) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_delete_completed')); + } + + $beforeData = $doc->toArray(); + $doc->deleted_by = $this->apiUserId(); + $doc->save(); + $doc->delete(); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'deleted', + $beforeData, + null + ); + + return 'success'; + } + + /** + * 검사 완료 처리 + */ + public function complete(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with(['locations']) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.already_completed')); + } + + // 미완료 개소 확인 + $pendingCount = $doc->locations->where('inspection_status', QualityDocumentLocation::STATUS_PENDING)->count(); + if ($pendingCount > 0) { + throw new BadRequestHttpException(__('error.quality.pending_locations', ['count' => $pendingCount])); + } + + $beforeData = $doc->toArray(); + + return DB::transaction(function () use ($doc, $userId, $tenantId, $beforeData) { + $doc->update([ + 'status' => QualityDocument::STATUS_COMPLETED, + 'updated_by' => $userId, + ]); + + // 실적신고 자동 생성 + $now = now(); + PerformanceReport::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'quality_document_id' => $doc->id, + ], + [ + 'year' => $now->year, + 'quarter' => (int) ceil($now->month / 3), + 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, + 'created_by' => $userId, + ] + ); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'completed', + $beforeData, + $doc->fresh()->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); + }); + } + + /** + * 검사 미등록 수주 목록 + */ + public function availableOrders(array $params): array + { + $tenantId = $this->tenantId(); + $q = trim((string) ($params['q'] ?? '')); + + // 이미 연결된 수주 ID 목록 + $linkedOrderIds = QualityDocumentOrder::whereHas('qualityDocument', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId); + })->pluck('order_id'); + + $query = Order::where('tenant_id', $tenantId) + ->whereNotIn('id', $linkedOrderIds) + ->with(['items']) + ->withCount('items'); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('order_no', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + return $query->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'order_number' => $order->order_no, + 'site_name' => $order->site_name ?? '', + 'delivery_date' => $order->delivery_date ?? '', + 'location_count' => $order->items_count, + ]) + ->toArray(); + } + + /** + * 수주 연결 + */ + public function attachOrders(int $docId, array $orderIds) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($doc, $orderIds, $tenantId) { + foreach ($orderIds as $orderId) { + $order = Order::where('tenant_id', $tenantId)->find($orderId); + if (! $order) { + continue; + } + + // 중복 체크 + $docOrder = QualityDocumentOrder::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'order_id' => $orderId, + ]); + + // 수주 연결 시 개소(order_items)를 locations에 자동 생성 + $orderItems = OrderItem::where('order_id', $orderId)->get(); + foreach ($orderItems as $item) { + QualityDocumentLocation::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'quality_document_order_id' => $docOrder->id, + 'order_item_id' => $item->id, + ]); + } + } + + // 상태를 진행중으로 변경 (접수 상태일 때) + if ($doc->isReceived()) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } + + return $this->transformToFrontend( + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) + ); + }); + } + + /** + * 수주 연결 해제 + */ + public function detachOrder(int $docId, int $orderId) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); + } + + $docOrder = QualityDocumentOrder::where('quality_document_id', $docId) + ->where('order_id', $orderId) + ->first(); + + if ($docOrder) { + // 해당 수주의 locations 삭제 + QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); + $docOrder->delete(); + } + + return $this->transformToFrontend( + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) + ); + } + + /** + * 필수정보 계산 + */ + public function calculateRequiredInfo(QualityDocument $doc): string + { + $options = $doc->options ?? []; + $missing = 0; + + $sections = [ + 'construction_site' => ['name', 'land_location', 'lot_number'], + 'material_distributor' => ['company', 'address', 'ceo', 'phone'], + 'contractor' => ['company', 'address', 'name', 'phone'], + 'supervisor' => ['office', 'address', 'name', 'phone'], + ]; + + foreach ($sections as $section => $fields) { + $data = $options[$section] ?? []; + foreach ($fields as $field) { + if (empty($data[$field])) { + $missing++; + break; // 섹션 단위 + } + } + } + + return $missing === 0 ? '완료' : "{$missing}건 누락"; + } + + /** + * DB → 프론트엔드 변환 + */ + private function transformToFrontend(QualityDocument $doc, bool $detail = false): array + { + $options = $doc->options ?? []; + + $result = [ + 'id' => $doc->id, + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'location_count' => $doc->locations?->count() ?? 0, + 'required_info' => $this->calculateRequiredInfo($doc), + 'inspection_period' => $this->formatInspectionPeriod($options), + 'inspector' => $doc->inspector?->name ?? '', + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + 'author' => $doc->creator?->name ?? '', + 'reception_date' => $doc->received_date?->format('Y-m-d'), + 'manager' => $options['manager']['name'] ?? '', + 'manager_contact' => $options['manager']['phone'] ?? '', + ]; + + if ($detail) { + $result['construction_site'] = [ + 'site_name' => $options['construction_site']['name'] ?? '', + 'land_location' => $options['construction_site']['land_location'] ?? '', + 'lot_number' => $options['construction_site']['lot_number'] ?? '', + ]; + $result['material_distributor'] = [ + 'company_name' => $options['material_distributor']['company'] ?? '', + 'company_address' => $options['material_distributor']['address'] ?? '', + 'representative_name' => $options['material_distributor']['ceo'] ?? '', + 'phone' => $options['material_distributor']['phone'] ?? '', + ]; + $result['constructor_info'] = [ + 'company_name' => $options['contractor']['company'] ?? '', + 'company_address' => $options['contractor']['address'] ?? '', + 'name' => $options['contractor']['name'] ?? '', + 'phone' => $options['contractor']['phone'] ?? '', + ]; + $result['supervisor'] = [ + 'office_name' => $options['supervisor']['office'] ?? '', + 'office_address' => $options['supervisor']['address'] ?? '', + 'name' => $options['supervisor']['name'] ?? '', + 'phone' => $options['supervisor']['phone'] ?? '', + ]; + $result['schedule_info'] = [ + 'visit_request_date' => $options['inspection']['request_date'] ?? '', + 'start_date' => $options['inspection']['start_date'] ?? '', + 'end_date' => $options['inspection']['end_date'] ?? '', + 'inspector' => $doc->inspector?->name ?? '', + 'site_postal_code' => $options['site_address']['postal_code'] ?? '', + 'site_address' => $options['site_address']['address'] ?? '', + 'site_address_detail' => $options['site_address']['detail'] ?? '', + ]; + + // 개소 목록 + $result['order_items'] = $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + return [ + 'id' => (string) $loc->id, + 'order_number' => $order?->order_no ?? '', + 'site_name' => $order?->site_name ?? '', + 'delivery_date' => $order?->delivery_date ?? '', + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'construction_width' => $loc->post_width ?? 0, + 'construction_height' => $loc->post_height ?? 0, + 'change_reason' => $loc->change_reason ?? '', + ]; + })->toArray(); + } + + return $result; + } + + /** + * 개소별 검사 저장 (시공후 규격 + 검사 성적서) + */ + public function inspectLocation(int $docId, int $locId, array $data) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); + } + + $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); + if (! $location) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($location, $data, $doc) { + $updateData = []; + + if (isset($data['post_width'])) { + $updateData['post_width'] = $data['post_width']; + } + if (isset($data['post_height'])) { + $updateData['post_height'] = $data['post_height']; + } + if (isset($data['change_reason'])) { + $updateData['change_reason'] = $data['change_reason']; + } + if (isset($data['inspection_status'])) { + $updateData['inspection_status'] = $data['inspection_status']; + } + + if (! empty($updateData)) { + $location->update($updateData); + } + + // 상태를 진행중으로 변경 (접수 상태일 때) + if ($doc->isReceived()) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } + + return $location->fresh()->toArray(); + }); + } + + /** + * 검사제품요청서 데이터 (PDF/프린트용) + */ + public function requestDocument(int $id): array + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'documentOrders.order', + 'locations.orderItem.node', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $options = $doc->options ?? []; + + return [ + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'received_date' => $doc->received_date?->format('Y-m-d'), + 'inspector' => $doc->inspector?->name ?? '', + 'construction_site' => $options['construction_site'] ?? [], + 'material_distributor' => $options['material_distributor'] ?? [], + 'contractor' => $options['contractor'] ?? [], + 'supervisor' => $options['supervisor'] ?? [], + 'inspection' => $options['inspection'] ?? [], + 'site_address' => $options['site_address'] ?? [], + 'manager' => $options['manager'] ?? [], + 'items' => $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + return [ + 'order_number' => $order?->order_no ?? '', + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'item_name' => $orderItem?->item_name ?? '', + 'specification' => $orderItem?->specification ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'quantity' => $orderItem?->quantity ?? 1, + ]; + })->toArray(), + ]; + } + + /** + * 제품검사성적서 데이터 (documents EAV 연동) + */ + public function resultDocument(int $id): array + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'locations.orderItem.node', + 'locations.document.data', + 'locations.document.template', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $options = $doc->options ?? []; + + return [ + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'inspector' => $doc->inspector?->name ?? '', + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + 'locations' => $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $document = $loc->document; + + $result = [ + 'id' => $loc->id, + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'post_width' => $loc->post_width, + 'post_height' => $loc->post_height, + 'change_reason' => $loc->change_reason, + 'inspection_status' => $loc->inspection_status, + 'document_id' => $loc->document_id, + ]; + + // EAV 문서 데이터가 있으면 포함 + if ($document) { + $result['document'] = [ + 'id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'template_id' => $document->template_id, + 'data' => $document->data?->map(fn ($d) => [ + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + ])->toArray() ?? [], + ]; + } + + return $result; + })->toArray(), + ]; + } + + private function formatInspectionPeriod(array $options): string + { + $inspection = $options['inspection'] ?? []; + $start = $inspection['start_date'] ?? ''; + $end = $inspection['end_date'] ?? ''; + + if ($start && $end) { + return "{$start}~{$end}"; + } + + return $start ?: $end ?: ''; + } +} diff --git a/database/migrations/2026_03_05_180001_create_quality_documents_table.php b/database/migrations/2026_03_05_180001_create_quality_documents_table.php new file mode 100644 index 0000000..3f5fa13 --- /dev/null +++ b/database/migrations/2026_03_05_180001_create_quality_documents_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->string('quality_doc_number', 30)->comment('품질관리서 번호'); + $table->string('site_name')->comment('현장명'); + $table->string('status', 20)->default('received')->comment('received/in_progress/completed'); + $table->foreignId('client_id')->nullable()->constrained('clients')->comment('수주처'); + $table->foreignId('inspector_id')->nullable()->constrained('users')->comment('검사자'); + $table->date('received_date')->nullable()->comment('접수일'); + $table->json('options')->nullable()->comment('관련자정보, 검사정보, 현장주소 등'); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'quality_doc_number']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'client_id']); + $table->index(['tenant_id', 'inspector_id']); + $table->index(['tenant_id', 'received_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_documents'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php b/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php new file mode 100644 index 0000000..2b9e6b2 --- /dev/null +++ b/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('quality_document_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->constrained('orders'); + $table->timestamps(); + + $table->unique(['quality_document_id', 'order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_document_orders'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php b/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php new file mode 100644 index 0000000..a3b8ba0 --- /dev/null +++ b/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('quality_document_id')->constrained()->cascadeOnDelete(); + $table->foreignId('quality_document_order_id')->constrained('quality_document_orders', 'id', 'qdl_qdo_id_fk')->cascadeOnDelete(); + $table->foreignId('order_item_id')->constrained('order_items'); + $table->integer('post_width')->nullable()->comment('시공후 가로'); + $table->integer('post_height')->nullable()->comment('시공후 세로'); + $table->string('change_reason')->nullable()->comment('규격 변경사유'); + $table->foreignId('document_id')->nullable()->comment('검사성적서 문서 ID'); + $table->string('inspection_status', 20)->default('pending')->comment('pending/completed'); + $table->timestamps(); + + $table->index(['quality_document_id', 'inspection_status'], 'qdl_doc_id_status_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_document_locations'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180004_create_performance_reports_table.php b/database/migrations/2026_03_05_180004_create_performance_reports_table.php new file mode 100644 index 0000000..860966d --- /dev/null +++ b/database/migrations/2026_03_05_180004_create_performance_reports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->foreignId('quality_document_id')->constrained(); + $table->unsignedSmallInteger('year')->comment('연도'); + $table->unsignedTinyInteger('quarter')->comment('분기 1-4'); + $table->string('confirmation_status', 20)->default('unconfirmed')->comment('unconfirmed/confirmed/reported'); + $table->date('confirmed_date')->nullable(); + $table->foreignId('confirmed_by')->nullable()->constrained('users'); + $table->text('memo')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'quality_document_id']); + $table->index(['tenant_id', 'year', 'quarter']); + $table->index(['tenant_id', 'confirmation_status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('performance_reports'); + } +}; \ No newline at end of file diff --git a/lang/ko/error.php b/lang/ko/error.php index bdad200..7684cc9 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -444,6 +444,15 @@ 'already_completed' => '이미 완료된 검사입니다.', ], + // 품질관리서 관련 + 'quality' => [ + 'cannot_delete_completed' => '완료된 품질관리서는 삭제할 수 없습니다.', + 'already_completed' => '이미 완료된 품질관리서입니다.', + 'cannot_modify_completed' => '완료된 품질관리서는 수정할 수 없습니다.', + 'pending_locations' => '미완료 개소가 :count건 있습니다.', + 'confirm_failed' => '필수정보가 누락된 건이 있어 확정할 수 없습니다.', + ], + // 입찰 관련 'bidding' => [ 'not_found' => '입찰을 찾을 수 없습니다.', diff --git a/routes/api.php b/routes/api.php index a700278..0d329de 100644 --- a/routes/api.php +++ b/routes/api.php @@ -41,6 +41,7 @@ require __DIR__.'/api/v1/app.php'; require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/esign.php'; + require __DIR__.'/api/v1/quality.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php new file mode 100644 index 0000000..edebe9d --- /dev/null +++ b/routes/api/v1/quality.php @@ -0,0 +1,40 @@ +group(function () { + Route::get('', [QualityDocumentController::class, 'index'])->name('v1.quality.documents.index'); + Route::get('/stats', [QualityDocumentController::class, 'stats'])->name('v1.quality.documents.stats'); + Route::get('/calendar', [QualityDocumentController::class, 'calendar'])->name('v1.quality.documents.calendar'); + Route::get('/available-orders', [QualityDocumentController::class, 'availableOrders'])->name('v1.quality.documents.available-orders'); + Route::post('', [QualityDocumentController::class, 'store'])->name('v1.quality.documents.store'); + Route::get('/{id}', [QualityDocumentController::class, 'show'])->whereNumber('id')->name('v1.quality.documents.show'); + Route::put('/{id}', [QualityDocumentController::class, 'update'])->whereNumber('id')->name('v1.quality.documents.update'); + Route::delete('/{id}', [QualityDocumentController::class, 'destroy'])->whereNumber('id')->name('v1.quality.documents.destroy'); + Route::patch('/{id}/complete', [QualityDocumentController::class, 'complete'])->whereNumber('id')->name('v1.quality.documents.complete'); + Route::post('/{id}/orders', [QualityDocumentController::class, 'attachOrders'])->whereNumber('id')->name('v1.quality.documents.attach-orders'); + Route::delete('/{id}/orders/{orderId}', [QualityDocumentController::class, 'detachOrder'])->whereNumber('id')->whereNumber('orderId')->name('v1.quality.documents.detach-order'); + Route::post('/{id}/locations/{locId}/inspect', [QualityDocumentController::class, 'inspectLocation'])->whereNumber('id')->whereNumber('locId')->name('v1.quality.documents.inspect-location'); + Route::get('/{id}/request-document', [QualityDocumentController::class, 'requestDocument'])->whereNumber('id')->name('v1.quality.documents.request-document'); + Route::get('/{id}/result-document', [QualityDocumentController::class, 'resultDocument'])->whereNumber('id')->name('v1.quality.documents.result-document'); +}); + +// 실적신고 +Route::prefix('quality/performance-reports')->group(function () { + Route::get('', [PerformanceReportController::class, 'index'])->name('v1.quality.performance-reports.index'); + Route::get('/stats', [PerformanceReportController::class, 'stats'])->name('v1.quality.performance-reports.stats'); + Route::get('/missing', [PerformanceReportController::class, 'missing'])->name('v1.quality.performance-reports.missing'); + Route::patch('/confirm', [PerformanceReportController::class, 'confirm'])->name('v1.quality.performance-reports.confirm'); + Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm'); + Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo'); +});