Files
sam-api/app/Services/PerformanceReportExcelService.php

463 lines
18 KiB
PHP
Raw Normal View History

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