feat: [quality] 실적신고 확정건 엑셀 다운로드 API 구현

- PhpSpreadsheet 기반 PerformanceReportExcelService 신규 생성
- 건기원 양식(품질인정자재등의 판매실적 대장) 엑셀 생성
- 카테고리별 배경색, 셀 병합, 회사정보 섹션 포함
- GET /api/v1/quality/performance-reports/export-excel 엔드포인트 추가
- 미확정 4개 필드(인정품목/내화성능시간/사용부위/로트번호) 빈값 처리
This commit is contained in:
김보곤
2026-03-17 15:56:57 +09:00
parent a96fd254e5
commit 17a0d2f98d
7 changed files with 495 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-17 13:34:26
> **자동 생성**: 2026-03-17 15:29:06
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황

View File

@@ -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);
}
}

View File

@@ -0,0 +1,462 @@
<?php
namespace App\Services;
use App\Models\Qualitys\PerformanceReport;
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Tenants\Tenant;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PerformanceReportExcelService extends Service
{
// 카테고리별 배경색
private const COLOR_MATERIAL = 'DAEEF3';
private const COLOR_SITE = 'E2EFDA';
private const COLOR_SUPERVISOR = 'FCE4D6';
private const COLOR_CONTRACTOR = 'EDEDED';
private const COLOR_DISTRIBUTOR = 'FFF2CC';
// 병합 대상 컬럼 (같은 품질관리서 내 개소가 여러개일 때)
private const MERGE_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA'];
// 비병합 컬럼 (개소별 데이터)
private const NO_MERGE_COLS = ['J', 'K', 'L'];
/**
* 확정건 엑셀 생성 및 스트림 응답
*/
public function generate(int $year, int $quarter): StreamedResponse
{
$tenantId = $this->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);
}
}

View File

@@ -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);
}
/**
* 목록 조회
*/

View File

@@ -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": {

22
composer.lock generated
View File

@@ -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",

View File

@@ -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');