tenantId(); $tenant = Tenant::find($tenantId); $reports = $this->getConfirmedReports($tenantId, $year, $quarter); $spreadsheet = new Spreadsheet; $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('판매실적대장'); // 시트 구성 $this->setColumnWidths($sheet); $this->writeTitle($sheet, $year, $quarter); $this->writeCompanyInfo($sheet, $tenant); $this->writeCategoryHeaders($sheet); $this->writeColumnHeaders($sheet); $dataStartRow = 13; $lastDataRow = $this->writeDataRows($sheet, $reports, $dataStartRow); // 데이터 영역 테두리 if ($lastDataRow >= $dataStartRow) { $this->applyDataBorders($sheet, $dataStartRow, $lastDataRow); } // 파일명 $companyName = $tenant?->company_name ?? 'SAM'; $filename = "{$companyName}_품질인정자재등의_판매실적_대장_{$year}년_{$quarter}분기.xlsx"; return $this->createStreamedResponse($spreadsheet, $filename); } /** * 확정건 데이터 조회 */ private function getConfirmedReports(int $tenantId, int $year, int $quarter) { return PerformanceReport::where('tenant_id', $tenantId) ->where('year', $year) ->where('quarter', $quarter) ->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED) ->with([ 'qualityDocument.locations.orderItem', 'qualityDocument.locations.qualityDocumentOrder.order', 'qualityDocument.client', ]) ->orderBy('id') ->get(); } /** * 컬럼 너비 설정 */ private function setColumnWidths($sheet): void { $widths = [ 'A' => 6, 'B' => 16, 'C' => 12, 'D' => 10, 'E' => 14, 'F' => 10, 'G' => 12, 'H' => 10, 'I' => 10, 'J' => 10, 'K' => 14, 'L' => 8, 'M' => 20, 'N' => 18, 'O' => 10, 'P' => 14, 'Q' => 18, 'R' => 10, 'S' => 14, 'T' => 14, 'U' => 18, 'V' => 10, 'W' => 14, 'X' => 14, 'Y' => 18, 'Z' => 10, 'AA' => 14, ]; foreach ($widths as $col => $width) { $sheet->getColumnDimension($col)->setWidth($width); } } /** * Row 1, 3: 제목/부제목 */ private function writeTitle($sheet, int $year, int $quarter): void { // Row 1: 제목 $sheet->mergeCells('A1:AA1'); $sheet->setCellValue('A1', '품질인정자재등의 판매실적 제출서식'); $sheet->getStyle('A1')->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 24, 'bold' => true], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(40); // Row 3: 부제목 $sheet->mergeCells('A3:AA3'); $sheet->setCellValue('A3', "품질인정자재등의 판매실적 대장({$year}년 {$quarter}분기)"); $sheet->getStyle('A3')->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 18, 'bold' => true], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(3)->setRowHeight(32); } /** * Row 5~9: 회사 정보 */ private function writeCompanyInfo($sheet, ?Tenant $tenant): void { $infoRows = [ 5 => ['label' => '회사명', 'value' => $tenant?->company_name ?? ''], 6 => ['label' => '대표자', 'value' => $tenant?->ceo_name ?? ''], 7 => ['label' => '사업자등록번호', 'value' => $tenant?->business_num ?? ''], 8 => ['label' => '주소', 'value' => $tenant?->address ?? ''], 9 => ['label' => '연락처', 'value' => $tenant?->phone ?? ''], ]; foreach ($infoRows as $row => $info) { $sheet->mergeCells("A{$row}:C{$row}"); $sheet->mergeCells("D{$row}:AA{$row}"); $sheet->setCellValue("A{$row}", $info['label']); $sheet->setCellValue("D{$row}", $info['value']); $sheet->getStyle("A{$row}:C{$row}")->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 11, 'bold' => true], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER], 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F2F2F2']], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); $sheet->getStyle("D{$row}:AA{$row}")->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 11], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); } } /** * Row 11: 카테고리 헤더 (건축자재내역, 건축공사장, 공사감리자, 공사시공자, 자재유통업자) */ private function writeCategoryHeaders($sheet): void { $categories = [ ['range' => 'A11:L11', 'label' => '건축자재내역', 'color' => self::COLOR_MATERIAL], ['range' => 'M11:O11', 'label' => '건축공사장', 'color' => self::COLOR_SITE], ['range' => 'P11:S11', 'label' => '공사감리자', 'color' => self::COLOR_SUPERVISOR], ['range' => 'T11:W11', 'label' => '공사시공자', 'color' => self::COLOR_CONTRACTOR], ['range' => 'X11:AA11', 'label' => '자재유통업자', 'color' => self::COLOR_DISTRIBUTOR], ]; foreach ($categories as $cat) { $sheet->mergeCells($cat['range']); $startCell = explode(':', $cat['range'])[0]; $sheet->setCellValue($startCell, $cat['label']); $sheet->getStyle($cat['range'])->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 11, 'bold' => true], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => $cat['color']]], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); } $sheet->getRowDimension(11)->setRowHeight(28); } /** * Row 12: 컬럼 헤더 */ private function writeColumnHeaders($sheet): void { $headers = [ 'A' => '일련번호', 'B' => '품질관리서번호', 'C' => '작성일', 'D' => '인정품목', 'E' => '규격(품명)', 'F' => '규격(종류)', 'G' => '제품검사일', 'H' => '내화성능시간', 'I' => '사용부위', 'J' => '로트번호', 'K' => '규격(치수)', 'L' => '수량', 'M' => '공사명칭', 'N' => '소재지', 'O' => '번지', 'P' => '사무소명', 'Q' => '사무소주소', 'R' => '성명', 'S' => '연락처', 'T' => '업체명', 'U' => '업체주소', 'V' => '성명', 'W' => '연락처', 'X' => '업체명', 'Y' => '업체주소', 'Z' => '대표자명', 'AA' => '연락처', ]; // 카테고리별 컬럼 색상 매핑 $colColors = []; foreach (['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'] as $c) { $colColors[$c] = self::COLOR_MATERIAL; } foreach (['M', 'N', 'O'] as $c) { $colColors[$c] = self::COLOR_SITE; } foreach (['P', 'Q', 'R', 'S'] as $c) { $colColors[$c] = self::COLOR_SUPERVISOR; } foreach (['T', 'U', 'V', 'W'] as $c) { $colColors[$c] = self::COLOR_CONTRACTOR; } foreach (['X', 'Y', 'Z', 'AA'] as $c) { $colColors[$c] = self::COLOR_DISTRIBUTOR; } foreach ($headers as $col => $label) { $cell = "{$col}12"; $sheet->setCellValue($cell, $label); $sheet->getStyle($cell)->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 10, 'bold' => true], 'alignment' => [ 'horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true, ], 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => $colColors[$col]]], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); } $sheet->getRowDimension(12)->setRowHeight(32); } /** * 데이터 행 쓰기 (Row 13+) * * @return int 마지막 데이터 행 번호 */ private function writeDataRows($sheet, $reports, int $startRow): int { $currentRow = $startRow; $serialNo = 1; foreach ($reports as $report) { $doc = $report->qualityDocument; if (! $doc) { continue; } $locations = $doc->locations ?? collect(); $locationCount = $locations->count(); if ($locationCount === 0) { // 개소 없는 경우에도 1행 출력 $this->writeDocumentRow($sheet, $currentRow, $serialNo, $doc, null); $currentRow++; } else { $firstRow = $currentRow; foreach ($locations as $idx => $location) { $this->writeDocumentRow( $sheet, $currentRow, $idx === 0 ? $serialNo : null, $idx === 0 ? $doc : null, $location ); $currentRow++; } // 같은 품질관리서의 여러 개소 → 병합 if ($locationCount > 1) { $lastRow = $currentRow - 1; foreach (self::MERGE_COLS as $col) { $sheet->mergeCells("{$col}{$firstRow}:{$col}{$lastRow}"); $sheet->getStyle("{$col}{$firstRow}")->getAlignment() ->setVertical(Alignment::VERTICAL_CENTER); } } } $serialNo++; } return $currentRow - 1; } /** * 한 행 쓰기 */ private function writeDocumentRow($sheet, int $row, ?int $serialNo, ?object $doc, ?QualityDocumentLocation $location): void { $options = $doc?->options ?? []; $orderItem = $location?->orderItem; // === 병합 컬럼 (문서 수준 - 첫 행에만 기록) === if ($doc !== null) { $sheet->setCellValue("A{$row}", $serialNo); $sheet->setCellValue("B{$row}", $doc->quality_doc_number ?? ''); $sheet->setCellValue("C{$row}", $doc->received_date?->format('Y-m-d') ?? ''); // 자재 정보 (D~I) $sheet->setCellValue("D{$row}", $this->getProductCategory($location)); $sheet->setCellValue("E{$row}", $orderItem?->item_name ?? ''); $sheet->setCellValue("F{$row}", $orderItem?->specification ?? ''); $sheet->setCellValue("G{$row}", $this->getInspectionDate($doc)); $sheet->setCellValue("H{$row}", $this->getFireResistanceTime($location)); $sheet->setCellValue("I{$row}", $this->getUsagePart($location)); // 건축공사장 (M~O) $site = $options['construction_site'] ?? []; $sheet->setCellValue("M{$row}", $site['name'] ?? ''); $sheet->setCellValue("N{$row}", $site['land_location'] ?? ''); $sheet->setCellValue("O{$row}", $site['lot_number'] ?? ''); // 공사감리자 (P~S) $supervisor = $options['supervisor'] ?? []; $sheet->setCellValue("P{$row}", $supervisor['office'] ?? ''); $sheet->setCellValue("Q{$row}", $supervisor['address'] ?? ''); $sheet->setCellValue("R{$row}", $supervisor['name'] ?? ''); $sheet->setCellValue("S{$row}", $supervisor['phone'] ?? ''); // 공사시공자 (T~W) $contractor = $options['contractor'] ?? []; $sheet->setCellValue("T{$row}", $contractor['company'] ?? ''); $sheet->setCellValue("U{$row}", $contractor['address'] ?? ''); $sheet->setCellValue("V{$row}", $contractor['name'] ?? ''); $sheet->setCellValue("W{$row}", $contractor['phone'] ?? ''); // 자재유통업자 (X~AA) $distributor = $options['material_distributor'] ?? []; $sheet->setCellValue("X{$row}", $distributor['company'] ?? ''); $sheet->setCellValue("Y{$row}", $distributor['address'] ?? ''); $sheet->setCellValue("Z{$row}", $distributor['ceo'] ?? ''); $sheet->setCellValue("AA{$row}", $distributor['phone'] ?? ''); } // === 비병합 컬럼 (개소별 - 매 행 기록) === if ($location !== null) { $sheet->setCellValue("J{$row}", $this->getLotNumber($location)); $sheet->setCellValue("K{$row}", $this->formatDimension($location)); $sheet->setCellValue("L{$row}", $this->getQuantity($location)); } // 행 스타일 $sheet->getStyle("A{$row}:AA{$row}")->applyFromArray([ 'font' => ['name' => '돋움', 'size' => 10], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], ]); $sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getStyle("L{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); } /** * 데이터 영역 테두리 적용 */ private function applyDataBorders($sheet, int $startRow, int $endRow): void { $sheet->getStyle("A{$startRow}:AA{$endRow}")->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], ]); } /** * 스트림 응답 생성 */ private function createStreamedResponse(Spreadsheet $spreadsheet, string $filename): StreamedResponse { $encodedFilename = rawurlencode($filename); return new StreamedResponse(function () use ($spreadsheet) { $writer = new Xlsx($spreadsheet); $writer->save('php://output'); $spreadsheet->disconnectWorksheets(); }, 200, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition' => "attachment; filename*=UTF-8''{$encodedFilename}", 'Cache-Control' => 'max-age=0', ]); } // ======================================== // 미확정 필드 (추후 데이터 매핑) // ======================================== /** 인정품목 (추후 구현) */ private function getProductCategory(?QualityDocumentLocation $location): string { return ''; } /** 내화성능시간 (추후 구현) */ private function getFireResistanceTime(?QualityDocumentLocation $location): string { return ''; } /** 사용부위 (추후 구현) */ private function getUsagePart(?QualityDocumentLocation $location): string { return ''; } /** 로트번호 (추후 구현) */ private function getLotNumber(?QualityDocumentLocation $location): string { return ''; } // ======================================== // 헬퍼 메서드 // ======================================== /** 제품검사일 (품질관리서의 검사 완료일) */ private function getInspectionDate(?object $doc): string { if (! $doc) { return ''; } $options = $doc->options ?? []; $endDate = $options['inspection']['end_date'] ?? ''; return $endDate ?: ''; } /** 규격(치수): 너비 × 높이 */ private function formatDimension(?QualityDocumentLocation $location): string { if (! $location) { return ''; } $w = $location->post_width; $h = $location->post_height; if (! $w && ! $h) { return ''; } return "{$w} × {$h}"; } /** 수량 (개소당 1 또는 orderItem 수량) */ private function getQuantity(?QualityDocumentLocation $location): int { if (! $location) { return 0; } return (int) ($location->orderItem?->quantity ?? 1); } }