This commit is contained in:
baodev0
2026-09-23 15:17:18 +07:00
parent cd38c5d6d0
commit 82a71244ca
107 changed files with 3332 additions and 3306 deletions
+125 -52
View File
@@ -2,21 +2,28 @@
namespace App\Mail;
use App\Models\EmailNotificationTemplate;
use App\Enums\Invoice\InvoiceStatus;
use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Invoice;
use App\Services\EmailNotificationTemplateRenderer;
use App\Services\InvoicePaymentService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Support\Facades\Log;
/**
* KHÔNG implement ShouldQueue — gọi từ trong queued job, gửi đồng bộ.
*
* Render từ tbl.template (scenario `invoice_notification`) như mọi mailable khác. Trước đây
* đi qua bảng email_notification_templates riêng và rơi về blade khi không có template —
* hai hệ template song song cho cùng một email, admin sửa một nơi thì nơi kia vẫn nguyên.
*/
class InvoiceNotificationMail extends Mailable
{
use RequiresDbTemplate;
public function __construct(
public readonly Invoice $invoice,
public readonly Collection $invoiceAttachments,
@@ -31,50 +38,15 @@ class InvoiceNotificationMail extends Mailable
public function envelope(): Envelope
{
$tpl = $this->activeTemplate();
$fallbackSubject = $this->type === 'renewal'
? "Thông báo gia hạn cước #{$this->invoice->invoice_number} - {$this->companyName}"
: "Thông báo hóa đơn #{$this->invoice->invoice_number} - {$this->companyName}";
$subject = $tpl
? $this->resolveSubject($tpl->subject)
: $fallbackSubject;
return new Envelope(subject: $subject);
return new Envelope(
subject: $this->renderFromTemplate('invoice_notification', $this->vars())['subject'],
);
}
public function content(): Content
{
$tpl = $this->activeTemplate();
if ($tpl) {
try {
$renderer = app(EmailNotificationTemplateRenderer::class);
$html = $renderer->render(
$tpl,
$this->invoice->load(['order.orderItems.product', 'customer', 'attachments']),
$this->companyName
);
return new Content(htmlString: $html);
} catch (\Throwable $e) {
Log::warning('InvoiceNotificationMail: renderer failed, falling back to blade', [
'template_id' => $tpl->id,
'error' => $e->getMessage(),
]);
}
}
return new Content(
view: 'emails.invoice-notification',
with: [
'invoice' => $this->invoice,
'attachments' => $this->invoiceAttachments,
'customerName' => $this->customerName,
'bodyMessage' => $this->message,
'companyName' => $this->companyName,
'type' => $this->type,
'paymentUrl' => $this->paymentUrl,
]
htmlString: $this->renderFromTemplate('invoice_notification', $this->vars())['html'],
);
}
@@ -90,19 +62,120 @@ class InvoiceNotificationMail extends Mailable
return [];
}
private function activeTemplate(): ?EmailNotificationTemplate
private function vars(): array
{
return EmailNotificationTemplate::where('type', 'invoice_notification')
->where('is_active', true)
->first();
$isRenewal = $this->type === 'renewal';
return [
'title' => $isRenewal ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn',
'company_name' => $this->companyName,
'customer_name' => $this->customerName,
'body_message' => $this->message,
'invoice_number' => $this->invoice->invoice_number ?? $this->invoice->order?->order_num ?? '',
'created_date' => $this->formatDate($this->invoice->date ?? $this->invoice->created_at),
'due_date' => $this->formatDate($this->invoice->due_date),
'total' => number_format((int) $this->invoice->total, 0, ',', '.'),
'status_label' => $this->statusLabel(),
'outstanding_row' => $this->outstandingRow(),
'intro_note' => $isRenewal
? 'File PDF hóa đơn gia hạn đã được đính kèm trong email này. Vui lòng hoàn tất thanh toán trước hạn để dịch vụ không bị gián đoạn.'
: 'File PDF hóa đơn đã được đính kèm trong email này để Quý khách tiện lưu trữ và đối chiếu.',
'pay_button' => $this->payButton(),
'attachments_block' => $this->attachmentsBlock(),
'note_block' => $this->noteBlock(),
];
}
private function resolveSubject(string $subject): string
private function formatDate(mixed $value): string
{
return str_replace(
['{{invoice_number}}', '{{company_name}}'],
[$this->invoice->invoice_number ?? '', $this->companyName],
$subject
);
if (empty($value)) {
return '—';
}
if ($value instanceof \Carbon\CarbonInterface) {
return $value->format('d/m/Y');
}
if (is_numeric($value)) {
return date('d/m/Y', (int) $value);
}
try {
return Carbon::parse($value)->format('d/m/Y');
} catch (\Throwable) {
return '—';
}
}
private function statusLabel(): string
{
return match ((int) $this->invoice->status) {
InvoiceStatus::PAID->value => '<strong style="color: #059669;">Đã thanh toán</strong>',
InvoiceStatus::UNPAID->value => '<strong style="color: #dc2626;">Chưa thanh toán</strong>',
InvoiceStatus::OVERDUE->value => '<strong style="color: #b45309;">Quá hạn</strong>',
InvoiceStatus::REFUNDED->value => '<strong style="color: #7c3aed;">Đã hoàn tiền</strong>',
default => '<strong style="color: #9ca3af;">Đã hủy</strong>',
};
}
/** Chỉ hiện khi khách đã trả được một phần nhưng chưa đủ. */
private function outstandingRow(): string
{
$paid = (int) $this->invoice->amount_paid;
$outstanding = InvoicePaymentService::outstanding($this->invoice);
if ($paid <= 0 || $outstanding <= 0) {
return '';
}
return '<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Đã thanh toán:</td><td style="padding: 7px 11px;"><strong style="color: #059669;">'
. number_format($paid, 0, ',', '.') . ' VNĐ</strong></td></tr>'
. '<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Còn thiếu:</td><td style="padding: 7px 11px;"><strong style="color: #dc2626;">'
. number_format($outstanding, 0, ',', '.') . ' VNĐ</strong></td></tr>';
}
private function payButton(): string
{
if (empty($this->paymentUrl)) {
return '';
}
$url = htmlspecialchars($this->paymentUrl, ENT_QUOTES, 'UTF-8');
return '<table cellspacing="0" cellpadding="0" style="margin: 24px auto 8px auto;"><tbody><tr><td align="center" style="background-color: #f16723; padding: 12px 24px; border-radius: 4px;">'
. '<a href="' . $url . '" target="_blank" rel="noopener" style="text-decoration: none; font-size: 14px; font-weight: 600; color: #ffffff;">THANH TOÁN HÓA ĐƠN</a>'
. '</td></tr></tbody></table>'
. '<p style="margin: 0 0 18px 0; word-break: break-all; font-size: 13px; color: #6b7280;">Hoặc sao chép liên kết thanh toán: <a href="' . $url . '" style="color: #f16723;">' . $url . '</a></p>';
}
private function attachmentsBlock(): string
{
if ($this->invoiceAttachments->isEmpty()) {
return '';
}
$items = $this->invoiceAttachments
->map(fn($file) => '<li style="margin-bottom: 6px;"><a href="'
. htmlspecialchars((string) $file->url, ENT_QUOTES, 'UTF-8')
. '" target="_blank" rel="noopener" style="color: #f16723; text-decoration: none;">'
. htmlspecialchars((string) $file->filename, ENT_QUOTES, 'UTF-8')
. '</a></li>')
->implode('');
return '<div style="margin: 20px 0;"><p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600; color: #374151;">File đính kèm</p>'
. '<ul style="padding-left: 18px; margin: 0; font-size: 14px;">' . $items . '</ul></div>';
}
private function noteBlock(): string
{
if (empty($this->invoice->note)) {
return '';
}
return '<table style="width: 100%; border-collapse: collapse; margin-top: 16px;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>'
. '<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;">'
. '<strong style="color: #374151;">Ghi chú:</strong> '
. htmlspecialchars((string) $this->invoice->note, ENT_QUOTES, 'UTF-8')
. '</td></tr></tbody></table>';
}
}