diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 4983f5a0..4edc5a81 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-17 13:34:26 +> **자동 생성**: 2026-03-17 15:29:06 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/app/Http/Controllers/Api/V1/PerformanceReportController.php b/app/Http/Controllers/Api/V1/PerformanceReportController.php index 75d40ea6..197f37d0 100644 --- a/app/Http/Controllers/Api/V1/PerformanceReportController.php +++ b/app/Http/Controllers/Api/V1/PerformanceReportController.php @@ -56,4 +56,12 @@ public function missing(Request $request) return $this->service->missing($request->all()); }, __('message.fetched')); } + + public function exportExcel(Request $request) + { + $year = (int) $request->input('year', now()->year); + $quarter = (int) $request->input('quarter', ceil(now()->month / 3)); + + return $this->service->exportConfirmed($year, $quarter); + } } diff --git a/app/Services/PerformanceReportExcelService.php b/app/Services/PerformanceReportExcelService.php new file mode 100644 index 00000000..be090b2d --- /dev/null +++ b/app/Services/PerformanceReportExcelService.php @@ -0,0 +1,462 @@ +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); + } +} diff --git a/app/Services/PerformanceReportService.php b/app/Services/PerformanceReportService.php index 80b343b9..adc4ca9e 100644 --- a/app/Services/PerformanceReportService.php +++ b/app/Services/PerformanceReportService.php @@ -13,9 +13,18 @@ class PerformanceReportService extends Service public function __construct( private readonly AuditLogger $auditLogger, - private readonly QualityDocumentService $qualityDocumentService + private readonly QualityDocumentService $qualityDocumentService, + private readonly PerformanceReportExcelService $excelService ) {} + /** + * 확정건 엑셀 다운로드 + */ + public function exportConfirmed(int $year, int $quarter) + { + return $this->excelService->generate($year, $quarter); + } + /** * 목록 조회 */ diff --git a/composer.json b/composer.json index 01007a97..56664e99 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "league/flysystem-aws-s3-v3": "^3.32", "livewire/livewire": "^3.0", "maatwebsite/excel": "^3.1", + "phpoffice/phpspreadsheet": "^1.30", "spatie/laravel-permission": "^6.21" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 019af7b1..74e509c6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f39a7807cc0a6aa991e31a6acffc9508", + "content-hash": "24a27207b2f9c54e14184a0f9ed3e874", "packages": [ { "name": "aws/aws-crt-php", @@ -3908,16 +3908,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "1.30.0", + "version": "1.30.2", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "2f39286e0136673778b7a142b3f0d141e43d1714" + "reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2f39286e0136673778b7a142b3f0d141e43d1714", - "reference": "2f39286e0136673778b7a142b3f0d141e43d1714", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/09cdde5e2f078b9a3358dd217e2c8cb4dac84be2", + "reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2", "shasum": "" }, "require": { @@ -3939,13 +3939,12 @@ "maennchen/zipstream-php": "^2.1 || ^3.0", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": "^7.4 || ^8.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", + "php": ">=7.4.0 <8.5.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "doctrine/instantiator": "^1.5", "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", "friendsofphp/php-cs-fixer": "^3.2", "mitoteam/jpgraph": "^10.3", @@ -3992,6 +3991,9 @@ }, { "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" } ], "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", @@ -4008,9 +4010,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.2" }, - "time": "2025-08-10T06:28:02+00:00" + "time": "2026-01-11T05:58:24+00:00" }, { "name": "phpoption/phpoption", diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index 0788b4e9..f8d1056d 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -38,6 +38,7 @@ 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::get('/export-excel', [PerformanceReportController::class, 'exportExcel'])->name('v1.quality.performance-reports.export-excel'); 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');