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.orderItem', 'locations.document', '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. 작업일지 & 4. 중간검사 성적서 (인식 가능한 공정만 — 공정별 1개씩) $recognizedWorkOrders = $workOrders->filter(function ($wo) { $subType = $this->mapProcessToSubType($wo->process?->process_name); return $subType !== null; })->groupBy('process_id')->map(fn ($group) => $group->first()); $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $recognizedWorkOrders); $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $recognizedWorkOrders); // 5. 납품확인서 $shipments = $order->shipments()->get(); $documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments); // 6. 출고증 $documents[] = $this->formatDocument('shipping', '출고증', $shipments); // 7. 제품검사 성적서 (FQC 문서 또는 inspection_data 완료건) $locationsWithInspection = $docOrder->locations->filter( fn ($loc) => $loc->document_id || $loc->inspection_status === 'completed' ); $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection); // 8. 품질관리서 (파일 정보 포함) $qualityDoc->loadMissing('file'); $qualityDocFormatted = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc])); // 파일 정보 추가 if ($qualityDoc->file) { $qualityDocFormatted['file_id'] = $qualityDoc->file->id; $qualityDocFormatted['file_name'] = $qualityDoc->file->display_name ?? $qualityDoc->file->original_name; $qualityDocFormatted['file_size'] = $qualityDoc->file->file_size; } $documents[] = $qualityDocFormatted; 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->getWorkOrderLogDetail($id), '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; // 수주로트 건수 = documentOrders 수 $totalRoutes = $doc->documentOrders->count(); // 확인 완료 수주로트 = 해당 주문의 모든 개소가 확인된 건수 $confirmedRoutes = $doc->documentOrders->filter(function ($docOrder) { $locations = $docOrder->locations; if ($locations->isEmpty()) { return false; } return $locations->every(fn ($loc) => 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' => $confirmedRoutes, 'total_routes' => $totalRoutes, 'quarter' => $performanceReport ? $performanceReport->year.'년 '.$performanceReport->quarter.'분기' : '', 'year' => $performanceReport?->year ?? now()->year, 'quarter_num' => $performanceReport?->quarter ?? 0, ]; } /** * 수주 대표 제품명 추출 * Order.item_id → Item.name */ private function getFgProductName(QualityDocument $doc): string { $order = $doc->documentOrders->first()?->order; return $order?->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(), 'client' => $docOrder->order->client_name ?? '', '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 기반 + work_order_id 전달 $workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item; if ($workOrder instanceof WorkOrder) { $processName = $workOrder->process?->process_name; $formatted['sub_type'] = $this->mapProcessToSubType($processName); $formatted['work_order_id'] = $workOrder->id; } return $formatted; })->all(), ]; } private function formatDocumentItem(string $type, $item): array { return match ($type) { 'import' => [ 'id' => (string) $item->id, 'title' => $item->inspection_no ?? '', 'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '', 'code' => $item->inspection_no ?? '', ], 'report' => [ 'id' => (string) $item->id, 'title' => $item->process?->process_name ?? '중간검사 성적서', 'date' => $item->created_at?->toDateString() ?? '', 'code' => $item->work_order_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->process?->process_name ?? '작업일지', 'date' => $item->created_at?->toDateString() ?? '', 'code' => $item->work_order_no ?? '', ], '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' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서', 'date' => $item->updated_at?->toDateString() ?? '', 'code' => $item->document?->document_no ?? '', ], '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([ 'client', 'nodes' => fn ($q) => $q->whereNull('parent_id')->orderBy('id'), ])->findOrFail($id); $rootNodes = $order->nodes; $options = $order->options ?? []; // 개소별 제품 정보 $products = $rootNodes->map(function ($node, $index) { $opts = $node->options ?? []; $vars = data_get($opts, 'bom_result.variables', []); return [ 'no' => $index + 1, 'floor' => $opts['floor'] ?? '-', 'symbol' => $opts['symbol'] ?? '-', 'product_name' => $opts['product_name'] ?? '-', 'product_code' => $opts['product_code'] ?? null, 'open_width' => $opts['open_width'] ?? null, 'open_height' => $opts['open_height'] ?? null, 'made_width' => $opts['width'] ?? null, 'made_height' => $opts['height'] ?? null, 'guide_rail' => $vars['installation_type'] ?? '-', 'shaft' => $vars['bracket_inch'] ?? '-', 'case_inch' => $vars['bracket_inch'] ?? '-', 'bracket' => $vars['BRACKET_SIZE'] ?? '-', 'capacity' => $vars['MOTOR_CAPACITY'] ?? '-', 'finish' => $vars['finishing_type'] ?? '-', 'product_type' => $vars['product_type'] ?? null, 'joint_bar' => null, // 철재 전용 — 아래에서 bom_items에서 보강 ]; })->values()->toArray(); // BOM items 집계 (모든 노드에서) $allBomItems = $rootNodes->flatMap(function ($node) { return collect(data_get($node->options, 'bom_result.items', [])); }); // 철재 제품의 조인트바 수량 보강 foreach ($rootNodes->values() as $index => $node) { $bomItems = collect(data_get($node->options, 'bom_result.items', [])); $jointBar = $bomItems->first(fn ($i) => str_contains($i['item_name'] ?? '', '조인트바')); if ($jointBar) { $products[$index]['joint_bar'] = $jointBar['quantity'] ?? null; } } // 모터 정보 (category: motor, controller) $motorItems = $allBomItems->filter(fn ($i) => in_array($i['item_category'] ?? '', ['motor', 'controller'])); $motorLeft = []; $motorRight = []; foreach ($motorItems->groupBy('item_name') as $name => $group) { $item = $group->first(); $totalQty = $group->sum('quantity'); $row = [ 'item' => $item['item_name'], 'type' => $item['specification'] ?? '-', 'spec' => $item['item_code'] ?? '-', 'qty' => $totalQty, ]; // 모터/브라켓 → 좌, 제어기/전동개폐기 → 우 if (in_array($item['item_category'], ['controller'])) { $motorRight[] = $row; } else { $motorLeft[] = $row; } } // 절곡물 (category: steel) $steelItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'steel'); $bendingParts = $this->groupBendingParts($steelItems); // 부자재 (category: parts) $partItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'parts'); $subsidiaryParts = []; foreach ($partItems->groupBy('item_name') as $name => $group) { $item = $group->first(); $subsidiaryParts[] = [ 'name' => $item['item_name'], 'spec' => $item['specification'] ?? '-', 'qty' => $group->sum('quantity'), ]; } return [ 'type' => 'order', 'data' => [ 'id' => $order->id, 'order_no' => $order->order_no, 'status_code' => $order->status_code, 'category_code' => $order->category_code, 'received_at' => $order->received_at?->toDateString(), 'delivery_date' => $order->delivery_date?->toDateString(), 'delivery_method_code' => $order->delivery_method_code, 'site_name' => $order->site_name, 'client_name' => $order->client_name ?? $order->client?->name, 'client_contact' => $order->client_contact, 'manager_name' => $options['manager_name'] ?? null, 'receiver' => $options['receiver'] ?? null, 'receiver_contact' => $options['receiver_contact'] ?? null, 'shipping_address' => $options['shipping_address'] ?? null, 'shipping_address_detail' => $options['shipping_address_detail'] ?? null, 'shipping_cost_code' => $options['shipping_cost_code'] ?? null, 'quantity' => $order->quantity, 'supply_amount' => $order->supply_amount, 'tax_amount' => $order->tax_amount, 'total_amount' => $order->total_amount, 'remarks' => $order->remarks, 'nodes_count' => $rootNodes->count(), 'products' => $products, 'motors' => [ 'left' => $motorLeft, 'right' => $motorRight, ], 'bending_parts' => $bendingParts, 'subsidiary_parts' => $subsidiaryParts, ], ]; } /** * 절곡물 BOM items를 그룹별로 분류 */ private function groupBendingParts($steelItems): array { $groups = [ '가이드레일' => [], '케이스' => [], '하단마감' => [], '연기차단재' => [], '기타' => [], ]; foreach ($steelItems->groupBy('item_name') as $name => $group) { $item = $group->first(); $totalQty = $group->sum('quantity'); $row = [ 'name' => $item['item_name'], 'spec' => $item['specification'] ?? '-', 'qty' => $totalQty, ]; if (str_contains($name, '연기차단재')) { $groups['연기차단재'][] = $row; } elseif (str_contains($name, '가이드레일')) { $groups['가이드레일'][] = $row; } elseif (str_contains($name, '케이스') || str_contains($name, '마구리')) { $groups['케이스'][] = $row; } elseif (str_contains($name, '하장바') || str_contains($name, 'L-BAR') || str_contains($name, '보강평철')) { $groups['하단마감'][] = $row; } else { $groups['기타'][] = $row; } } $result = []; foreach ($groups as $groupName => $items) { if (! empty($items)) { $result[] = [ 'group' => $groupName, 'items' => $items, ]; } } return $result; } 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::with([ 'vehicleDispatches', 'items', 'order.nodes' => fn ($q) => $q->whereNull('parent_id'), ])->findOrFail($id); // 배차정보 $vehicleDispatches = $shipment->vehicleDispatches->map(fn ($d) => [ 'logistics_company' => $d->logistics_company, 'arrival_datetime' => $d->arrival_datetime, 'tonnage' => $d->tonnage, 'vehicle_no' => $d->vehicle_no, 'driver_contact' => $d->driver_contact, 'remarks' => $d->remarks, ])->values()->toArray(); // 출하 품목 → 제품 그룹별 분류 $productGroups = []; $otherParts = []; foreach ($shipment->items as $item) { $row = [ 'item_name' => $item->item_name, 'specification' => $item->specification, 'quantity' => $item->quantity, 'unit' => $item->unit, 'lot_no' => $item->lot_no, 'floor_unit' => $item->floor_unit, ]; // floor_unit가 있으면 해당 제품 그룹에, 없으면 기타 부품 if ($item->floor_unit) { $productGroups[$item->floor_unit][] = $row; } else { $otherParts[] = $row; } } return [ 'type' => 'shipping', 'data' => [ 'id' => $shipment->id, 'shipment_no' => $shipment->shipment_no, 'lot_no' => $shipment->lot_no, 'status' => $shipment->status, 'scheduled_date' => $shipment->scheduled_date?->toDateString(), 'customer_name' => $shipment->customer_name, 'customer_grade' => $shipment->customer_grade, 'site_name' => $shipment->site_name, 'delivery_address' => $shipment->delivery_address, 'delivery_method' => $shipment->delivery_method, 'shipping_cost' => $shipment->shipping_cost, 'receiver' => $shipment->receiver, 'receiver_contact' => $shipment->receiver_contact, 'vehicle_no' => $shipment->vehicle_no, 'driver_name' => $shipment->driver_name, 'driver_contact' => $shipment->driver_contact, 'remarks' => $shipment->remarks, 'vehicle_dispatches' => $vehicleDispatches, 'product_groups' => $productGroups, 'other_parts' => $otherParts, ], ]; } private function getLocationDetail(int $id): array { $location = QualityDocumentLocation::with([ 'orderItem', 'document.template.sections.items', 'document.template.columns', 'document.template.approvalLines', 'document.template.basicFields', 'document.data', ])->findOrFail($id); $result = [ '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, ], ]; // FQC 문서가 있으면 template + data 포함 if ($location->document) { $doc = $location->document; $result['data']['fqc_document'] = [ 'id' => $doc->id, 'template_id' => $doc->template_id, 'document_no' => $doc->document_no, 'title' => $doc->title, 'status' => $doc->status, 'created_at' => $doc->created_at?->toIso8601String(), 'template' => $this->formatFqcTemplate($doc->template), 'data' => $doc->data->map(fn ($d) => [ 'section_id' => $d->section_id, 'column_id' => $d->column_id, 'row_index' => $d->row_index, 'field_key' => $d->field_key, 'field_value' => $d->field_value, ])->all(), ]; } return $result; } private function formatFqcTemplate($template): ?array { if (! $template) { return null; } return [ 'id' => $template->id, 'name' => $template->name, 'category' => $template->category, 'title' => $template->title, 'approval_lines' => $template->approvalLines->map(fn ($a) => [ 'id' => $a->id, 'name' => $a->name, 'department' => $a->department, 'sort_order' => $a->sort_order, ])->all(), 'basic_fields' => $template->basicFields->map(fn ($f) => [ 'id' => $f->id, 'label' => $f->label, 'field_key' => $f->field_key, 'field_type' => $f->field_type, 'default_value' => $f->default_value, 'is_required' => $f->is_required, 'sort_order' => $f->sort_order, ])->all(), 'sections' => $template->sections->map(fn ($s) => [ 'id' => $s->id, 'name' => $s->name, 'title' => $s->title, 'description' => $s->description, 'image_path' => $s->image_path, 'sort_order' => $s->sort_order, 'items' => $s->items->map(fn ($i) => [ 'id' => $i->id, 'section_id' => $i->section_id, 'item_name' => $i->item ?? '', 'standard' => $i->standard, 'tolerance' => $i->tolerance, 'measurement_type' => $i->measurement_type, 'frequency' => $i->frequency, 'sort_order' => $i->sort_order, 'category' => $i->category, 'method' => $i->method, ])->all(), ])->all(), 'columns' => $template->columns->map(fn ($c) => [ 'id' => $c->id, 'label' => $c->label, 'column_type' => $c->column_type, 'width' => $c->width, 'group_name' => $c->group_name, 'sort_order' => $c->sort_order, ])->all(), ]; } 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, ], ]; } }