- PhpSpreadsheet 기반 PerformanceReportExcelService 신규 생성 - 건기원 양식(품질인정자재등의 판매실적 대장) 엑셀 생성 - 카테고리별 배경색, 셀 병합, 회사정보 섹션 포함 - GET /api/v1/quality/performance-reports/export-excel 엔드포인트 추가 - 미확정 4개 필드(인정품목/내화성능시간/사용부위/로트번호) 빈값 처리
463 lines
18 KiB
PHP
463 lines
18 KiB
PHP
<?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);
|
||
}
|
||
}
|