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/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/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/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/routes/api/v1/quality.php b/routes/api/v1/quality.php index edebe9d..8100eb7 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -8,6 +8,7 @@ */ 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 +39,12 @@ 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'); +});