Files
portal-api/app/Exports/WebDesignQuoteSpreadsheet.php
T
baodev0andClaude Opus 5.5 46aa138eec feat(admin): IP pool allocation, billing exports, service attachments
- IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke
  with notification email (ip_allocated/ip_revoked), ip_pool_logs history.
- Orders/invoices: Excel export on branded template (BrandedReportWriter),
  shared billing filters, order item service info in list responses.
- Web design quote Excel export; activity log export.
- Service attachments with copy-to-invoice; deployment invoice attachments.
- Customer email attachments stored on disk and attached by path.
- Admin password change endpoint; user.fullname backfill migration;
  orders.user_id backfill from logs.
- Disable deploy workflow until CI/CD is set up.
- Ignore stored customer email and service attachment uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:58:36 +07:00

424 lines
15 KiB
PHP

<?php
namespace App\Exports;
use App\Enums\WebDesign\WebDesignPlatform;
use App\Models\WebDesignQuote;
use Carbon\CarbonInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class WebDesignQuoteSpreadsheet
{
private const TEMPLATE = 'Exports/Templates/report_pricing_service.xlsx';
private const STYLE_SOURCES = [
'meta_text' => 'A7',
'meta_label' => 'D7',
'meta_date' => 'E7',
'section' => 'A10',
'head' => 'A23',
'info_label' => 'A12',
'text' => 'C12',
'center' => 'A24',
'item_name' => 'B24',
'qty' => 'D24',
'money' => 'E24',
'sum_label' => 'A33',
'sum_money' => 'E33',
'percent' => 'D46',
'total_label' => 'A49',
'total_money' => 'E49',
'sign_title' => 'B137',
'sign_company' => 'D137',
'sign_hint' => 'B138',
];
private const FIRST_BODY_ROW = 9;
private const COLUMN_WIDTHS = ['A' => 7, 'B' => 30, 'C' => 50, 'D' => 10, 'E' => 18, 'F' => 22];
private const LAST_COLUMN = 'F';
private const DEFAULT_VALIDITY_DAYS = 30;
private const LINE_HEIGHT = 15;
private const MIN_ROW_HEIGHT = 24;
private Worksheet $sheet;
/** @var array<string, int> */
private array $styles = [];
private int $row = self::FIRST_BODY_ROW;
private string $moneyFormat;
public function download(WebDesignQuote $quote): BinaryFileResponse
{
$path = tempnam(sys_get_temp_dir(), 'quote') . '.xlsx';
$this->write($quote, $path);
return response()->download($path, self::fileName($quote))->deleteFileAfterSend();
}
public static function fileName(WebDesignQuote $quote): string
{
return 'Bao-gia-' . $quote->quote_number . '.xlsx';
}
public function write(WebDesignQuote $quote, string $path): void
{
$quote->loadMissing('creator');
$book = IOFactory::load(app_path(self::TEMPLATE));
while ($book->getSheetCount() > 1) {
$book->removeSheetByIndex(1);
}
$this->sheet = $book->getSheet(0);
$this->row = self::FIRST_BODY_ROW;
$this->moneyFormat = $quote->currency === 'USD' ? '[$$-409]#,##0.00' : '#,##0\ [$đ-42A]';
$this->captureStyles();
$this->resetBody();
$this->writeHeader($quote);
$this->writeInfo($quote);
$this->writeItems($quote);
$this->writeTerms($quote);
$this->writeSignature($quote);
$lastRow = $this->row - 1;
$this->sheet->getPageSetup()->setPrintArea('A1:' . self::LAST_COLUMN . $lastRow);
$this->sheet->setSelectedCell('A1');
IOFactory::createWriter($book, 'Xlsx')->save($path);
}
private function captureStyles(): void
{
foreach (self::STYLE_SOURCES as $name => $cell) {
$this->styles[$name] = $this->sheet->getCell($cell)->getXfIndex();
}
}
private function resetBody(): void
{
foreach (array_keys($this->sheet->getMergeCells()) as $range) {
$this->sheet->unmergeCells($range);
}
$highestRow = $this->sheet->getHighestRow();
$this->sheet->removeRow(self::FIRST_BODY_ROW, $highestRow - self::FIRST_BODY_ROW + 1);
foreach (self::COLUMN_WIDTHS as $column => $width) {
$this->sheet->getColumnDimension($column)->setWidth($width);
}
}
private function writeHeader(WebDesignQuote $quote): void
{
foreach ([1, 2, 3, 4] as $companyRow) {
$this->sheet->mergeCells("C{$companyRow}:" . self::LAST_COLUMN . $companyRow);
}
$title = 'Báo giá thiết kế website ' . $quote->website_name;
$this->sheet->setCellValueExplicit('A6', mb_strtoupper($title), DataType::TYPE_STRING);
$this->sheet->mergeCells('A6:' . self::LAST_COLUMN . '6');
$customer = implode(' | ', array_filter([
'Khách hàng: ' . $quote->customer_name,
$quote->customer_company ? 'Công ty: ' . $quote->customer_company : null,
]));
$quoteDate = $quote->created_at ?? now();
$validUntil = $quote->valid_until ?? $quoteDate->copy()->addDays(self::DEFAULT_VALIDITY_DAYS);
$this->writeMetaRow(7, $customer, 'Ngày báo giá', $quoteDate);
$this->writeMetaRow(8, 'Mã báo giá: ' . $quote->quote_number, 'Hiệu lực đến', $validUntil);
}
private function writeMetaRow(int $row, string $text, string $label, CarbonInterface $date): void
{
$this->styleRange("A{$row}:D{$row}", 'meta_text');
$this->text("A{$row}", $text);
$this->sheet->mergeCells("A{$row}:D{$row}");
$this->styleRange("E{$row}", 'meta_label');
$this->text("E{$row}", $label);
$this->styleRange("F{$row}", 'meta_date');
$this->sheet->setCellValue("F{$row}", Date::PHPToExcel($date->copy()->startOfDay()));
}
private function writeInfo(WebDesignQuote $quote): void
{
$this->blankRow();
$this->sectionRow('1. Thông tin khách hàng và dự án');
$this->headRow([['A', 'B', 'Thông tin'], ['C', 'F', 'Chi tiết']]);
$platform = WebDesignPlatform::tryFrom((string) $quote->platform);
$frontend = WebDesignPlatform::frontendLabel($quote->frontend_framework);
$platformText = implode(' — ', array_filter([
$platform?->label() ?? $quote->platform,
$frontend ? 'Frontend: ' . $frontend : null,
$quote->custom_admin ? 'Custom Admin' : null,
]));
$licenseFee = $platform?->annualLicenseFeeUsd() ?? 0;
$rows = [
'Khách hàng' => $quote->customer_name,
'Công ty' => $quote->customer_company,
'Điện thoại' => $quote->customer_phone,
'Email' => $quote->customer_email,
'Website' => $quote->website_name,
'Thể loại' => $quote->website_category,
'Nền tảng' => $platformText,
'Bản quyền' => $licenseFee > 0 ? "Elementor Pro — {$licenseFee}$/năm (chưa gồm trong tổng báo giá)" : null,
'Mô tả' => $quote->description,
'Yêu cầu' => $quote->requirements,
'Website tham khảo' => implode("\n", $quote->reference_websites ?? []),
];
foreach ($rows as $label => $value) {
if ($value === null || trim((string) $value) === '') {
continue;
}
$r = $this->row++;
$this->styleRange("A{$r}:B{$r}", 'info_label');
$this->text("A{$r}", $label);
$this->sheet->mergeCells("A{$r}:B{$r}");
$this->styleRange("C{$r}:F{$r}", 'text');
$this->text("C{$r}", (string) $value);
$this->sheet->mergeCells("C{$r}:F{$r}");
$this->fitHeight($r, (string) $value, $this->widthOf('C', 'F'));
}
}
private function writeItems(WebDesignQuote $quote): void
{
$currency = $quote->currency ?: 'VND';
$this->blankRow();
$this->sectionRow('2. Chi tiết hạng mục và giá');
$this->headRow([
['A', 'A', 'STT'],
['B', 'B', 'Hạng mục'],
['C', 'C', 'Mô tả chi tiết'],
['D', 'D', 'SL'],
['E', 'E', "Đơn giá ({$currency})"],
['F', 'F', "Thành tiền ({$currency})"],
]);
$firstItemRow = $this->row;
foreach (array_values($quote->items ?? []) as $index => $item) {
$r = $this->row++;
$description = self::htmlToText((string) ($item['description'] ?? ''));
$this->styleRange("A{$r}", 'center');
$this->sheet->setCellValue("A{$r}", $index + 1);
$this->styleRange("B{$r}", 'item_name');
$this->text("B{$r}", (string) ($item['name'] ?? ''));
$this->styleRange("C{$r}", 'text');
$this->text("C{$r}", $description);
$this->styleRange("D{$r}", 'qty');
$this->sheet->setCellValue("D{$r}", (float) ($item['quantity'] ?? 0));
$this->sheet->getStyle("D{$r}")->getNumberFormat()->setFormatCode('#,##0.##');
$this->styleRange("E{$r}:F{$r}", 'money');
$this->sheet->setCellValue("E{$r}", (float) ($item['unit_price'] ?? 0));
$this->sheet->setCellValue("F{$r}", "=D{$r}*E{$r}");
$this->moneyFormat("E{$r}:F{$r}");
$height = max(
$this->estimateHeight((string) ($item['name'] ?? ''), $this->widthOf('B', 'B')),
$this->estimateHeight($description, $this->widthOf('C', 'C')),
);
$this->sheet->getRowDimension($r)->setRowHeight($height);
}
$lastItemRow = max($this->row - 1, $firstItemRow);
$subtotalRow = $this->summaryRow('Tạm tính', "=SUM(F{$firstItemRow}:F{$lastItemRow})", 'sum_label', 'sum_money');
$discountRow = $this->summaryRow('Giảm giá', -1 * (float) $quote->discount, 'text', 'money');
$vatRow = $this->row;
$this->summaryRow(
'Thuế GTGT (VAT)',
"=ROUND(MAX(F{$subtotalRow}+F{$discountRow},0)*E{$vatRow}," . ($currency === 'USD' ? 2 : 0) . ')',
'text',
'money',
'D',
);
$this->styleRange("E{$vatRow}", 'percent');
$this->sheet->setCellValue("E{$vatRow}", (float) $quote->vat_percent / 100);
$this->sheet->getStyle("E{$vatRow}")->getNumberFormat()->setFormatCode('0.##%');
$this->summaryRow(
'Tổng giá trị báo giá',
"=MAX(F{$subtotalRow}+F{$discountRow},0)+F{$vatRow}",
'total_label',
'total_money',
);
}
private function summaryRow(
string $label,
float|string $value,
string $labelStyle,
string $moneyStyle,
string $labelTo = 'E',
): int {
$r = $this->row++;
$this->styleRange("A{$r}:E{$r}", $labelStyle);
$this->text("A{$r}", $label);
$this->sheet->mergeCells("A{$r}:{$labelTo}{$r}");
$this->styleRange("F{$r}", $moneyStyle);
$this->sheet->setCellValue("F{$r}", $value);
$this->moneyFormat("F{$r}");
$this->sheet->getRowDimension($r)->setRowHeight(self::MIN_ROW_HEIGHT);
return $r;
}
private function writeTerms(WebDesignQuote $quote): void
{
$validUntil = $quote->valid_until
? 'đến ngày ' . $quote->valid_until->format('d/m/Y')
: 'trong ' . self::DEFAULT_VALIDITY_DAYS . ' ngày kể từ ngày báo giá';
$terms = array_filter([
$quote->note,
"Báo giá có hiệu lực {$validUntil}.",
]);
$this->blankRow();
$this->sectionRow('3. Ghi chú và hiệu lực báo giá');
foreach (array_values($terms) as $index => $term) {
$r = $this->row++;
$this->styleRange("A{$r}", 'center');
$this->sheet->setCellValue("A{$r}", $index + 1);
$this->styleRange("B{$r}:F{$r}", 'text');
$this->text("B{$r}", $term);
$this->sheet->mergeCells("B{$r}:F{$r}");
$this->fitHeight($r, $term, $this->widthOf('B', 'F'));
}
}
private function writeSignature(WebDesignQuote $quote): void
{
$this->blankRow();
$this->blankRow();
$r = $this->row++;
$this->styleRange("B{$r}", 'sign_title');
$this->text("B{$r}", 'ĐẠI DIỆN KHÁCH HÀNG');
$this->styleRange("D{$r}:F{$r}", 'sign_company');
$this->text("D{$r}", 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ');
$this->sheet->mergeCells("D{$r}:F{$r}");
$this->sheet->getRowDimension($r)->setRowHeight(41.2);
$r = $this->row++;
foreach (['B', 'D'] as $column) {
$this->styleRange("{$column}{$r}", 'sign_hint');
$this->text("{$column}{$r}", '(Ký, ghi rõ họ tên)');
}
$this->sheet->mergeCells("D{$r}:F{$r}");
$creator = $quote->creator?->name;
if ($creator) {
$this->row += 3;
$r = $this->row++;
$this->styleRange("D{$r}:F{$r}", 'sign_company');
$this->text("D{$r}", $creator);
$this->sheet->mergeCells("D{$r}:F{$r}");
}
}
private function sectionRow(string $title): void
{
$r = $this->row++;
$this->styleRange("A{$r}:" . self::LAST_COLUMN . $r, 'section');
$this->text("A{$r}", mb_strtoupper($title));
$this->sheet->mergeCells("A{$r}:" . self::LAST_COLUMN . $r);
$this->sheet->getRowDimension($r)->setRowHeight(30);
}
/**
* @param list<array{0: string, 1: string, 2: string}> $cells
*/
private function headRow(array $cells): void
{
$r = $this->row++;
foreach ($cells as [$from, $to, $label]) {
$this->styleRange("{$from}{$r}:{$to}{$r}", 'head');
$this->text("{$from}{$r}", $label);
if ($from !== $to) {
$this->sheet->mergeCells("{$from}{$r}:{$to}{$r}");
}
}
$this->sheet->getRowDimension($r)->setRowHeight(30);
}
private function blankRow(): void
{
$this->row++;
}
private function styleRange(string $range, string $style): void
{
foreach ($this->sheet->rangeToArray($range, null, false, false, true) as $row => $columns) {
foreach (array_keys($columns) as $column) {
$this->sheet->getCell("{$column}{$row}")->setXfIndex($this->styles[$style]);
}
}
}
private function text(string $cell, string $value): void
{
$this->sheet->setCellValueExplicit($cell, $value, DataType::TYPE_STRING);
}
private function moneyFormat(string $range): void
{
$this->sheet->getStyle($range)->getNumberFormat()->setFormatCode($this->moneyFormat);
}
private function widthOf(string $from, string $to): float
{
$width = 0;
foreach (range(Coordinate::columnIndexFromString($from), Coordinate::columnIndexFromString($to)) as $index) {
$width += self::COLUMN_WIDTHS[Coordinate::stringFromColumnIndex($index)];
}
return $width;
}
private function fitHeight(int $row, string $text, float $width): void
{
$this->sheet->getRowDimension($row)->setRowHeight($this->estimateHeight($text, $width));
}
private function estimateHeight(string $text, float $width): float
{
$charsPerLine = max((int) floor($width * 1.1), 1);
$lines = 0;
foreach (explode("\n", $text) as $line) {
$lines += max((int) ceil(mb_strlen($line) / $charsPerLine), 1);
}
return max($lines * self::LINE_HEIGHT + 9, self::MIN_ROW_HEIGHT);
}
public static function htmlToText(string $html): string
{
$text = preg_replace('/<li[^>]*>/i', '- ', $html);
$text = preg_replace('/<br\s*\/?>|<\/(p|div|li|h[1-6]|tr)>/i', "\n", $text);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$lines = array_filter(array_map('trim', explode("\n", $text)), fn (string $line) => $line !== '');
return implode("\n", $lines);
}
}