- 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>
424 lines
15 KiB
PHP
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);
|
|
}
|
|
}
|