'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 */ 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 $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('/]*>/i', '- ', $html); $text = preg_replace('/|<\/(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); } }