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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ea5697cb81
commit
46aa138eec
@@ -0,0 +1,423 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user