Files
sam-api/app/Services/PerformanceReportExcelService.php
김보곤 17a0d2f98d feat: [quality] 실적신고 확정건 엑셀 다운로드 API 구현
- PhpSpreadsheet 기반 PerformanceReportExcelService 신규 생성
- 건기원 양식(품질인정자재등의 판매실적 대장) 엑셀 생성
- 카테고리별 배경색, 셀 병합, 회사정보 섹션 포함
- GET /api/v1/quality/performance-reports/export-excel 엔드포인트 추가
- 미확정 4개 필드(인정품목/내화성능시간/사용부위/로트번호) 빈값 처리
2026-03-17 15:56:57 +09:00

463 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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