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
@@ -64,6 +64,22 @@ enum WebDesignPlatform: string
|
||||
return $this === self::WORDPRESS_ELEMENTOR ? 60 : 0;
|
||||
}
|
||||
|
||||
public static function frontendLabel(?string $framework): ?string
|
||||
{
|
||||
if (!$framework) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'nextjs' => 'Next.js',
|
||||
'react' => 'React',
|
||||
'vue' => 'Vue',
|
||||
'nuxt' => 'Nuxt',
|
||||
'astro' => 'Astro',
|
||||
'angular' => 'Angular',
|
||||
][$framework] ?? $framework;
|
||||
}
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class ActivityLogsExport implements BrandedReport
|
||||
{
|
||||
private const EVENT_LABELS = [
|
||||
'created' => 'Tạo mới',
|
||||
'updated' => 'Cập nhật',
|
||||
'deleted' => 'Xóa',
|
||||
'restored' => 'Khôi phục',
|
||||
'login' => 'Đăng nhập',
|
||||
'logout' => 'Đăng xuất',
|
||||
];
|
||||
|
||||
public function __construct(private readonly Builder $query) {}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Lịch sử hoạt động';
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query->latest('id');
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'ID', 'Module', 'Hành động', 'Mô tả', 'Loại người thực hiện', 'Người thực hiện',
|
||||
'Đối tượng', 'ID đối tượng', 'IP', 'Thiết bị', 'Dữ liệu cũ', 'Dữ liệu mới', 'Thời gian',
|
||||
];
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
/** @var ActivityLog $row */
|
||||
return [
|
||||
$row->id,
|
||||
$row->log_name,
|
||||
self::EVENT_LABELS[$row->event] ?? $row->event,
|
||||
$row->description,
|
||||
$row->causer_type,
|
||||
$row->causer_name,
|
||||
$row->subject_type,
|
||||
$row->subject_id,
|
||||
$row->ip_address,
|
||||
$row->user_agent,
|
||||
$this->encodeValues($row->old_values),
|
||||
$this->encodeValues($row->new_values),
|
||||
$row->created_at?->format('d/m/Y H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
public function moneyColumns(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
private function encodeValues(?array $values): ?string
|
||||
{
|
||||
if ($values === null || $values === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
interface BrandedReport
|
||||
{
|
||||
public function title(): string;
|
||||
|
||||
public function query(): Builder;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function headings(): array;
|
||||
|
||||
/**
|
||||
* @return list<scalar|null>
|
||||
*/
|
||||
public function map($row): array;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function moneyColumns(): array;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class BrandedReportWriter
|
||||
{
|
||||
private const TEMPLATE = 'Exports/Templates/report_template.xlsx';
|
||||
|
||||
private const COMPANY_ROWS = [1, 2, 3, 4];
|
||||
|
||||
private const TITLE_ROW = 6;
|
||||
|
||||
private const HEADING_ROW = 7;
|
||||
|
||||
private const FIRST_DATA_ROW = 8;
|
||||
|
||||
private const LOGO_COLUMNS_MIN_WIDTH = 37;
|
||||
|
||||
private const MAX_COLUMN_WIDTH = 60;
|
||||
|
||||
private const FONT_WIDTH_FACTOR = 1.2;
|
||||
|
||||
private const FONT = 'Google Sans';
|
||||
|
||||
private const BRAND_COLOR = 'F26522';
|
||||
|
||||
private const BORDER_COLOR = 'D9D9D9';
|
||||
|
||||
private const CHUNK_SIZE = 500;
|
||||
|
||||
public function download(BrandedReport $report, string $filename): BinaryFileResponse
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'report') . '.xlsx';
|
||||
$this->write($report, $path);
|
||||
|
||||
return response()->download($path, $filename)->deleteFileAfterSend();
|
||||
}
|
||||
|
||||
public function write(BrandedReport $report, string $path): void
|
||||
{
|
||||
$book = IOFactory::load(app_path(self::TEMPLATE));
|
||||
$sheet = $book->getActiveSheet();
|
||||
$headings = $report->headings();
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(count($headings));
|
||||
|
||||
$this->resetBody($sheet);
|
||||
$this->spanCompanyAndTitle($sheet, $lastColumn, $report->title());
|
||||
$this->writeHeadings($sheet, $headings, $lastColumn);
|
||||
|
||||
$widths = array_map(fn (string $heading) => mb_strlen($heading), $headings);
|
||||
$row = self::FIRST_DATA_ROW;
|
||||
|
||||
$report->query()->chunk(self::CHUNK_SIZE, function ($records) use ($report, $sheet, &$row, &$widths) {
|
||||
foreach ($records as $record) {
|
||||
foreach (array_values($report->map($record)) as $index => $value) {
|
||||
$this->writeCell($sheet, $index + 1, $row, $value);
|
||||
$widths[$index] = max($widths[$index], $this->displayWidth($value));
|
||||
}
|
||||
$row++;
|
||||
}
|
||||
});
|
||||
|
||||
$lastRow = max($row - 1, self::HEADING_ROW);
|
||||
$this->styleBody($sheet, $lastColumn, $lastRow, $report->moneyColumns());
|
||||
$this->sizeColumns($sheet, $widths);
|
||||
|
||||
$sheet->freezePane('A' . self::FIRST_DATA_ROW);
|
||||
$sheet->setAutoFilter('A' . self::HEADING_ROW . ":{$lastColumn}{$lastRow}");
|
||||
$sheet->getPageSetup()
|
||||
->setPrintArea("A1:{$lastColumn}{$lastRow}")
|
||||
->setOrientation(PageSetup::ORIENTATION_LANDSCAPE)
|
||||
->setFitToWidth(1)
|
||||
->setFitToHeight(0);
|
||||
$sheet->setSelectedCell('A' . self::FIRST_DATA_ROW);
|
||||
|
||||
IOFactory::createWriter($book, 'Xlsx')->save($path);
|
||||
}
|
||||
|
||||
private function resetBody(Worksheet $sheet): void
|
||||
{
|
||||
foreach (array_keys($sheet->getMergeCells()) as $range) {
|
||||
$sheet->unmergeCells($range);
|
||||
}
|
||||
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
if ($highestRow >= self::HEADING_ROW) {
|
||||
$sheet->removeRow(self::HEADING_ROW, $highestRow - self::HEADING_ROW + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private function spanCompanyAndTitle(Worksheet $sheet, string $lastColumn, string $title): void
|
||||
{
|
||||
$companyLastColumn = Coordinate::columnIndexFromString($lastColumn) > 3 ? $lastColumn : 'E';
|
||||
foreach (self::COMPANY_ROWS as $companyRow) {
|
||||
$sheet->mergeCells("C{$companyRow}:{$companyLastColumn}{$companyRow}");
|
||||
}
|
||||
|
||||
$titleCell = 'A' . self::TITLE_ROW;
|
||||
$sheet->setCellValueExplicit($titleCell, mb_strtoupper($title), DataType::TYPE_STRING);
|
||||
$sheet->mergeCells("{$titleCell}:{$lastColumn}" . self::TITLE_ROW);
|
||||
}
|
||||
|
||||
private function writeHeadings(Worksheet $sheet, array $headings, string $lastColumn): void
|
||||
{
|
||||
foreach ($headings as $index => $heading) {
|
||||
$sheet->setCellValueExplicit([$index + 1, self::HEADING_ROW], $heading, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
$range = 'A' . self::HEADING_ROW . ":{$lastColumn}" . self::HEADING_ROW;
|
||||
$sheet->getStyle($range)->applyFromArray([
|
||||
'font' => ['name' => self::FONT, 'size' => 11, 'bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => self::BRAND_COLOR]],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
'wrapText' => true,
|
||||
],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => self::BORDER_COLOR]]],
|
||||
]);
|
||||
$sheet->getRowDimension(self::HEADING_ROW)->setRowHeight(30);
|
||||
}
|
||||
|
||||
private function writeCell(Worksheet $sheet, int $column, int $row, mixed $value): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
$sheet->setCellValue([$column, $row], $value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueExplicit([$column, $row], (string) $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
private function styleBody(Worksheet $sheet, string $lastColumn, int $lastRow, array $moneyColumns): void
|
||||
{
|
||||
if ($lastRow < self::FIRST_DATA_ROW) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->getStyle('A' . self::FIRST_DATA_ROW . ":{$lastColumn}{$lastRow}")->applyFromArray([
|
||||
'font' => ['name' => self::FONT, 'size' => 11],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_TOP, 'wrapText' => true],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => self::BORDER_COLOR]]],
|
||||
]);
|
||||
|
||||
foreach ($moneyColumns as $column) {
|
||||
$sheet->getStyle("{$column}" . self::FIRST_DATA_ROW . ":{$column}{$lastRow}")
|
||||
->getNumberFormat()->setFormatCode('#,##0');
|
||||
}
|
||||
}
|
||||
|
||||
private function sizeColumns(Worksheet $sheet, array $widths): void
|
||||
{
|
||||
$widths = array_map(fn (int $width) => min((int) ceil($width * self::FONT_WIDTH_FACTOR) + 3, self::MAX_COLUMN_WIDTH), $widths);
|
||||
|
||||
$logoShortfall = self::LOGO_COLUMNS_MIN_WIDTH - ($widths[0] + ($widths[1] ?? 0));
|
||||
if ($logoShortfall > 0) {
|
||||
$widths[1] = ($widths[1] ?? 0) + $logoShortfall;
|
||||
}
|
||||
|
||||
foreach ($widths as $index => $width) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
|
||||
}
|
||||
}
|
||||
|
||||
private function displayWidth(mixed $value): int
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return mb_strlen(number_format((float) $value));
|
||||
}
|
||||
|
||||
$lines = explode("\n", (string) $value);
|
||||
|
||||
return max(array_map(fn (string $line) => mb_strlen($line), $lines));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports\Concerns;
|
||||
|
||||
use App\Models\Order;
|
||||
use Carbon\Carbon;
|
||||
|
||||
trait FormatsBillingCells
|
||||
{
|
||||
private function describeServices(?Order $order): string
|
||||
{
|
||||
return ($order?->orderItems ?? collect())
|
||||
->map(function ($item) {
|
||||
$name = $item->service?->server_name ?: $item->server_name ?: $item->domain_name ?: $item->product?->name;
|
||||
|
||||
return $item->service
|
||||
? "#{$item->service->id} {$name} ({$item->service->status_label})"
|
||||
: "{$name} (chưa có dịch vụ)";
|
||||
})
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
private function formatDate(mixed $value, string $format = 'd/m/Y'): ?string
|
||||
{
|
||||
return $value ? Carbon::parse($value)->format($format) : null;
|
||||
}
|
||||
|
||||
private function paymentMethodLabel(?string $method): ?string
|
||||
{
|
||||
return match ($method) {
|
||||
'transfer' => 'Chuyển khoản',
|
||||
'balance' => 'Trừ số dư',
|
||||
default => $method,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Exports\Concerns\FormatsBillingCells;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class InvoicesExport implements BrandedReport
|
||||
{
|
||||
use FormatsBillingCells;
|
||||
|
||||
private const TYPE_LABELS = [1 => 'Khởi tạo', 2 => 'Gia hạn', 3 => 'Nâng cấp'];
|
||||
|
||||
public function __construct(private readonly Builder $query) {}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Danh sách hoá đơn';
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query->latest('id');
|
||||
}
|
||||
|
||||
public function moneyColumns(): array
|
||||
{
|
||||
return ['G', 'H', 'I', 'J', 'K', 'L'];
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Mã hoá đơn', 'Mã đơn hàng', 'Loại', 'Khách hàng', 'Email', 'Dịch vụ', 'Tạm tính',
|
||||
'Giảm giá', 'Thuế', 'Tổng tiền', 'Đã thu', 'Còn nợ', 'Trạng thái', 'Phương thức thanh toán',
|
||||
'Hạn thanh toán', 'Ngày thanh toán', 'Ngày tạo',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Invoice $invoice
|
||||
*/
|
||||
public function map($invoice): array
|
||||
{
|
||||
return [
|
||||
$invoice->invoice_number,
|
||||
$invoice->order?->order_num,
|
||||
self::TYPE_LABELS[(int) $invoice->invoice_type] ?? $invoice->invoice_type,
|
||||
$invoice->customer?->fullname,
|
||||
$invoice->customer?->email,
|
||||
$this->describeServices($invoice->order),
|
||||
(int) $invoice->sub_total,
|
||||
(int) $invoice->discount_amount,
|
||||
(int) $invoice->tax,
|
||||
(int) $invoice->total,
|
||||
(int) $invoice->amount_paid,
|
||||
InvoicePaymentService::outstanding($invoice),
|
||||
InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? $invoice->status,
|
||||
$invoice->payment?->name ?? $this->paymentMethodLabel($invoice->payment_method),
|
||||
$this->formatDate($invoice->due_date),
|
||||
$this->formatDate($invoice->paid_at, 'd/m/Y H:i'),
|
||||
$this->formatDate($invoice->created_at, 'd/m/Y H:i'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Exports\Concerns\FormatsBillingCells;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class OrdersExport implements BrandedReport
|
||||
{
|
||||
use FormatsBillingCells;
|
||||
|
||||
public function __construct(private readonly Builder $query) {}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Danh sách đơn hàng';
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->query->latest('id');
|
||||
}
|
||||
|
||||
public function moneyColumns(): array
|
||||
{
|
||||
return ['F', 'G', 'H'];
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Mã đơn hàng', 'Ngày đặt', 'Khách hàng', 'Email', 'Dịch vụ', 'Tạm tính', 'Giảm giá',
|
||||
'Tổng tiền', 'Trạng thái', 'Phương thức thanh toán', 'Hoá đơn', 'Ghi chú', 'Ngày tạo',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Order $order
|
||||
*/
|
||||
public function map($order): array
|
||||
{
|
||||
return [
|
||||
$order->order_num,
|
||||
$this->formatDate($order->date),
|
||||
$order->customer?->fullname,
|
||||
$order->customer?->email,
|
||||
$this->describeServices($order),
|
||||
(int) $order->sub_total,
|
||||
(int) $order->discount_amount,
|
||||
(int) $order->total,
|
||||
OrderStatus::tryFrom((int) $order->status)?->label() ?? $order->status,
|
||||
$order->payment?->name ?? $this->paymentMethodLabel($order->payment_method),
|
||||
$order->invoices->pluck('invoice_number')->implode(', '),
|
||||
$order->note,
|
||||
$this->formatDate($order->created_at, 'd/m/Y H:i'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\ActivityLog;
|
||||
|
||||
use App\Exports\ActivityLogsExport;
|
||||
use App\Exports\BrandedReportWriter;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Repositories\ActivityLog\ActivityLogRepositoryInterface;
|
||||
@@ -9,6 +11,7 @@ use App\Support\Cache\CacheKey;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class ActivityLogController extends Controller
|
||||
{
|
||||
@@ -39,7 +42,11 @@ class ActivityLogController extends Controller
|
||||
|
||||
$cacheKey = CacheKey::build('activity_logs', 'list', array_merge(
|
||||
$filters,
|
||||
['per_page' => $perPage, 'page' => (int) $request->get('page', 1)]
|
||||
[
|
||||
'per_page' => $perPage,
|
||||
'page' => (int) $request->get('page', 1),
|
||||
'version' => $this->cacheVersion(),
|
||||
]
|
||||
));
|
||||
|
||||
$data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filters, $perPage) {
|
||||
@@ -59,7 +66,10 @@ class ActivityLogController extends Controller
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$cacheKey = CacheKey::build('activity_logs', 'detail', ['id' => $id]);
|
||||
$cacheKey = CacheKey::build('activity_logs', 'detail', [
|
||||
'id' => $id,
|
||||
'version' => $this->cacheVersion(),
|
||||
]);
|
||||
|
||||
$log = Cache::remember($cacheKey, self::CACHE_TTL * 5, function () use ($id) {
|
||||
return $this->activityLogRepository->find($id);
|
||||
@@ -74,7 +84,7 @@ class ActivityLogController extends Controller
|
||||
|
||||
public function modules(): JsonResponse
|
||||
{
|
||||
$cacheKey = CacheKey::build('activity_logs', 'modules');
|
||||
$cacheKey = CacheKey::build('activity_logs', 'modules', ['version' => $this->cacheVersion()]);
|
||||
|
||||
$modules = Cache::remember($cacheKey, self::CACHE_TTL * 10, function () {
|
||||
return ActivityLog::query()
|
||||
@@ -89,7 +99,7 @@ class ActivityLogController extends Controller
|
||||
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$cacheKey = CacheKey::build('activity_logs', 'stats');
|
||||
$cacheKey = CacheKey::build('activity_logs', 'stats', ['version' => $this->cacheVersion()]);
|
||||
|
||||
$stats = Cache::remember($cacheKey, self::CACHE_TTL * 2, function () {
|
||||
return [
|
||||
@@ -108,4 +118,65 @@ class ActivityLogController extends Controller
|
||||
|
||||
return response()->json(['error' => false, 'data' => $stats]);
|
||||
}
|
||||
|
||||
public function export(Request $request, BrandedReportWriter $writer): BinaryFileResponse
|
||||
{
|
||||
$filters = $request->validate($this->filterRules());
|
||||
$query = $this->activityLogRepository->query(array_filter(
|
||||
$filters,
|
||||
fn ($value) => $value !== null && $value !== '',
|
||||
));
|
||||
|
||||
return $writer->download(
|
||||
new ActivityLogsExport($query),
|
||||
'lich-su-hoat-dong-' . now()->format('Y-m-d-His') . '.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mode' => ['required', 'string', 'in:all,date_range'],
|
||||
'date_from' => ['required_if:mode,date_range', 'nullable', 'date_format:Y-m-d'],
|
||||
'date_to' => ['required_if:mode,date_range', 'nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
|
||||
]);
|
||||
|
||||
$query = ActivityLog::query();
|
||||
if ($validated['mode'] === 'date_range') {
|
||||
$query
|
||||
->where('created_at', '>=', $validated['date_from'] . ' 00:00:00')
|
||||
->where('created_at', '<=', $validated['date_to'] . ' 23:59:59');
|
||||
}
|
||||
|
||||
$deleted = $query->delete();
|
||||
Cache::forever('activity_logs:version', $this->cacheVersion() + 1);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => "Đã xóa {$deleted} log hoạt động.",
|
||||
'data' => ['deleted' => $deleted],
|
||||
]);
|
||||
}
|
||||
|
||||
private function filterRules(): array
|
||||
{
|
||||
return [
|
||||
'log_name' => ['nullable', 'string', 'max:100'],
|
||||
'event' => ['nullable', 'string', 'max:100'],
|
||||
'causer_type' => ['nullable', 'string', 'max:100'],
|
||||
'causer_id' => ['nullable', 'integer'],
|
||||
'causer_name' => ['nullable', 'string', 'max:255'],
|
||||
'subject_type' => ['nullable', 'string', 'max:255'],
|
||||
'subject_id' => ['nullable', 'string', 'max:255'],
|
||||
'ip_address' => ['nullable', 'ip'],
|
||||
'date_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'date_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
private function cacheVersion(): int
|
||||
{
|
||||
return (int) Cache::get('activity_logs:version', 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ namespace App\Http\Controllers\Api\Admin\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UpdateProfileRequest;
|
||||
use App\Http\Requests\UpdateAdminPasswordRequest;
|
||||
use App\Http\Resources\Api\Admin\AdminUserResource;
|
||||
use App\Services\ActivityLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tymon\JWTAuth\Facades\JWTAuth;
|
||||
use Tymon\JWTAuth\Exceptions\JWTException;
|
||||
@@ -93,6 +95,27 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePassword(UpdateAdminPasswordRequest $request): JsonResponse
|
||||
{
|
||||
$user = Auth::guard('admin')->user();
|
||||
|
||||
$user->update([
|
||||
'password' => Hash::make($request->string('password')->toString()),
|
||||
]);
|
||||
|
||||
$this->activityLogger->logAuth(
|
||||
'password_changed',
|
||||
'admin',
|
||||
$user->id,
|
||||
$user->username ?? $user->email,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Đổi mật khẩu thành công.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function refresh(): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Customer\Concerns;
|
||||
|
||||
use App\Support\MailAttachments;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -64,6 +65,7 @@ trait ManagesCustomerAccount
|
||||
'cc' => 'nullable|string|max:1000',
|
||||
'bcc' => 'nullable|string|max:1000',
|
||||
'with_layout' => 'nullable|boolean',
|
||||
...MailAttachments::rules(),
|
||||
]);
|
||||
|
||||
$this->mailService->sendCustomEmail($uuid, $data, Auth::guard('admin')->user(), $request->ip());
|
||||
|
||||
@@ -31,7 +31,7 @@ trait HandlesInvoiceAttachments
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx|max:51200',
|
||||
'file' => InvoiceAttachment::UPLOAD_RULE,
|
||||
'type' => 'nullable|in:' . InvoiceAttachment::TYPE_PAYMENT_PROOF . ',' . InvoiceAttachment::TYPE_DEPLOYMENT,
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\InvoiceRequest;
|
||||
use App\Exports\BrandedReportWriter;
|
||||
use App\Http\Requests\BillingExportRequest;
|
||||
use App\Exports\InvoicesExport;
|
||||
use App\Http\Resources\Api\Admin\InvoiceResource;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use App\Http\Resources\Api\Admin\OrderItemServiceResource;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\Order;
|
||||
@@ -39,7 +44,12 @@ class InvoiceController extends Controller
|
||||
{
|
||||
$query = $this->invoiceRepository->filter(
|
||||
$request->all(),
|
||||
['payment', 'customer', 'order'],
|
||||
[
|
||||
'payment',
|
||||
'customer',
|
||||
'order',
|
||||
...OrderItemServiceResource::relationsFor('order.orderItems'),
|
||||
],
|
||||
[],
|
||||
['*'],
|
||||
true
|
||||
@@ -54,6 +64,19 @@ class InvoiceController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(BillingExportRequest $request, BrandedReportWriter $writer): BinaryFileResponse
|
||||
{
|
||||
$query = $this->invoiceRepository->filter(
|
||||
array_filter($request->validated(), fn ($value) => $value !== null && $value !== []),
|
||||
['payment', 'customer', 'order', ...OrderItemServiceResource::relationsFor('order.orderItems')],
|
||||
[],
|
||||
['*'],
|
||||
true
|
||||
);
|
||||
|
||||
return $writer->download(new InvoicesExport($query), 'hoa-don-' . now()->format('Y-m-d-His') . '.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin tạo thêm 1 invoice thủ công cho 1 order đã tồn tại — chỉ dùng cho phụ
|
||||
* phí/điều chỉnh thuộc CHÍNH dịch vụ trong order đó (vd. phạt trễ hạn, phí xử lý).
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\IpPool\Concerns;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Services\IpPool\IpAllocationService;
|
||||
use App\Support\MailAttachments;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ManagesIpPoolAllocation
|
||||
{
|
||||
public function allocationPreview(Request $request, IpAllocationService $allocation): JsonResponse
|
||||
{
|
||||
$data = $this->validateAllocation($request);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $allocation->previewAllocation($data['ids'], $this->allocationCustomer($data), $data),
|
||||
]);
|
||||
}
|
||||
|
||||
public function allocate(Request $request, IpAllocationService $allocation): JsonResponse
|
||||
{
|
||||
$data = $this->validateAllocation($request);
|
||||
$count = $allocation->allocate($data['ids'], $this->allocationCustomer($data), $data, $request->user('admin'));
|
||||
|
||||
return response()->json(['error' => false, 'message' => "Đã cấp phát {$count} IP và gửi email cho khách hàng"]);
|
||||
}
|
||||
|
||||
public function revocationPreview(Request $request, IpAllocationService $allocation): JsonResponse
|
||||
{
|
||||
$data = $this->validateRevocation($request);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $allocation->previewRevocation($data['ids'], $data)]);
|
||||
}
|
||||
|
||||
public function revoke(Request $request, IpAllocationService $allocation): JsonResponse
|
||||
{
|
||||
$data = $this->validateRevocation($request, withRecipients: true);
|
||||
$recipients = collect($data['recipients'])->keyBy('key')->all();
|
||||
$count = $allocation->revoke($data['ids'], $data, $recipients, $request->user('admin'));
|
||||
|
||||
return response()->json(['error' => false, 'message' => "Đã thu hồi {$count} IP và gửi email thông báo"]);
|
||||
}
|
||||
|
||||
public function logs(int $id): JsonResponse
|
||||
{
|
||||
$pool = IpPool::withTrashed()->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $pool->logs()->with('user:id,username,fullname')->limit(200)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateAllocation(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer',
|
||||
'customer_uuid' => 'required|string|exists:customers,uuid',
|
||||
'to' => ['required', 'string', 'max:1000', $this->multiEmailRule()],
|
||||
'cc' => ['nullable', 'string', 'max:1000', $this->multiEmailRule()],
|
||||
'date' => 'required|date',
|
||||
'service_name' => 'nullable|string|max:255',
|
||||
'services' => 'nullable|array',
|
||||
'services.*' => 'nullable|string|max:255',
|
||||
'note' => 'nullable|string|max:2000',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
...MailAttachments::rules(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateRevocation(Request $request, bool $withRecipients = false): array
|
||||
{
|
||||
return $request->validate([
|
||||
'ids' => 'required|array|min:1|max:1000',
|
||||
'ids.*' => 'integer',
|
||||
'date' => 'required|date',
|
||||
'note' => 'nullable|string|max:2000',
|
||||
'recipients' => $withRecipients ? 'required|array|min:1' : 'prohibited',
|
||||
'recipients.*.key' => 'required|string',
|
||||
'recipients.*.to' => ['nullable', 'string', 'max:1000', $this->multiEmailRule()],
|
||||
'recipients.*.cc' => ['nullable', 'string', 'max:1000', $this->multiEmailRule()],
|
||||
'recipients.*.subject' => 'nullable|string|max:255',
|
||||
]);
|
||||
}
|
||||
|
||||
private function allocationCustomer(array $data): Customer
|
||||
{
|
||||
return Customer::where('uuid', $data['customer_uuid'])->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\IpPool\Concerns;
|
||||
|
||||
use App\Imports\IpPoolImport;
|
||||
use App\Services\IpPool\IpPoolSheetImporter;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleIpAddress;
|
||||
use App\Models\ModuleNetwork;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Maatwebsite\Excel\Validators\ValidationException;
|
||||
|
||||
trait ManagesIpPoolBulkActions
|
||||
{
|
||||
@@ -94,39 +92,24 @@ trait ManagesIpPoolBulkActions
|
||||
]);
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse
|
||||
public function importPreview(Request $request, IpPoolSheetImporter $importer): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt|max:10240',
|
||||
]);
|
||||
$request->validate(['file' => self::IMPORT_FILE_RULE]);
|
||||
|
||||
$countBefore = IpPool::withTrashed()->count();
|
||||
return response()->json(['error' => false, 'data' => $importer->preview($request->file('file'))]);
|
||||
}
|
||||
|
||||
try {
|
||||
Excel::import(new IpPoolImport(), $request->file('file'));
|
||||
} catch (ValidationException $e) {
|
||||
$errors = [];
|
||||
foreach ($e->failures() as $failure) {
|
||||
$errors[] = 'Dòng ' . $failure->row() . ': ' . implode(', ', $failure->errors());
|
||||
}
|
||||
public function import(Request $request, IpPoolSheetImporter $importer): JsonResponse
|
||||
{
|
||||
$request->validate(['file' => self::IMPORT_FILE_RULE]);
|
||||
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Import thất bại',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => true, 'message' => 'Import thất bại: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
$countAfter = IpPool::withTrashed()->count();
|
||||
|
||||
IpPool::resyncAllContacts();
|
||||
$summary = $importer->import($request->file('file'));
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Import thành công',
|
||||
'created' => max(0, $countAfter - $countBefore),
|
||||
'message' => "Đã thêm {$summary['create']}, cập nhật {$summary['update']} IP"
|
||||
. ($summary['error_count'] ? ", bỏ qua {$summary['error_count']} dòng lỗi" : ''),
|
||||
'data' => $summary,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -142,7 +125,7 @@ trait ManagesIpPoolBulkActions
|
||||
$updated = 0;
|
||||
$hostByNet = ModuleNetwork::pluck('module_host_id', 'id');
|
||||
|
||||
IpPool::chunkById(500, function ($pools) use (&$created, &$updated, $hostByNet) {
|
||||
IpPool::whereNotNull('module_network_id')->chunkById(500, function ($pools) use (&$created, &$updated, $hostByNet) {
|
||||
foreach ($pools as $pool) {
|
||||
$hostId = $hostByNet[$pool->module_network_id] ?? null;
|
||||
$netmask = $pool->netmask ?: '255.255.255.0';
|
||||
|
||||
@@ -6,14 +6,17 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleIpAddress;
|
||||
use App\Models\ModuleNetwork;
|
||||
use App\Services\IpPool\IpPoolUsageResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Api\Admin\IpPool\Concerns\ManagesIpPoolAllocation;
|
||||
use App\Http\Controllers\Api\Admin\IpPool\Concerns\ManagesIpPoolBulkActions;
|
||||
use App\Http\Controllers\Api\Admin\IpPool\Concerns\ReadsIpPoolUsage;
|
||||
|
||||
class IpPoolController extends Controller
|
||||
{
|
||||
use ManagesIpPoolAllocation;
|
||||
use ManagesIpPoolBulkActions;
|
||||
use ReadsIpPoolUsage;
|
||||
|
||||
@@ -22,13 +25,15 @@ class IpPoolController extends Controller
|
||||
* status/service_id which have defaulting + module_ip_address mirror logic below.
|
||||
*/
|
||||
private const EDITABLE_FIELDS = [
|
||||
'owner_type', 'ip_role', 'parent_ip', 'customer_name', 'service_name',
|
||||
'allocated_at', 'revoked_at', 'allocation_status',
|
||||
'ip_block', 'owner_type', 'ip_role', 'parent_ip', 'customer_name', 'service_name',
|
||||
'allocated_at', 'revoked_at',
|
||||
'contact_email', 'contact_cc', 'contact_phone',
|
||||
'monthly_cost', 'selling_price', 'additional_info',
|
||||
'last_email_type', 'last_email_at', 'updated_by', 'note',
|
||||
];
|
||||
|
||||
private const IMPORT_FILE_RULE = 'required|file|extensions:xlsx,xls,csv|max:20480';
|
||||
|
||||
/**
|
||||
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon.
|
||||
*/
|
||||
@@ -44,14 +49,14 @@ class IpPoolController extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
public function index(Request $request, IpPoolUsageResolver $usageResolver): JsonResponse
|
||||
{
|
||||
// 'all' lets the admin grid load the full dataset once and filter/sort client-side.
|
||||
$perPage = $request->boolean('all') ? 20000 : min((int) $request->get('per_page', 15), 500);
|
||||
$filter = $request->get('filter');
|
||||
$networkId = $request->get('network_id');
|
||||
|
||||
$query = IpPool::with(['network:id,name,type,network', 'service:id,server_name,status,customer_id']);
|
||||
$query = IpPool::with(['network:id,name,type,network', 'service:id,server_name,status,customer_id', 'customer:id,uuid,fullname']);
|
||||
|
||||
if ($filter) {
|
||||
$query->where('ip_address', 'like', "%{$filter}%");
|
||||
@@ -62,61 +67,12 @@ class IpPoolController extends Controller
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage);
|
||||
$usage = $usageResolver->forPools($paginator->getCollection());
|
||||
|
||||
// Enrich each item with usage info
|
||||
$usedIps = ModuleIpAddress::whereIn('ip', $paginator->pluck('ip_address')->toArray())
|
||||
->where('status', 1)
|
||||
->with('host:id,name')
|
||||
->select(['id', 'ip', 'vm_name', 'module_host_id', 'module_network_id', 'status'])
|
||||
->get()
|
||||
->keyBy('ip');
|
||||
|
||||
// Get vmware info for used IPs
|
||||
$vmwareMap = [];
|
||||
if ($usedIps->isNotEmpty()) {
|
||||
$vmwares = DB::table('module_vmware as mv')
|
||||
->join('module_ip_address as mia', 'mv.module_ip_address', '=', 'mia.id')
|
||||
->whereIn('mia.ip', $usedIps->keys()->toArray())
|
||||
->leftJoin('services as s', 's.id', '=', 'mv.service_id')
|
||||
->leftJoin('customers as c', 'c.id', '=', 'mv.customer_id')
|
||||
->select([
|
||||
'mia.ip',
|
||||
'mv.name as vm_name',
|
||||
'mv.id as vm_id',
|
||||
's.id as service_id',
|
||||
's.status as service_status',
|
||||
'c.fullname',
|
||||
'c.email',
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($vmwares as $vm) {
|
||||
$vmwareMap[$vm->ip] = $vm;
|
||||
}
|
||||
}
|
||||
|
||||
$items = $paginator->map(function (IpPool $pool) use ($usedIps, $vmwareMap) {
|
||||
$data = $pool->toArray();
|
||||
$ip = $pool->ip_address;
|
||||
|
||||
if (isset($usedIps[$ip])) {
|
||||
$mia = $usedIps[$ip];
|
||||
$vm = $vmwareMap[$ip] ?? null;
|
||||
$data['usage'] = [
|
||||
'assigned' => true,
|
||||
'vm_name' => $vm?->vm_name ?? $mia->vm_name,
|
||||
'vm_id' => $vm?->vm_id ?? null,
|
||||
'service_id' => $vm?->service_id ?? null,
|
||||
'service_status' => $vm?->service_status ?? null,
|
||||
'customer_name' => $vm?->fullname ?? null,
|
||||
'customer_email' => $vm?->email ?? null,
|
||||
];
|
||||
} else {
|
||||
$data['usage'] = ['assigned' => false];
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
$items = $paginator->map(fn (IpPool $pool) => [
|
||||
...$pool->toArray(),
|
||||
'usage' => $usage[$pool->ip_address] ?? ['assigned' => false],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
|
||||
@@ -5,7 +5,12 @@ namespace App\Http\Controllers\Api\Admin\Order;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\OrderRequest;
|
||||
use App\Http\Requests\UpdateOrderRequest;
|
||||
use App\Exports\BrandedReportWriter;
|
||||
use App\Http\Requests\BillingExportRequest;
|
||||
use App\Exports\OrdersExport;
|
||||
use App\Http\Resources\Api\Admin\OrderItemServiceResource;
|
||||
use App\Http\Resources\Api\Admin\OrderResource;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use App\Models\Order;
|
||||
use App\Models\DomainPricing;
|
||||
use App\Repositories\Order\OrderRepositoryInterface;
|
||||
@@ -27,7 +32,13 @@ class OrderController extends Controller
|
||||
|
||||
$data = $this->orderRepository->filter(
|
||||
$request->all(),
|
||||
['payment', 'customer', 'invoice'],
|
||||
[
|
||||
'payment',
|
||||
'customer',
|
||||
'invoice',
|
||||
'orderItems.os',
|
||||
...OrderItemServiceResource::relationsFor('orderItems'),
|
||||
],
|
||||
[],
|
||||
['*'],
|
||||
true
|
||||
@@ -43,6 +54,19 @@ class OrderController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(BillingExportRequest $request, BrandedReportWriter $writer): BinaryFileResponse
|
||||
{
|
||||
$query = $this->orderRepository->filter(
|
||||
array_filter($request->validated(), fn ($value) => $value !== null && $value !== []),
|
||||
['payment', 'customer', 'invoices:id,order_id,invoice_number', ...OrderItemServiceResource::relationsFor('orderItems')],
|
||||
[],
|
||||
['*'],
|
||||
true
|
||||
);
|
||||
|
||||
return $writer->download(new OrdersExport($query), 'don-hang-' . now()->format('Y-m-d-His') . '.xlsx');
|
||||
}
|
||||
|
||||
public function domainPricing(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Service\Concerns;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceAttachment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAttachment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
trait HandlesServiceAttachments
|
||||
{
|
||||
public function listAttachments(int $id): JsonResponse
|
||||
{
|
||||
$service = Service::with('orderItem')->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => [
|
||||
'attachments' => ServiceAttachment::where('service_id', $id)->latest()->get(),
|
||||
'invoices' => $this->linkedInvoices(collect([$service])),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadAttachment(Request $request, int $id): JsonResponse
|
||||
{
|
||||
Service::findOrFail($id);
|
||||
$request->validate(['file' => InvoiceAttachment::UPLOAD_RULE]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$attachment = ServiceAttachment::create([
|
||||
'service_id' => $id,
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'path' => $file->store("service-attachments/{$id}", 'public'),
|
||||
'disk' => 'public',
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'uploaded_by' => $request->user()?->username ?? $request->user()?->name ?? 'admin',
|
||||
]);
|
||||
|
||||
return response()->json(['error' => false, 'message' => 'File uploaded successfully', 'data' => $attachment], 201);
|
||||
}
|
||||
|
||||
public function deleteAttachment(int $id, int $attachmentId): JsonResponse
|
||||
{
|
||||
$attachment = ServiceAttachment::where('service_id', $id)->findOrFail($attachmentId);
|
||||
Storage::disk($attachment->disk)->delete($attachment->path);
|
||||
$attachment->delete();
|
||||
|
||||
return response()->json(['error' => false, 'message' => 'Attachment deleted']);
|
||||
}
|
||||
|
||||
public function copyAttachmentToInvoice(Request $request, int $id, int $attachmentId): JsonResponse
|
||||
{
|
||||
$service = Service::with('orderItem')->findOrFail($id);
|
||||
$attachment = ServiceAttachment::where('service_id', $id)->findOrFail($attachmentId);
|
||||
$invoiceId = (int) $request->validate(['invoice_id' => 'required|integer'])['invoice_id'];
|
||||
$invoice = Invoice::ofServices([$service])->findOrFail($invoiceId);
|
||||
|
||||
$path = "invoice-attachments/{$invoice->id}/" . basename($attachment->path);
|
||||
if (!Storage::disk('public')->exists($path)) {
|
||||
Storage::disk('public')->put($path, Storage::disk($attachment->disk)->readStream($attachment->path));
|
||||
}
|
||||
|
||||
$copy = InvoiceAttachment::firstOrCreate(
|
||||
['invoice_id' => $invoice->id, 'path' => $path],
|
||||
[
|
||||
'type' => InvoiceAttachment::TYPE_DEPLOYMENT,
|
||||
'filename' => $attachment->filename,
|
||||
'disk' => 'public',
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'size' => $attachment->size,
|
||||
'uploaded_by' => $request->user()?->username ?? $request->user()?->name ?? 'admin',
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json(['error' => false, 'message' => 'Attachment copied to invoice', 'data' => $copy], 201);
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,31 @@
|
||||
namespace App\Http\Controllers\Api\Admin\Service;
|
||||
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Http\Controllers\Api\Admin\Service\Concerns\HandlesServiceAttachments;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\VirtualMachine\Provision\ResendVmCredentialsEmailJob;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\DomainPricing;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Models\Service;
|
||||
use App\Services\Customer\CustomerServiceAssignment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
use HandlesServiceAttachments;
|
||||
|
||||
public function __construct(private readonly CustomerServiceAssignment $assignment) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) ($request->per_page ?? 15), 100);
|
||||
|
||||
$query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address'])
|
||||
$query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address', 'orderItem:id,order_id', 'orderItem.order:id,order_num'])
|
||||
->where(fn($q) => $q->whereNull('order_item_id')->orWhereNull('type')->orWhere('type', '!=', 'domain'))
|
||||
->when($request->filter, fn($q, $v) => $q->where('server_name', 'like', "%{$v}%"))
|
||||
->when($request->status, fn($q, $v) => $q->where('status', $v))
|
||||
@@ -30,6 +35,11 @@ class ServiceController extends Controller
|
||||
->when($request->boolean('unassigned'), fn($q) => $q->whereNull('customer_id'));
|
||||
|
||||
$paginator = $query->latest()->paginate($perPage);
|
||||
$invoices = $this->linkedInvoices($paginator->getCollection());
|
||||
$paginator->getCollection()->each(fn (Service $service) => $service->setAttribute(
|
||||
'linked_invoices',
|
||||
$invoices->filter(fn (Invoice $invoice) => $invoice->belongsToService($service))->values(),
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
@@ -72,11 +82,12 @@ class ServiceController extends Controller
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$service = Service::with(['customer', 'product', 'orderItem', 'invoice', 'vmware.os.osGroup', 'addons', ...Service::HOSTING_ACCOUNT_RELATIONS])
|
||||
$service = Service::with(['customer', 'product', 'orderItem.order:id,order_num', 'invoice', 'vmware.os.osGroup', 'addons', ...Service::HOSTING_ACCOUNT_RELATIONS])
|
||||
->findOrFail($id);
|
||||
|
||||
ModuleVmware::resolveVirtualMachineIds([$service->vmware]);
|
||||
|
||||
$service->setAttribute('linked_invoices', $this->linkedInvoices(collect([$service])));
|
||||
$service->setAttribute('activity_logs', ActivityLog::where('subject_type', Service::class)
|
||||
->where('subject_id', $service->id)
|
||||
->latest('id')
|
||||
@@ -86,6 +97,15 @@ class ServiceController extends Controller
|
||||
return response()->json(['error' => false, 'data' => $service]);
|
||||
}
|
||||
|
||||
/** @param Collection<int, Service> $services */
|
||||
private function linkedInvoices(Collection $services): Collection
|
||||
{
|
||||
return Invoice::ofServices($services)
|
||||
->with('order:id,order_num')
|
||||
->latest('id')
|
||||
->get(['id', 'order_id', 'service_id', 'invoice_number', 'invoice_type', 'status', 'total', 'created_at']);
|
||||
}
|
||||
|
||||
public function bulkAction(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
||||
@@ -8,8 +8,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Facade\SignRSA;
|
||||
use App\Http\Services\Telegram;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Cache\CacheKey;
|
||||
use App\Support\PublicSetting;
|
||||
use App\Support\MailSettings;
|
||||
use App\Support\Turnstile;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -115,8 +115,7 @@ class SettingController extends Controller
|
||||
['value' => json_encode($merged, JSON_UNESCAPED_UNICODE)]
|
||||
);
|
||||
|
||||
// Flush mail config cache so new SMTP settings take effect immediately
|
||||
Cache::forget(CacheKey::build('settings', 'mail'));
|
||||
Cache::forget(MailSettings::cacheKey());
|
||||
}
|
||||
|
||||
if ($request->has('turnstile')) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class StaffOnlineController extends Controller
|
||||
|
||||
$online = $users->map(fn ($user) => [
|
||||
'user_id' => $user->id,
|
||||
'name' => trim($user->firstname . ' ' . $user->lastname),
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'email' => $user->email,
|
||||
'last_visited' => $user->staffOnline?->last_visited,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\WebDesign;
|
||||
|
||||
use App\Enums\WebDesign\WebDesignPlatform;
|
||||
use App\Enums\WebDesign\WebDesignQuoteStatus;
|
||||
use App\Exports\WebDesignQuoteSpreadsheet;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WebDesignQuote;
|
||||
use App\Services\WebDesignQuotePdfService;
|
||||
@@ -11,6 +12,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class WebDesignQuoteController extends Controller
|
||||
{
|
||||
@@ -18,7 +20,7 @@ class WebDesignQuoteController extends Controller
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 15), 100);
|
||||
|
||||
$query = WebDesignQuote::with('creator:id,firstname,lastname')
|
||||
$query = WebDesignQuote::with('creator:id,fullname')
|
||||
->when($request->get('filter'), fn($q, $f) =>
|
||||
$q->where(fn($sub) => $sub
|
||||
->where('quote_number', 'like', "%{$f}%")
|
||||
@@ -47,7 +49,7 @@ class WebDesignQuoteController extends Controller
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$quote = WebDesignQuote::with('creator:id,firstname,lastname')->find($id);
|
||||
$quote = WebDesignQuote::with('creator:id,fullname')->find($id);
|
||||
|
||||
if (!$quote) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404);
|
||||
@@ -92,7 +94,7 @@ class WebDesignQuoteController extends Controller
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Cập nhật báo giá thành công.',
|
||||
'data' => $quote->fresh('creator:id,firstname,lastname'),
|
||||
'data' => $quote->fresh('creator:id,fullname'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -149,6 +151,17 @@ class WebDesignQuoteController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadXlsx(int $id, WebDesignQuoteSpreadsheet $spreadsheet): BinaryFileResponse|JsonResponse
|
||||
{
|
||||
$quote = WebDesignQuote::find($id);
|
||||
|
||||
if (!$quote) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy báo giá.'], 404);
|
||||
}
|
||||
|
||||
return $spreadsheet->download($quote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Danh sách cấu hình nền tảng cho form báo giá (label, frontend, custom admin, phí bản quyền).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillingExportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'filter' => 'nullable|string|max:255',
|
||||
'status' => 'nullable|array',
|
||||
'status.*' => 'integer|between:0,5',
|
||||
'date_from' => 'nullable|date',
|
||||
'date_to' => 'nullable|date|after_or_equal:date_from',
|
||||
'customer_id' => 'nullable|integer',
|
||||
'payment_method' => 'nullable|string|max:50',
|
||||
'invoice_type' => 'nullable|integer|in:1,2,3',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdateAdminPasswordRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => ['required', 'string'],
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
Password::min(10),
|
||||
'confirmed',
|
||||
'different:current_password',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
$user = $this->user('admin');
|
||||
|
||||
if ($user && ! Hash::check((string) $this->input('current_password'), $user->password)) {
|
||||
$validator->errors()->add('current_password', 'Mật khẩu hiện tại không chính xác.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'current_password.required' => 'Vui lòng nhập mật khẩu hiện tại.',
|
||||
'password.required' => 'Vui lòng nhập mật khẩu mới.',
|
||||
'password.min' => 'Mật khẩu mới phải có ít nhất 10 ký tự.',
|
||||
'password.confirmed' => 'Xác nhận mật khẩu mới không khớp.',
|
||||
'password.different' => 'Mật khẩu mới phải khác mật khẩu hiện tại.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@ class UpdateProfileRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'firstname' => 'required|string|max:255',
|
||||
'lastname' => 'required|string|max:255',
|
||||
'fullname' => 'required|string|min:2|max:255',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department' => ['nullable', Rule::in(['IT', 'Sale', 'Marketing', 'Customer Service', 'Accounting', 'Management'])],
|
||||
'start_date' => 'nullable|date',
|
||||
|
||||
@@ -13,6 +13,10 @@ class InvoiceResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'invoice_number' => $this->invoice_number,
|
||||
'order_num' => $this->whenLoaded('order', fn() => $this->order?->order_num),
|
||||
'order_items' => $this->when(
|
||||
$this->relationLoaded('order') && $this->order?->relationLoaded('orderItems'),
|
||||
fn() => OrderItemServiceResource::collection($this->order->orderItems)->resolve(),
|
||||
),
|
||||
'customer' => $this->whenLoaded('customer', fn() => [
|
||||
'id' => $this->customer->id,
|
||||
'uuid' => $this->customer->uuid,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Api\Admin;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class OrderItemServiceResource extends JsonResource
|
||||
{
|
||||
public const RELATIONS = ['product:id,name,product_type', 'service'];
|
||||
|
||||
public static function relationsFor(string $orderItemsPath): array
|
||||
{
|
||||
return array_map(fn ($relation) => "{$orderItemsPath}.{$relation}", self::RELATIONS);
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'server_name' => $this->server_name,
|
||||
'domain_name' => $this->domain_name,
|
||||
'product' => $this->product ? [
|
||||
'id' => $this->product->id,
|
||||
'name' => $this->product->name,
|
||||
'product_type' => $this->product->product_type,
|
||||
] : null,
|
||||
'service' => $this->service ? [
|
||||
'id' => $this->service->id,
|
||||
'vm_id' => $this->service->vm_id,
|
||||
'server_name' => $this->service->server_name,
|
||||
'type' => $this->service->type,
|
||||
'status' => $this->service->status,
|
||||
'started_at' => $this->service->started_at,
|
||||
'expires_at' => $this->service->expires_at,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@ class OrderResource extends JsonResource
|
||||
]),
|
||||
'user' => $this->whenLoaded('user', fn() => [
|
||||
'id' => $this->user->id,
|
||||
'firstname' => $this->user->firstname,
|
||||
'lastname' => $this->user->lastname,
|
||||
'fullname' => $this->user->fullname,
|
||||
'email' => $this->user->email,
|
||||
]),
|
||||
'payment' => $this->whenLoaded('payment', fn() => [
|
||||
@@ -117,34 +116,19 @@ class OrderResource extends JsonResource
|
||||
'status' => $vm->status,
|
||||
'next_due_date' => $vm->next_due_date,
|
||||
])),
|
||||
'order_items' => $this->whenLoaded('orderItems', fn() => $this->orderItems->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product' => $item->product ? [
|
||||
'id' => $item->product->id,
|
||||
'name' => $item->product->name,
|
||||
'product_type' => $item->product->product_type,
|
||||
] : null,
|
||||
'os' => $item->os ? [
|
||||
'id' => $item->os->id,
|
||||
'name' => $item->os->name,
|
||||
'version' => $item->os->version,
|
||||
] : null,
|
||||
'billing_cycle' => $item->billing_cycle,
|
||||
'months' => $item->months,
|
||||
'expiry_date' => $item->expiry_date,
|
||||
'quantity' => $item->quantity,
|
||||
'server_name' => $item->server_name,
|
||||
'price' => $item->price,
|
||||
'service' => $item->service ? [
|
||||
'id' => $item->service->id,
|
||||
'vm_id' => $item->service->vm_id,
|
||||
'status' => $item->service->status,
|
||||
'started_at' => $item->service->started_at,
|
||||
'expires_at' => $item->service->expires_at,
|
||||
] : null,
|
||||
];
|
||||
})),
|
||||
'order_items' => $this->whenLoaded('orderItems', fn() => $this->orderItems->map(fn($item) => [
|
||||
...OrderItemServiceResource::make($item)->resolve(),
|
||||
'os' => $item->os ? [
|
||||
'id' => $item->os->id,
|
||||
'name' => $item->os->name,
|
||||
'version' => $item->os->version,
|
||||
] : null,
|
||||
'billing_cycle' => $item->billing_cycle,
|
||||
'months' => $item->months,
|
||||
'expiry_date' => $item->expiry_date,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleNetwork;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
use Maatwebsite\Excel\Concerns\WithUpsertColumns;
|
||||
use Maatwebsite\Excel\Concerns\WithUpserts;
|
||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
|
||||
class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpserts, WithUpsertColumns
|
||||
{
|
||||
/** @var array<string, ModuleNetwork> */
|
||||
private array $networkCache = [];
|
||||
|
||||
public function model(array $row)
|
||||
{
|
||||
$network = $this->resolveNetwork($row['ten_mang'] ?? null, $row['cidr'] ?? null);
|
||||
|
||||
return new IpPool([
|
||||
// uuid has no DB default and must be set on insert; excluded from upsertColumns() so it's never overwritten
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'ip_address' => $row['ip_address'],
|
||||
'module_network_id' => $network?->id,
|
||||
// ip_pools.cidr is NOT NULL; fall back to the network's own prefix (or 24) when the row omits it
|
||||
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null)
|
||||
?? $this->parsePrefixLength($network?->network)
|
||||
?? 24,
|
||||
'gateway' => $row['gateway'] ?? null,
|
||||
'netmask' => $row['subnet_mask'] ?? null,
|
||||
'owner_type' => $row['owner_type'] ?? null,
|
||||
'ip_role' => $row['ip_role'] ?? null,
|
||||
'parent_ip' => $row['parent_ip'] ?? null,
|
||||
'customer_name' => $row['ten_khach_hang'] ?? null,
|
||||
'service_name' => $row['ten_dich_vu'] ?? null,
|
||||
'allocated_at' => $this->parseDate($row['ngay_cap_phat'] ?? null),
|
||||
'revoked_at' => $this->parseDate($row['ngay_thu_hoi'] ?? null),
|
||||
'allocation_status' => $row['trang_thai'] ?? null,
|
||||
'contact_email' => $row['contact_email'] ?? null,
|
||||
'contact_cc' => $row['contact_cc'] ?? null,
|
||||
'contact_phone' => $row['contact_phone'] ?? null,
|
||||
'monthly_cost' => $row['monthly_cost'] ?: null,
|
||||
'selling_price' => $row['selling_price'] ?: null,
|
||||
'additional_info' => $row['thong_tin_bo_sung'] ?? null,
|
||||
'last_email_type' => $row['last_email_type'] ?? null,
|
||||
'last_email_at' => $this->parseDate($row['last_email_at'] ?? null),
|
||||
'updated_by' => $row['updated_by'] ?? null,
|
||||
'note' => $row['ghi_chu'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$multiEmail = function (string $attribute, mixed $value, \Closure $fail) {
|
||||
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
|
||||
$email = trim($email);
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$fail("Email không hợp lệ: {$email}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'ten_mang' => 'required|string|max:255',
|
||||
'cidr' => 'nullable|string|max:45',
|
||||
'ip_address' => 'required|ip',
|
||||
'contact_email' => ['nullable', 'string', $multiEmail],
|
||||
'contact_cc' => ['nullable', 'string', $multiEmail],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV dates come as "dd/mm/yyyy" or "dd/mm/yyyy H:i:s" (Vietnamese sheet export).
|
||||
*/
|
||||
private function parseDate(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('d/m/Y H:i:s', $value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
// not a datetime, try date-only below
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('d/m/Y', $value)->startOfDay()->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV gives CIDR as "/22"; the ip_pools.cidr column only stores the prefix length.
|
||||
*/
|
||||
private function parsePrefixLength(?string $cidr): ?int
|
||||
{
|
||||
// "/22" or "111.111.111.0/22" -> 22
|
||||
return preg_match('/(\d+)$/', (string) $cidr, $m) ? (int) $m[1] : null;
|
||||
}
|
||||
|
||||
public function uniqueBy(): array
|
||||
{
|
||||
return ['ip_address', 'module_network_id'];
|
||||
}
|
||||
|
||||
public function upsertColumns(): array
|
||||
{
|
||||
return [
|
||||
'cidr', 'gateway', 'netmask', 'owner_type', 'ip_role', 'parent_ip',
|
||||
'customer_name', 'service_name', 'allocated_at', 'revoked_at', 'allocation_status',
|
||||
'contact_email', 'contact_cc', 'contact_phone', 'monthly_cost', 'selling_price',
|
||||
'additional_info', 'last_email_type', 'last_email_at', 'updated_by', 'note',
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveNetwork(?string $name, ?string $cidr): ?ModuleNetwork
|
||||
{
|
||||
if (!$name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = $name . '|' . $cidr;
|
||||
|
||||
if (isset($this->networkCache[$key])) {
|
||||
return $this->networkCache[$key];
|
||||
}
|
||||
|
||||
$network = ModuleNetwork::where('name', $name)->first()
|
||||
?? ($cidr ? ModuleNetwork::where('network', $cidr)->first() : null)
|
||||
?? ModuleNetwork::create(['name' => $name, 'network' => $cidr]);
|
||||
|
||||
return $this->networkCache[$key] = $network;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Jobs\VirtualMachine;
|
||||
|
||||
use App\Actions\VM\ChangeIpVMAction;
|
||||
use App\Http\Services\Telegram;
|
||||
use App\Models\IpPoolLog;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Services\VirtualMachine\VmIpReservationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -79,8 +80,10 @@ class ChangeIpVMJob implements ShouldQueue
|
||||
}
|
||||
} else {
|
||||
$vm->update(['ip' => $this->ipAddress, 'dns' => $this->dns]);
|
||||
IpPoolLog::recordVm($this->newIpPoolId, IpPoolLog::VM_ASSIGNED, 'free', $vm, 'Đổi IP VM');
|
||||
if ($this->oldIpPoolId) {
|
||||
$ipReservation->releaseByPoolId($this->oldIpPoolId);
|
||||
IpPoolLog::recordVm($this->oldIpPoolId, IpPoolLog::VM_RELEASED, 'assigned', $vm, 'Đổi IP VM sang ' . $this->ipAddress);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Jobs\VirtualMachine\Provision\Concerns;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleIpAddress;
|
||||
use App\Models\IpPoolLog;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Models\Service;
|
||||
use App\Models\VirtualMachines;
|
||||
@@ -164,10 +165,12 @@ trait ManagesIpReservation
|
||||
|
||||
// Flip ip_pools row to assigned and link service
|
||||
if ($ipPoolId) {
|
||||
$statusFrom = (string) IpPool::whereKey($ipPoolId)->value('status');
|
||||
IpPool::where('id', $ipPoolId)->update([
|
||||
'status' => 'assigned',
|
||||
'service_id' => $serviceId,
|
||||
]);
|
||||
IpPoolLog::recordVm($ipPoolId, IpPoolLog::VM_ASSIGNED, $statusFrom, $moduleVmware, 'Tạo VM');
|
||||
}
|
||||
|
||||
// Touch services row so server_name reflects the provisioned VM.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail\Concerns;
|
||||
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
|
||||
trait AttachesStoredFiles
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{disk: string, path: string, name: string}> $files
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
protected function storedFileAttachments(array $files): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $file) => Attachment::fromStorageDisk($file['disk'], $file['path'])->as($file['name']),
|
||||
$files,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail\Customer;
|
||||
|
||||
use App\Mail\Concerns\AttachesStoredFiles;
|
||||
use App\Models\Template;
|
||||
use App\Support\EmailTemplateShell;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -13,12 +14,13 @@ use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ManualCustomerEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
use Queueable, SerializesModels, AttachesStoredFiles;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $emailSubject,
|
||||
private readonly string $htmlContent,
|
||||
private readonly bool $wrapLayout = true,
|
||||
private readonly array $storedFiles = [],
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
@@ -37,6 +39,6 @@ class ManualCustomerEmail extends Mailable implements ShouldQueue
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
return $this->storedFileAttachments($this->storedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\AttachesStoredFiles;
|
||||
use App\Mail\Concerns\BuildsTemplateEnvelope;
|
||||
use App\Support\AutoEmailGate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -17,13 +18,14 @@ use Illuminate\Queue\SerializesModels;
|
||||
*/
|
||||
class GenericTemplateMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels, BuildsTemplateEnvelope;
|
||||
use Queueable, SerializesModels, BuildsTemplateEnvelope, AttachesStoredFiles;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $renderedSubject,
|
||||
public readonly string $renderedHtml,
|
||||
public readonly array $mailConfig = [],
|
||||
public readonly ?string $scenario = null,
|
||||
public readonly array $storedFiles = [],
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
@@ -36,6 +38,11 @@ class GenericTemplateMail extends Mailable
|
||||
return new Content(htmlString: $this->renderedHtml);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return $this->storedFileAttachments($this->storedFiles);
|
||||
}
|
||||
|
||||
public function buildViewData()
|
||||
{
|
||||
return [...parent::buildViewData(), AutoEmailGate::SCENARIO_DATA_KEY => $this->scenario];
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\AttachesStoredFiles;
|
||||
use App\Models\IpPool;
|
||||
use App\Support\EmailTemplateShell;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
||||
class IpAllocationMail extends TemplateMailable
|
||||
{
|
||||
use Queueable, AttachesStoredFiles;
|
||||
|
||||
public const ALLOCATED = 'ip_allocated';
|
||||
public const REVOKED = 'ip_revoked';
|
||||
|
||||
private const CELL = "padding: 13px 14px; font-size: 15px; text-align: center; font-family: 'Times New Roman', Times, serif; color: #2b2b2b;";
|
||||
|
||||
private const HEAD_CELL = "padding: 11px 14px; color: #c2410c; font-size: 13px; font-weight: bold; letter-spacing: .4px; border-bottom: 1px solid #f6d2b8; background: #fff5ee; text-align: center; font-family: 'Times New Roman', Times, serif;";
|
||||
|
||||
/**
|
||||
* @param array<int, array{ip_address: string, cidr: ?int, gateway: ?string, service_name: ?string}> $ips
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $type,
|
||||
public readonly string $clientName,
|
||||
public readonly array $ips,
|
||||
public readonly string $date,
|
||||
public readonly ?string $note = null,
|
||||
public readonly ?string $subjectOverride = null,
|
||||
public readonly array $storedFiles = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param iterable<int, IpPool> $pools
|
||||
* @return array<int, array{ip_address: string, cidr: ?int, gateway: ?string, service_name: ?string}>
|
||||
*/
|
||||
public static function ipsFrom(iterable $pools): array
|
||||
{
|
||||
return collect($pools)
|
||||
->map(fn (IpPool $pool) => $pool->only(['ip_address', 'cidr', 'gateway', 'service_name']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return $this->storedFileAttachments($this->storedFiles);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$envelope = parent::envelope();
|
||||
if (filled($this->subjectOverride)) {
|
||||
$envelope->subject = trim($this->subjectOverride);
|
||||
}
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
protected function scenario(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
protected function variables(): array
|
||||
{
|
||||
$carbon = Carbon::parse($this->date);
|
||||
$date = $carbon->format('d/m/Y');
|
||||
|
||||
return [
|
||||
'client_name' => $this->clientName,
|
||||
'ip_count' => (string) count($this->ips),
|
||||
'ip_rows' => collect($this->ips)->map(fn (array $ip) => EmailTemplateShell::row(
|
||||
e($ip['ip_address']),
|
||||
e(implode(' · ', array_filter([
|
||||
$ip['cidr'] ? '/' . $ip['cidr'] : null,
|
||||
$ip['gateway'] ? 'Gateway ' . $ip['gateway'] : null,
|
||||
])) ?: '—'),
|
||||
))->implode(''),
|
||||
'ip_table' => $this->ipTable($date),
|
||||
'issued_on' => "ngày {$carbon->day} tháng {$carbon->month} năm {$carbon->year}",
|
||||
'allocated_at' => $date,
|
||||
'revoked_at' => $date,
|
||||
'note_block' => $this->note ? EmailTemplateShell::note(nl2br(e($this->note)), 'Ghi chú:') : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function ipTable(string $date): string
|
||||
{
|
||||
$last = count($this->ips) - 1;
|
||||
$rows = collect($this->ips)->values()->map(function (array $ip, int $index) use ($date, $last) {
|
||||
$border = $index === $last ? '' : ' border-bottom: 1px solid #f6d2b8;';
|
||||
$cell = fn (string $value, string $extra = '') => '<td style="' . self::CELL . $border . $extra . '">' . $value . '</td>';
|
||||
|
||||
return '<tr>'
|
||||
. $cell((string) ($index + 1), ' width: 60px;')
|
||||
. $cell(e($ip['ip_address']), " font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #c2410c;")
|
||||
. $cell(e($ip['service_name'] ?: '—'))
|
||||
. $cell($date)
|
||||
. '</tr>';
|
||||
})->implode('');
|
||||
|
||||
$head = collect(['STT', 'IP Address', 'Dịch vụ', 'Ngày cấp phát'])
|
||||
->map(fn (string $title) => '<td style="' . self::HEAD_CELL . '">' . $title . '</td>')
|
||||
->implode('');
|
||||
|
||||
return '<table style="border: 1px solid #f6d2b8; border-radius: 10px; border-collapse: separate; overflow: hidden;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
. '<tr>' . $head . '</tr>' . $rows
|
||||
. '</tbody></table>';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -41,6 +42,26 @@ class Invoice extends Model
|
||||
return '/hoa-don/' . ($this->order?->order_num ?? $this->invoice_number);
|
||||
}
|
||||
|
||||
/** @param iterable<Service> $services */
|
||||
public function scopeOfServices(Builder $query, iterable $services): Builder
|
||||
{
|
||||
$services = collect($services);
|
||||
$orderIds = $services->map(fn (Service $service) => $service->orderItem?->order_id)->filter()->unique()->values();
|
||||
|
||||
return $query->where(fn (Builder $q) => $q
|
||||
->whereIn('service_id', $services->pluck('id'))
|
||||
->orWhere(fn (Builder $q) => $q->whereNull('service_id')->whereIn('order_id', $orderIds)));
|
||||
}
|
||||
|
||||
public function belongsToService(Service $service): bool
|
||||
{
|
||||
if ($this->service_id !== null) {
|
||||
return (int) $this->service_id === (int) $service->id;
|
||||
}
|
||||
|
||||
return $this->order_id !== null && (int) $this->order_id === (int) $service->orderItem?->order_id;
|
||||
}
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class, 'order_id');
|
||||
|
||||
@@ -9,6 +9,7 @@ class InvoiceAttachment extends Model
|
||||
{
|
||||
public const TYPE_PAYMENT_PROOF = 'payment_proof';
|
||||
public const TYPE_DEPLOYMENT = 'deployment';
|
||||
public const UPLOAD_RULE = 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx|max:51200';
|
||||
|
||||
protected $table = 'invoice_attachments';
|
||||
|
||||
|
||||
+12
-1
@@ -15,6 +15,7 @@ class IpPool extends Model
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'ip_address',
|
||||
'ip_block',
|
||||
'gateway',
|
||||
'cidr',
|
||||
'netmask',
|
||||
@@ -24,10 +25,10 @@ class IpPool extends Model
|
||||
'ip_role',
|
||||
'parent_ip',
|
||||
'customer_name',
|
||||
'customer_id',
|
||||
'service_name',
|
||||
'allocated_at',
|
||||
'revoked_at',
|
||||
'allocation_status',
|
||||
'service_id',
|
||||
'contact_email',
|
||||
'contact_cc',
|
||||
@@ -73,6 +74,16 @@ class IpPool extends Model
|
||||
return $this->belongsTo(ModuleNetwork::class, 'module_network_id', 'id');
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(IpPoolLog::class)->latest('id');
|
||||
}
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class, 'service_id');
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class IpPoolLog extends Model
|
||||
{
|
||||
public const ALLOCATED = 'allocated';
|
||||
public const REVOKED = 'revoked';
|
||||
public const VM_ASSIGNED = 'vm_assigned';
|
||||
public const VM_RELEASED = 'vm_released';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $casts = ['effective_date' => 'date:Y-m-d'];
|
||||
|
||||
protected $fillable = [
|
||||
'ip_pool_id', 'ip_address', 'action', 'effective_date', 'status_from', 'status_to',
|
||||
'customer_id', 'customer_name', 'service_name', 'service_id', 'vm_name', 'email_to', 'user_id', 'note',
|
||||
];
|
||||
|
||||
public static function record(IpPool $pool, string $action, string $statusFrom, array $attributes = []): self
|
||||
{
|
||||
return self::create([
|
||||
'ip_pool_id' => $pool->id,
|
||||
'ip_address' => $pool->ip_address,
|
||||
'action' => $action,
|
||||
'status_from' => $statusFrom,
|
||||
'status_to' => $pool->status,
|
||||
'user_id' => auth('admin')->id(),
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordVm(?int $poolId, string $action, string $statusFrom, ModuleVmware $vm, ?string $note = null): void
|
||||
{
|
||||
$pool = $poolId ? IpPool::find($poolId) : null;
|
||||
if (!$pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = Service::where('id', $vm->service_id)->orWhere('vm_id', $vm->id)->first(['id', 'server_name']);
|
||||
|
||||
self::record($pool, $action, $statusFrom, [
|
||||
'vm_name' => $vm->name,
|
||||
'service_id' => $service?->id,
|
||||
'service_name' => $service?->server_name,
|
||||
'customer_id' => $vm->customer_id,
|
||||
'customer_name' => Customer::whereKey($vm->customer_id)->value('fullname'),
|
||||
'note' => $note,
|
||||
]);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ServiceAttachment extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return Storage::disk($this->disk)->url($this->path);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -64,7 +64,7 @@ class User extends Authenticatable implements JWTSubject
|
||||
|
||||
public function getNameAttribute(): string
|
||||
{
|
||||
return trim("{$this->firstname} {$this->lastname}") ?: trim((string) $this->fullname);
|
||||
return trim((string) $this->fullname);
|
||||
}
|
||||
|
||||
public function staffOnline()
|
||||
|
||||
@@ -2,84 +2,23 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Http\Services\SignRSA;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Cache\CacheKey;
|
||||
use App\Support\MailSettings;
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class MailConfigServiceProvider extends ServiceProvider
|
||||
{
|
||||
private static ?array $resolved = null;
|
||||
|
||||
public function register()
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
if (!App::runningInConsole()) {
|
||||
MailSettings::apply();
|
||||
|
||||
public function boot()
|
||||
{
|
||||
if (App::runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self::$resolved !== null) {
|
||||
Config::set('mail', self::$resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
$config = Cache::remember(CacheKey::build('settings', 'mail'), 3600, function () {
|
||||
if (!Schema::hasTable('setting')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mail = Setting::where('key', 'smtp')->first();
|
||||
$setting = Setting::where('key', 'setting')->first();
|
||||
|
||||
if (!$mail || !$setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mail = json_decode($mail->value);
|
||||
$company = json_decode($setting->value)->company ?? 'Your Company';
|
||||
|
||||
$password = '';
|
||||
if (isset($mail->password)) {
|
||||
$decrypted = (new SignRSA())->DecryptFunc($mail->password);
|
||||
$password = $decrypted ?: $mail->password;
|
||||
}
|
||||
|
||||
$transport = $mail->transport ?? 'smtp';
|
||||
$encryption = $mail->encryption ?? config('mail.mailers.smtp.encryption');
|
||||
|
||||
if ($encryption === 0 || $encryption === '0' || $encryption === 'starttls') {
|
||||
$encryption = 'tls';
|
||||
}
|
||||
|
||||
$config = config('mail');
|
||||
$config['default'] = $transport;
|
||||
$config['from'] = [
|
||||
'address' => $mail->from_address ?? $mail->user ?? config('mail.from.address', 'no-reply@example.com'),
|
||||
'name' => $mail->from_name ?? $company,
|
||||
];
|
||||
$config['mailers'][$transport] = array_merge($config['mailers'][$transport] ?? [], [
|
||||
'transport' => $transport,
|
||||
'host' => $mail->host ?? '',
|
||||
'port' => $mail->port ?? 587,
|
||||
'encryption' => $encryption,
|
||||
'username' => $mail->user ?? '',
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
return $config;
|
||||
});
|
||||
|
||||
if ($config) {
|
||||
self::$resolved = $config;
|
||||
Config::set('mail', $config);
|
||||
}
|
||||
$this->app->afterResolving('mail.manager', fn () => MailSettings::apply());
|
||||
Queue::before(fn () => MailSettings::refresh($this->app->make('mail.manager')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Repositories\ActivityLog;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Repositories\BaseRepository;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ActivityLogRepository extends BaseRepository implements ActivityLogRepositoryInterface
|
||||
{
|
||||
@@ -14,6 +15,11 @@ class ActivityLogRepository extends BaseRepository implements ActivityLogReposit
|
||||
}
|
||||
|
||||
public function filter(array $filters, int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
return $this->query($filters)->latest('id')->paginate($perPage);
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
@@ -61,6 +67,6 @@ class ActivityLogRepository extends BaseRepository implements ActivityLogReposit
|
||||
$query->where('description', 'like', '%' . $filters['search'] . '%');
|
||||
}
|
||||
|
||||
return $query->latest('id')->paginate($perPage);
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ namespace App\Repositories\ActivityLog;
|
||||
|
||||
use App\Repositories\BaseRepositoryInterface;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
interface ActivityLogRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
public function filter(array $filters, int $perPage = 20): LengthAwarePaginator;
|
||||
|
||||
public function query(array $filters): Builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories\Concerns;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait FiltersBillingRecords
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $aliases
|
||||
*/
|
||||
private function whereStatus(Builder $query, mixed $status, array $aliases): Builder
|
||||
{
|
||||
$values = collect((array) $status)
|
||||
->reject(fn ($value) => $value === null || $value === '' || $value === 'full')
|
||||
->map(fn ($value) => $aliases[$value] ?? (int) $value)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return $values->isEmpty() ? $query : $query->whereIn('status', $values->all());
|
||||
}
|
||||
|
||||
private function whereCreatedBetween(Builder $query, ?string $from, ?string $to): Builder
|
||||
{
|
||||
return $query
|
||||
->when($from, fn (Builder $q) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
|
||||
->when($to, fn (Builder $q) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()));
|
||||
}
|
||||
|
||||
private function whereOrderItemNameLike(Builder $query, string $orderItemsRelation, string $search): Builder
|
||||
{
|
||||
return $query->orWhereHas($orderItemsRelation, fn (Builder $items) => $items
|
||||
->where('server_name', 'like', "%{$search}%")
|
||||
->orWhere('domain_name', 'like', "%{$search}%"));
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,7 @@ class EmployeeRepository extends BaseRepository implements EmployeeRepositoryInt
|
||||
if (!empty($data['search'])) {
|
||||
$search = $data['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('firstname', 'like', "%{$search}%")
|
||||
->orWhere('lastname', 'like', "%{$search}%")
|
||||
$q->where('fullname', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
|
||||
@@ -4,10 +4,13 @@ namespace App\Repositories\Invoice;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Repositories\BaseRepository;
|
||||
use App\Repositories\Concerns\FiltersBillingRecords;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface
|
||||
{
|
||||
use FiltersBillingRecords;
|
||||
|
||||
public function getModel()
|
||||
{
|
||||
return Invoice::class;
|
||||
@@ -58,11 +61,18 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
|
||||
);
|
||||
});
|
||||
}
|
||||
if (isset($data['status']) && $data['status'] !=='full') {
|
||||
$model = $model->where('status', match ($data['status']) {
|
||||
'active' => 1,
|
||||
'cancel' => 2,
|
||||
default => 0
|
||||
$model = $this->whereStatus($model, $data['status'] ?? null, ['unpaid' => 1, 'paid' => 2, 'cancel' => 3, 'overdue' => 4, 'refunded' => 5]);
|
||||
$model = $this->whereCreatedBetween($model, $data['date_from'] ?? null, $data['date_to'] ?? null);
|
||||
if (isset($data['filter']) && $data['filter'] !== '') {
|
||||
$search = $data['filter'];
|
||||
$model = $model->where(function ($query) use ($search) {
|
||||
$query->where('invoice_number', 'like', "%{$search}%")
|
||||
->orWhereHas('order', fn ($query) => $query->where('order_num', 'like', "%{$search}%"))
|
||||
->orWhereHas('customer', function ($query) use ($search) {
|
||||
$query->where('fullname', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
$this->whereOrderItemNameLike($query, 'order.orderItems', $search);
|
||||
});
|
||||
}
|
||||
if (isset($data['relations'])) {
|
||||
|
||||
@@ -4,10 +4,13 @@ namespace App\Repositories\Order;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Repositories\BaseRepository;
|
||||
use App\Repositories\Concerns\FiltersBillingRecords;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class OrderRepository extends BaseRepository implements OrderRepositoryInterface
|
||||
{
|
||||
use FiltersBillingRecords;
|
||||
|
||||
public function getModel()
|
||||
{
|
||||
return Order::class;
|
||||
@@ -54,15 +57,13 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface
|
||||
$model = $model->where("invoice_id", $data['invoice_id']);
|
||||
}
|
||||
}
|
||||
if (isset($data['status']) && $data['status'] !== '' && $data['status'] !== null && $data['status'] != 'full') {
|
||||
$status = match ($data['status']) {
|
||||
'active' => 1,
|
||||
'pending' => 0,
|
||||
'processing' => 3,
|
||||
'cancel' => 2,
|
||||
default => $data['status'],
|
||||
};
|
||||
$model = $model->where("status", $status);
|
||||
$model = $this->whereStatus($model, $data['status'] ?? null, ['active' => 1, 'pending' => 0, 'processing' => 3, 'cancel' => 2]);
|
||||
$model = $this->whereCreatedBetween($model, $data['date_from'] ?? null, $data['date_to'] ?? null);
|
||||
if (! empty($data['customer_id'])) {
|
||||
$model = $model->where('customer_id', $data['customer_id']);
|
||||
}
|
||||
if (! empty($data['payment_method'])) {
|
||||
$model = $model->where('payment_method', $data['payment_method']);
|
||||
}
|
||||
|
||||
if (isset($data['filter']) && $data['filter'] !== '') {
|
||||
@@ -73,6 +74,7 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface
|
||||
$query->where('fullname', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
$this->whereOrderItemNameLike($query, 'orderItems', $search);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Customer;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Http\Resources\Api\Admin\OrderItemServiceResource;
|
||||
use App\Models\Order;
|
||||
use App\Enums\TransactionStatus;
|
||||
use App\Models\Transaction;
|
||||
@@ -127,7 +128,13 @@ class CustomerBillingService
|
||||
{
|
||||
$customer = $this->resolveCustomer($uuid);
|
||||
|
||||
return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoices']);
|
||||
return $this->orderRepository->filter(['client' => $customer->id], [
|
||||
'customer',
|
||||
'user:id,username',
|
||||
'payment',
|
||||
'invoices',
|
||||
...OrderItemServiceResource::relationsFor('orderItems'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Services\Customer\Concerns\ResolvesCustomer;
|
||||
use App\Services\Email\EmailTemplateRenderer;
|
||||
use App\Support\AutoEmailGate;
|
||||
use App\Support\EmailRecipients;
|
||||
use App\Support\MailAttachments;
|
||||
use App\Support\MailGlobals;
|
||||
use App\Support\MailPlaceholders;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -165,6 +166,8 @@ class CustomerMailService
|
||||
|
||||
abort_if($to === [], 422, 'Không có địa chỉ email nhận hợp lệ.');
|
||||
|
||||
$files = MailAttachments::store($data['attachments'] ?? [], "customer-emails/{$customer->id}");
|
||||
|
||||
$mailer = Mail::to($to);
|
||||
if ($cc !== []) {
|
||||
$mailer->cc($cc);
|
||||
@@ -173,8 +176,8 @@ class CustomerMailService
|
||||
$mailer->bcc($bcc);
|
||||
}
|
||||
$mailer->send($template
|
||||
? new GenericTemplateMail($subject, $message, array_diff_key($template->mailConfig(), array_flip(['cc', 'bcc'])))
|
||||
: new ManualCustomerEmail($subject, $message, wrapLayout: (bool) ($data['with_layout'] ?? true)));
|
||||
? new GenericTemplateMail($subject, $message, array_diff_key($template->mailConfig(), array_flip(['cc', 'bcc'])), storedFiles: $files)
|
||||
: new ManualCustomerEmail($subject, $message, wrapLayout: (bool) ($data['with_layout'] ?? true), storedFiles: $files));
|
||||
|
||||
MailLog::create([
|
||||
'template_id' => $template->id ?? 0,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IpPool;
|
||||
|
||||
use App\Mail\IpAllocationMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\IpPoolLog;
|
||||
use App\Models\Mail as MailLog;
|
||||
use App\Models\ModuleIpAddress;
|
||||
use App\Models\User;
|
||||
use App\Services\Email\EmailTemplateService;
|
||||
use App\Support\AutoEmailGate;
|
||||
use App\Support\EmailRecipients;
|
||||
use App\Support\MailAttachments;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class IpAllocationService
|
||||
{
|
||||
public function __construct(private readonly EmailTemplateService $templates) {}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
*/
|
||||
public function previewAllocation(array $ids, Customer $customer, array $data): array
|
||||
{
|
||||
$pools = $this->allocatable($this->pools($ids));
|
||||
$pools->each(fn (IpPool $pool) => $pool->service_name = $this->serviceNameFor($pool, $data));
|
||||
|
||||
return $this->preview($this->allocationMail($customer, $pools, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
*/
|
||||
public function allocate(array $ids, Customer $customer, array $data, User $admin): int
|
||||
{
|
||||
$this->ensureMailReady(IpAllocationMail::ALLOCATED);
|
||||
|
||||
$files = MailAttachments::store($data['attachments'] ?? [], "customer-emails/{$customer->id}");
|
||||
|
||||
return DB::transaction(function () use ($ids, $customer, $data, $admin, $files) {
|
||||
$pools = $this->allocatable($this->pools($ids, lock: true));
|
||||
|
||||
foreach ($pools as $pool) {
|
||||
$from = $pool->status;
|
||||
$pool->update([
|
||||
'status' => 'assigned',
|
||||
'customer_id' => $customer->id,
|
||||
'customer_name' => $customer->fullname,
|
||||
'service_name' => $this->serviceNameFor($pool, $data),
|
||||
'allocated_at' => $data['date'],
|
||||
'revoked_at' => null,
|
||||
'contact_email' => $data['to'],
|
||||
'contact_cc' => $data['cc'] ?? null,
|
||||
'last_email_type' => 'ALLOCATED_SENT',
|
||||
'last_email_at' => now(),
|
||||
'updated_by' => $admin->username,
|
||||
]);
|
||||
IpPoolLog::record($pool, IpPoolLog::ALLOCATED, $from, [
|
||||
'effective_date' => $data['date'],
|
||||
'customer_id' => $customer->id,
|
||||
'customer_name' => $customer->fullname,
|
||||
'service_name' => $pool->service_name,
|
||||
'email_to' => $data['to'],
|
||||
'note' => $data['note'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->send($this->allocationMail($customer, $pools, $data, $files), $data['to'], $data['cc'] ?? null, $customer->id, $admin);
|
||||
|
||||
return $pools->count();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function previewRevocation(array $ids, array $data): array
|
||||
{
|
||||
return $this->revocationGroups($this->revocable($this->pools($ids)))
|
||||
->map(fn (Collection $pools, string $key) => [
|
||||
'key' => $key,
|
||||
'customer_id' => $pools->first()->customer_id,
|
||||
'customer_name' => $pools->first()->customer_name,
|
||||
'to' => $pools->first()->contact_email,
|
||||
'cc' => $pools->first()->contact_cc,
|
||||
'ips' => $pools->pluck('ip_address')->values(),
|
||||
...$this->preview($this->revocationMail($pools, $data)),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
* @param array<string, array{to: string, cc?: string|null}> $recipients
|
||||
*/
|
||||
public function revoke(array $ids, array $data, array $recipients, User $admin): int
|
||||
{
|
||||
$this->ensureMailReady(IpAllocationMail::REVOKED);
|
||||
|
||||
$groups = $this->revocationGroups($this->revocable($this->pools($ids)));
|
||||
$missing = $groups->keys()->reject(fn ($key) => filled($recipients[$key]['to'] ?? null));
|
||||
if ($missing->isNotEmpty()) {
|
||||
throw ValidationException::withMessages(['recipients' => 'Chưa nhập email người nhận cho: ' . $groups->only($missing->all())
|
||||
->map(fn ($pools) => $pools->first()->customer_name ?: $pools->pluck('ip_address')->implode(', '))->implode('; ')]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($ids, $data, $recipients, $admin) {
|
||||
$groups = $this->revocationGroups($this->revocable($this->pools($ids, lock: true)));
|
||||
|
||||
return $groups->map(function (Collection $pools, string $key) use ($data, $recipients, $admin) {
|
||||
$mail = $this->revocationMail($pools, $data, $recipients[$key]['subject'] ?? null);
|
||||
$customerId = $pools->first()->customer_id;
|
||||
foreach ($pools as $pool) {
|
||||
$from = $pool->status;
|
||||
$before = $pool->only(['customer_id', 'customer_name', 'service_name']);
|
||||
$pool->update([
|
||||
'status' => 'free',
|
||||
'customer_id' => null,
|
||||
'customer_name' => null,
|
||||
'service_name' => null,
|
||||
'contact_email' => null,
|
||||
'revoked_at' => $data['date'],
|
||||
'last_email_type' => 'REVOKE_SENT',
|
||||
'last_email_at' => now(),
|
||||
'updated_by' => $admin->username,
|
||||
]);
|
||||
IpPoolLog::record($pool, IpPoolLog::REVOKED, $from, [
|
||||
...$before,
|
||||
'effective_date' => $data['date'],
|
||||
'email_to' => $recipients[$key]['to'],
|
||||
'note' => $data['note'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->send($mail, $recipients[$key]['to'], $recipients[$key]['cc'] ?? null, $customerId, $admin);
|
||||
|
||||
return $pools->count();
|
||||
})->sum();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
* @return Collection<int, IpPool>
|
||||
*/
|
||||
private function pools(array $ids, bool $lock = false): Collection
|
||||
{
|
||||
$query = IpPool::whereIn('id', $ids)->orderBy('ip_address');
|
||||
|
||||
$pools = ($lock ? $query->lockForUpdate() : $query)->get();
|
||||
if ($pools->isEmpty()) {
|
||||
throw ValidationException::withMessages(['ids' => 'Không tìm thấy IP nào.']);
|
||||
}
|
||||
|
||||
return $pools;
|
||||
}
|
||||
|
||||
private function allocatable(Collection $pools): Collection
|
||||
{
|
||||
$blocked = $pools->reject(fn (IpPool $pool) => $pool->status === 'free');
|
||||
if ($blocked->isNotEmpty()) {
|
||||
throw ValidationException::withMessages(['ids' => 'Chỉ cấp phát được IP đang trống. IP không hợp lệ: ' . $blocked->pluck('ip_address')->implode(', ')]);
|
||||
}
|
||||
|
||||
return $pools;
|
||||
}
|
||||
|
||||
private function revocable(Collection $pools): Collection
|
||||
{
|
||||
$inUse = ModuleIpAddress::where('status', 1)->whereIn('ip', $pools->pluck('ip_address'))->pluck('ip')->all();
|
||||
$blocked = $pools->filter(fn (IpPool $pool) => $pool->status !== 'assigned'
|
||||
|| $pool->service_id !== null
|
||||
|| in_array($pool->ip_address, $inUse, true));
|
||||
|
||||
if ($blocked->isNotEmpty()) {
|
||||
throw ValidationException::withMessages(['ids' => 'Chỉ thu hồi được IP đã cấp và không gắn VM/dịch vụ (IP gắn VM thu hồi qua dịch vụ). IP không hợp lệ: ' . $blocked->pluck('ip_address')->implode(', ')]);
|
||||
}
|
||||
|
||||
return $pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<string, Collection<int, IpPool>>
|
||||
*/
|
||||
private function revocationGroups(Collection $pools): \Illuminate\Support\Collection
|
||||
{
|
||||
return $pools->groupBy(fn (IpPool $pool) => $pool->customer_id
|
||||
? 'customer:' . $pool->customer_id
|
||||
: 'name:' . mb_strtolower(trim((string) $pool->customer_name)) . '|' . mb_strtolower(trim((string) $pool->contact_email)))
|
||||
->toBase();
|
||||
}
|
||||
|
||||
private function allocationMail(Customer $customer, Collection $pools, array $data, array $files = []): IpAllocationMail
|
||||
{
|
||||
return new IpAllocationMail(
|
||||
IpAllocationMail::ALLOCATED,
|
||||
$customer->fullname,
|
||||
IpAllocationMail::ipsFrom($pools),
|
||||
$data['date'],
|
||||
$data['note'] ?? null,
|
||||
$data['subject'] ?? null,
|
||||
$files,
|
||||
);
|
||||
}
|
||||
|
||||
private function serviceNameFor(IpPool $pool, array $data): ?string
|
||||
{
|
||||
$name = trim((string) (($data['services'][$pool->id] ?? null) ?: ($data['service_name'] ?? '')));
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
private function revocationMail(Collection $pools, array $data, ?string $subject = null): IpAllocationMail
|
||||
{
|
||||
return new IpAllocationMail(
|
||||
IpAllocationMail::REVOKED,
|
||||
$pools->first()->customer_name ?: 'Quý khách',
|
||||
IpAllocationMail::ipsFrom($pools),
|
||||
$data['date'],
|
||||
$data['note'] ?? null,
|
||||
$subject,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{subject: string, html: string}
|
||||
*/
|
||||
private function preview(IpAllocationMail $mail): array
|
||||
{
|
||||
try {
|
||||
return ['subject' => $mail->envelope()->subject, 'html' => $mail->render()];
|
||||
} catch (ModelNotFoundException) {
|
||||
throw ValidationException::withMessages(['template' => 'Chưa có mẫu email đang bật cho thao tác này (Mẫu email → ' . $mail->type . ').']);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureMailReady(string $scenario): void
|
||||
{
|
||||
if (!AutoEmailGate::mailEnabled()) {
|
||||
throw ValidationException::withMessages(['mail' => 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).']);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->templates->loadTemplate($scenario);
|
||||
} catch (ModelNotFoundException) {
|
||||
throw ValidationException::withMessages(['template' => "Chưa có mẫu email {$scenario} đang bật trong Mẫu email."]);
|
||||
}
|
||||
}
|
||||
|
||||
private function send(IpAllocationMail $mail, string $to, ?string $cc, ?int $customerId, User $admin): void
|
||||
{
|
||||
AutoEmailGate::markManual();
|
||||
$template = $this->templates->loadTemplate($mail->type);
|
||||
$toList = EmailRecipients::parse($to);
|
||||
$ccList = EmailRecipients::parse($cc);
|
||||
|
||||
MailLog::create([
|
||||
'template_id' => $template->id,
|
||||
'subject' => $mail->envelope()->subject,
|
||||
'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'),
|
||||
'to' => implode(', ', $toList),
|
||||
'cc' => $ccList ? implode(', ', $ccList) : null,
|
||||
'customer_id' => $customerId,
|
||||
'sent_by' => $admin->id,
|
||||
'message' => (clone $mail)->render(),
|
||||
]);
|
||||
|
||||
Mail::to($toList)->cc($ccList)->queue($mail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IpPool;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleIpAddress;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class IpPoolSheetImporter
|
||||
{
|
||||
private const ERROR_LIMIT = 100;
|
||||
|
||||
private const CHUNK = 500;
|
||||
|
||||
private const NETWORK_FIELDS = ['gateway', 'netmask'];
|
||||
|
||||
private const COPIED_FIELDS = [
|
||||
'ip_block', 'gateway', 'netmask', 'owner_type', 'ip_role', 'parent_ip',
|
||||
'customer_name', 'service_name', 'allocated_at', 'revoked_at',
|
||||
'contact_email', 'contact_cc', 'contact_phone', 'monthly_cost', 'selling_price',
|
||||
'additional_info', 'last_email_type', 'last_email_at', 'updated_by', 'note',
|
||||
];
|
||||
|
||||
public function __construct(private readonly IpPoolSheetReader $reader) {}
|
||||
|
||||
public function preview(UploadedFile $file): array
|
||||
{
|
||||
return $this->run($file, false);
|
||||
}
|
||||
|
||||
public function import(UploadedFile $file): array
|
||||
{
|
||||
return $this->run($file, true);
|
||||
}
|
||||
|
||||
public static function resolveStatus(?string $currentStatus, bool $systemOwned, ?string $sheetStatus): string
|
||||
{
|
||||
if ($currentStatus !== null && $systemOwned) {
|
||||
return $currentStatus;
|
||||
}
|
||||
|
||||
return match ($sheetStatus) {
|
||||
'ALLOCATED' => 'assigned',
|
||||
'FREE', 'REVOKED' => 'free',
|
||||
default => $currentStatus ?? 'free',
|
||||
};
|
||||
}
|
||||
|
||||
private function run(UploadedFile $file, bool $apply): array
|
||||
{
|
||||
$sheet = $this->reader->read($file);
|
||||
$rows = [];
|
||||
foreach ($sheet['rows'] as $row) {
|
||||
$rows[$row['ip_address']] = $row;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total_rows' => count($sheet['rows']) + count($sheet['errors']) + $sheet['skipped_blank'],
|
||||
'valid' => count($rows),
|
||||
'skipped_blank' => $sheet['skipped_blank'],
|
||||
'duplicates' => count($sheet['rows']) - count($rows),
|
||||
'error_count' => count($sheet['errors']),
|
||||
'errors' => array_slice($sheet['errors'], 0, self::ERROR_LIMIT),
|
||||
'create' => 0,
|
||||
'update' => 0,
|
||||
'restore' => 0,
|
||||
'to_assigned' => 0,
|
||||
'to_free' => 0,
|
||||
'kept_by_system' => 0,
|
||||
];
|
||||
|
||||
[$inserts, $updates] = $this->plan($rows, $summary);
|
||||
|
||||
if ($apply) {
|
||||
DB::transaction(function () use ($inserts, $updates) {
|
||||
foreach (array_chunk($inserts, self::CHUNK) as $chunk) {
|
||||
IpPool::insert($chunk);
|
||||
}
|
||||
foreach (array_chunk($updates, self::CHUNK) as $chunk) {
|
||||
IpPool::withTrashed()->upsert($chunk, ['id'], [...self::COPIED_FIELDS, 'cidr', 'status', 'deleted_at']);
|
||||
}
|
||||
});
|
||||
IpPool::resyncAllContacts();
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private function plan(array $rows, array &$summary): array
|
||||
{
|
||||
$ips = array_keys($rows);
|
||||
$existing = $this->existingByIp($ips);
|
||||
$inUse = $this->inUseKeys($ips);
|
||||
$now = now()->toDateTimeString();
|
||||
|
||||
$inserts = [];
|
||||
$updates = [];
|
||||
foreach ($rows as $ip => $row) {
|
||||
$pools = $existing->get($ip);
|
||||
|
||||
if ($pools === null) {
|
||||
$status = self::resolveStatus(null, false, $row['sheet_status']);
|
||||
$summary['create']++;
|
||||
$summary['to_assigned'] += $status === 'assigned' ? 1 : 0;
|
||||
$inserts[] = $this->record($row, null, $status) + ['created_at' => $now, 'updated_at' => $now];
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($pools as $pool) {
|
||||
$systemOwned = $pool->status === 'reserved'
|
||||
|| $pool->service_id !== null
|
||||
|| isset($inUse[$pool->module_network_id . '|' . $ip]);
|
||||
$status = self::resolveStatus($pool->status, $systemOwned, $row['sheet_status']);
|
||||
|
||||
$summary[$pool->deleted_at ? 'restore' : 'update']++;
|
||||
if ($systemOwned) {
|
||||
$summary['kept_by_system']++;
|
||||
} elseif ($pool->status !== $status) {
|
||||
$summary[$status === 'assigned' ? 'to_assigned' : 'to_free']++;
|
||||
}
|
||||
|
||||
$updates[] = $this->record($row, $pool, $status);
|
||||
}
|
||||
}
|
||||
|
||||
return [$inserts, $updates];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $ips
|
||||
* @return Collection<string, Collection<int, IpPool>>
|
||||
*/
|
||||
private function existingByIp(array $ips): Collection
|
||||
{
|
||||
return collect($ips)->chunk(1000)->flatMap(fn ($chunk) => IpPool::withTrashed()
|
||||
->whereIn('ip_address', $chunk->all())
|
||||
->get(['id', 'uuid', 'ip_address', 'module_network_id', 'status', 'service_id', 'cidr', 'gateway', 'netmask', 'deleted_at'])
|
||||
)->groupBy('ip_address');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $ips
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function inUseKeys(array $ips): array
|
||||
{
|
||||
$keys = [];
|
||||
foreach (array_chunk($ips, 1000) as $chunk) {
|
||||
foreach (ModuleIpAddress::where('status', 1)->whereIn('ip', $chunk)->get(['ip', 'module_network_id']) as $mirror) {
|
||||
$keys[$mirror->module_network_id . '|' . $mirror->ip] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function record(array $row, ?IpPool $pool, string $status): array
|
||||
{
|
||||
$record = [
|
||||
'uuid' => $pool?->uuid ?? (string) Str::uuid(),
|
||||
'ip_address' => $row['ip_address'],
|
||||
'module_network_id' => $pool?->module_network_id,
|
||||
'cidr' => $row['cidr'] ?? $pool?->cidr ?? 24,
|
||||
'status' => $status,
|
||||
'deleted_at' => null,
|
||||
];
|
||||
|
||||
foreach (self::COPIED_FIELDS as $field) {
|
||||
$record[$field] = $row[$field];
|
||||
}
|
||||
|
||||
foreach (self::NETWORK_FIELDS as $field) {
|
||||
$record[$field] ??= $pool?->{$field};
|
||||
}
|
||||
|
||||
return $pool ? ['id' => $pool->id] + $record : $record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IpPool;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class IpPoolSheetReader
|
||||
{
|
||||
public const SHEET_NAME = 'IP_POOL';
|
||||
|
||||
private const READERS = ['xlsx' => 'Xlsx', 'xls' => 'Xls', 'csv' => 'Csv', 'txt' => 'Csv'];
|
||||
|
||||
private const HEADERS = [
|
||||
'TEN_MANG' => 'ip_block',
|
||||
'CIDR' => 'cidr',
|
||||
'OWNER_TYPE' => 'owner_type',
|
||||
'IP_ADDRESS' => 'ip_address',
|
||||
'IP_ROLE' => 'ip_role',
|
||||
'PARENT_IP' => 'parent_ip',
|
||||
'SUBNET_MASK' => 'netmask',
|
||||
'GATEWAY' => 'gateway',
|
||||
'TEN_KHACH_HANG' => 'customer_name',
|
||||
'TEN_DICH_VU' => 'service_name',
|
||||
'NGAY_CAP_PHAT' => 'allocated_at',
|
||||
'NGAY_THU_HOI' => 'revoked_at',
|
||||
'TRANG_THAI' => 'sheet_status',
|
||||
'CONTACT_EMAIL' => 'contact_email',
|
||||
'CONTACT_CC' => 'contact_cc',
|
||||
'CONTACT_PHONE' => 'contact_phone',
|
||||
'MONTHLY_COST' => 'monthly_cost',
|
||||
'SELLING_PRICE' => 'selling_price',
|
||||
'THONG_TIN_BO_SUNG' => 'additional_info',
|
||||
'LAST_EMAIL_TYPE' => 'last_email_type',
|
||||
'LAST_EMAIL_AT' => 'last_email_at',
|
||||
'UPDATED_BY' => 'updated_by',
|
||||
'GHI_CHU' => 'note',
|
||||
];
|
||||
|
||||
private const UPPERCASE_FIELDS = ['owner_type', 'ip_role', 'sheet_status', 'last_email_type'];
|
||||
|
||||
/**
|
||||
* @return array{rows: array<int, array<string, mixed>>, errors: array<int, array{row: int, message: string}>, skipped_blank: int}
|
||||
*/
|
||||
public function read(UploadedFile $file): array
|
||||
{
|
||||
$type = self::READERS[strtolower($file->getClientOriginalExtension())] ?? 'Csv';
|
||||
$reader = IOFactory::createReader($type);
|
||||
$reader->setReadDataOnly(true);
|
||||
|
||||
if ($type !== 'Csv') {
|
||||
$sheets = $reader->listWorksheetNames($file->getRealPath());
|
||||
$reader->setLoadSheetsOnly(in_array(self::SHEET_NAME, $sheets, true) ? self::SHEET_NAME : $sheets[0]);
|
||||
}
|
||||
|
||||
return $this->parse($this->cachedValues($reader->load($file->getRealPath())->getActiveSheet()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function cachedValues(Worksheet $sheet): array
|
||||
{
|
||||
$table = [];
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator() as $cell) {
|
||||
$cells[] = $cell->isFormula() ? $cell->getOldCalculatedValue() : $cell->getValue();
|
||||
}
|
||||
$table[] = $cells;
|
||||
}
|
||||
|
||||
return $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, mixed>> $table
|
||||
*/
|
||||
public function parse(array $table): array
|
||||
{
|
||||
$columns = $this->mapHeader(array_shift($table) ?? []);
|
||||
$rows = [];
|
||||
$errors = [];
|
||||
$skippedBlank = 0;
|
||||
|
||||
foreach ($table as $index => $cells) {
|
||||
$line = $index + 2;
|
||||
$raw = [];
|
||||
foreach ($columns as $position => $field) {
|
||||
$raw[$field] = $cells[$position] ?? null;
|
||||
}
|
||||
|
||||
if ($this->clean($raw['ip_address'] ?? null) === null) {
|
||||
$skippedBlank++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$problems = [];
|
||||
$row = $this->normalize($raw, $problems);
|
||||
|
||||
if ($problems) {
|
||||
$errors[] = ['row' => $line, 'message' => implode('; ', $problems)];
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$line] = $row;
|
||||
}
|
||||
|
||||
return ['rows' => $rows, 'errors' => $errors, 'skipped_blank' => $skippedBlank];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function mapHeader(array $header): array
|
||||
{
|
||||
$columns = [];
|
||||
foreach ($header as $position => $title) {
|
||||
$field = self::HEADERS[strtoupper((string) $this->clean($title))] ?? null;
|
||||
if ($field && !in_array($field, $columns, true)) {
|
||||
$columns[$position] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $problems
|
||||
*/
|
||||
private function normalize(array $raw, array &$problems): array
|
||||
{
|
||||
$row = [];
|
||||
foreach (array_keys(self::HEADERS) as $header) {
|
||||
$field = self::HEADERS[$header];
|
||||
$row[$field] = $this->clean($raw[$field] ?? null);
|
||||
}
|
||||
|
||||
foreach (self::UPPERCASE_FIELDS as $field) {
|
||||
$row[$field] = $row[$field] === null ? null : mb_strtoupper($row[$field]);
|
||||
}
|
||||
|
||||
$row['ip_block'] = $row['ip_block'] === null ? null : preg_replace('/\s+/', ' ', $row['ip_block']);
|
||||
|
||||
if (!filter_var($row['ip_address'], FILTER_VALIDATE_IP)) {
|
||||
$problems[] = "IP không hợp lệ: {$row['ip_address']}";
|
||||
}
|
||||
|
||||
$row['cidr'] = preg_match('/(\d{1,2})$/', (string) $row['cidr'], $m) && (int) $m[1] <= 32 ? (int) $m[1] : null;
|
||||
$row['contact_email'] = $this->emails($row['contact_email'], 'CONTACT_EMAIL', $problems);
|
||||
$row['contact_cc'] = $this->emails($row['contact_cc'], 'CONTACT_CC', $problems);
|
||||
$row['contact_phone'] = $this->phone($row['contact_phone']);
|
||||
$row['monthly_cost'] = $this->money($raw['monthly_cost'] ?? null, 'MONTHLY_COST', $problems);
|
||||
$row['selling_price'] = $this->money($raw['selling_price'] ?? null, 'SELLING_PRICE', $problems);
|
||||
$row['allocated_at'] = $this->date($raw['allocated_at'] ?? null, false);
|
||||
$row['revoked_at'] = $this->date($raw['revoked_at'] ?? null, false);
|
||||
$row['last_email_at'] = $this->date($raw['last_email_at'] ?? null, true);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function clean(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value instanceof DateTimeInterface) {
|
||||
return $value?->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$value = trim(str_replace("\u{00A0}", ' ', (string) $value), " \t\n\r\0\x0B");
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function emails(?string $value, string $label, array &$problems): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$emails = array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', $value))));
|
||||
foreach ($emails as $email) {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$problems[] = "{$label} không hợp lệ: {$email}";
|
||||
}
|
||||
}
|
||||
|
||||
return $emails ? implode(', ', $emails) : null;
|
||||
}
|
||||
|
||||
private function phone(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/[\s.\-]/', '', $value);
|
||||
|
||||
return preg_match('/^[1-9]\d{8}$/', $digits) ? '0' . $digits : $digits;
|
||||
}
|
||||
|
||||
private function money(mixed $value, string $label, array &$problems): ?float
|
||||
{
|
||||
$text = $this->clean($value);
|
||||
if ($text === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($text)) {
|
||||
return round((float) $text, 2);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{1,3}([.,]\d{3})+$/', $text)) {
|
||||
return (float) preg_replace('/[.,]/', '', $text);
|
||||
}
|
||||
|
||||
$problems[] = "{$label} không phải số: {$text}";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function date(mixed $value, bool $withTime): ?string
|
||||
{
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
$date = Carbon::instance($value);
|
||||
} elseif (is_numeric($value)) {
|
||||
$date = Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value));
|
||||
} else {
|
||||
$date = $this->parseDateText($this->clean($value));
|
||||
}
|
||||
|
||||
if ($date === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $withTime ? $date->toDateTimeString() : $date->toDateString();
|
||||
}
|
||||
|
||||
private function parseDateText(?string $text): ?Carbon
|
||||
{
|
||||
if ($text === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['j/n/Y H:i:s', 'j/n/Y H:i', 'j/n/Y', 'Y-m-d H:i:s', 'Y-m-d'] as $format) {
|
||||
try {
|
||||
return Carbon::createFromFormat('!' . $format, $text);
|
||||
} catch (InvalidFormatException) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IpPool;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleIpAddress;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Models\Service;
|
||||
use App\Models\VirtualMachines;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IpPoolUsageResolver
|
||||
{
|
||||
private const CHUNK = 1000;
|
||||
|
||||
/**
|
||||
* @param Collection<int, IpPool> $pools
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function forPools(Collection $pools): array
|
||||
{
|
||||
$inUse = $this->chunked($pools->pluck('ip_address'), fn ($ips) => ModuleIpAddress::where('status', 1)
|
||||
->whereIn('ip', $ips)
|
||||
->get(['id', 'ip', 'vm_name']))
|
||||
->keyBy('ip');
|
||||
|
||||
if ($inUse->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ips = $inUse->keys();
|
||||
$vmwares = $this->vmwares($inUse);
|
||||
$services = $this->services($vmwares);
|
||||
$vcenterVms = $this->vcenterVms($ips, $inUse->pluck('vm_name')->filter());
|
||||
|
||||
return $inUse->mapWithKeys(function (ModuleIpAddress $mirror, string $ip) use ($vmwares, $services, $vcenterVms) {
|
||||
$vmware = $vmwares->get($ip);
|
||||
$service = $vmware ? $services->get($vmware->id) : null;
|
||||
$vcenterVm = $vcenterVms->get($ip);
|
||||
|
||||
return [$ip => [
|
||||
'assigned' => true,
|
||||
'vm_name' => $vmware?->name ?? $vcenterVm?->name ?? $mirror->vm_name,
|
||||
'vm_id' => $vmware?->id,
|
||||
'vcenter_vm_id' => $vcenterVm?->id,
|
||||
'service_id' => $service?->id,
|
||||
'service_name' => $service?->server_name,
|
||||
'service_status' => $service?->status,
|
||||
'customer_name' => $service?->customer?->fullname,
|
||||
'customer_email' => $service?->customer?->email,
|
||||
'customer_uuid' => $service?->customer?->uuid,
|
||||
]];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, ModuleIpAddress> $inUse
|
||||
* @return Collection<string, ModuleVmware>
|
||||
*/
|
||||
private function vmwares(Collection $inUse): Collection
|
||||
{
|
||||
$mirrorIp = $inUse->mapWithKeys(fn (ModuleIpAddress $mirror) => [$mirror->id => $mirror->ip]);
|
||||
|
||||
return ModuleVmware::query()
|
||||
->where(fn ($query) => $query->whereIn('module_ip_address', $mirrorIp->keys())->orWhereIn('ip', $inUse->keys()))
|
||||
->get(['id', 'name', 'ip', 'module_ip_address', 'service_id', 'customer_id'])
|
||||
->keyBy(fn (ModuleVmware $vm) => $mirrorIp[$vm->module_ip_address] ?? $vm->ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, ModuleVmware> $vmwares
|
||||
* @return Collection<int, Service>
|
||||
*/
|
||||
private function services(Collection $vmwares): Collection
|
||||
{
|
||||
if ($vmwares->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$services = Service::with('customer:id,uuid,fullname,email')
|
||||
->where(fn ($query) => $query->whereIn('id', $vmwares->pluck('service_id')->filter())->orWhereIn('vm_id', $vmwares->pluck('id')))
|
||||
->get(['id', 'server_name', 'status', 'customer_id', 'vm_id']);
|
||||
|
||||
return $vmwares->mapWithKeys(fn (ModuleVmware $vm) => [
|
||||
$vm->id => $services->firstWhere('id', $vm->service_id) ?? $services->firstWhere('vm_id', $vm->id),
|
||||
])->filter();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $ips
|
||||
* @param Collection<int, string> $vmNames
|
||||
* @return Collection<string, VirtualMachines>
|
||||
*/
|
||||
private function vcenterVms(Collection $ips, Collection $vmNames): Collection
|
||||
{
|
||||
$prefix = DB::raw("SUBSTRING_INDEX(name, '_', 1)");
|
||||
|
||||
return VirtualMachines::query()
|
||||
->where(fn ($query) => $query->whereIn('ip', $ips)->orWhereIn('name', $vmNames)->orWhereIn($prefix, $ips))
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'name', 'ip'])
|
||||
->reduce(function (Collection $byIp, VirtualMachines $vm) use ($ips) {
|
||||
$ip = $vm->ip && $ips->contains($vm->ip) ? $vm->ip : strstr($vm->name, '_', true);
|
||||
if ($ip && $ips->contains($ip) && !$byIp->has($ip)) {
|
||||
$byIp->put($ip, $vm);
|
||||
}
|
||||
|
||||
return $byIp;
|
||||
}, collect());
|
||||
}
|
||||
|
||||
private function chunked(Collection $values, callable $query): Collection
|
||||
{
|
||||
return $values->chunk(self::CHUNK)->flatMap(fn (Collection $chunk) => $query($chunk->values()->all()));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ServiceOrderRecorder
|
||||
@@ -35,6 +36,7 @@ class ServiceOrderRecorder
|
||||
$order = Order::create([
|
||||
'order_num' => OrderHelper::generateOrderNumber(),
|
||||
'customer_id' => $customerId,
|
||||
'user_id' => Auth::guard('admin')->id(),
|
||||
'date' => now()->toDateString(),
|
||||
'sub_total' => $price,
|
||||
'discount_amount' => 0,
|
||||
|
||||
@@ -73,6 +73,14 @@ final class EmailScenarioRegistry
|
||||
'label' => 'Hosting đã khởi tạo', 'category' => self::BUSINESS, 'group' => 'SERVICE',
|
||||
'required_variables' => ['domain_name'],
|
||||
],
|
||||
'ip_allocated' => [
|
||||
'label' => 'Cấp phát địa chỉ IP', 'category' => self::BUSINESS, 'group' => 'SERVICE',
|
||||
'required_variables' => ['ip_table'],
|
||||
],
|
||||
'ip_revoked' => [
|
||||
'label' => 'Thu hồi địa chỉ IP', 'category' => self::BUSINESS, 'group' => 'SERVICE',
|
||||
'required_variables' => ['ip_rows'],
|
||||
],
|
||||
'vm_suspended' => [
|
||||
'label' => 'VPS bị tạm ngưng', 'category' => self::BUSINESS, 'group' => 'SERVICE',
|
||||
'required_variables' => ['vm_name'],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
final class MailAttachments
|
||||
{
|
||||
public const MAX_FILES = 10;
|
||||
|
||||
public const RULE = 'file|mimes:pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip,rar,jpg,jpeg,png,gif,webp|max:20480';
|
||||
|
||||
private const MAX_TOTAL_KB = 25 * 1024;
|
||||
|
||||
private const DISK = 'local';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function rules(): array
|
||||
{
|
||||
return [
|
||||
'attachments' => 'nullable|array|max:' . self::MAX_FILES,
|
||||
'attachments.*' => self::RULE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, UploadedFile> $uploads
|
||||
* @return array<int, array{disk: string, path: string, name: string}>
|
||||
*/
|
||||
public static function store(array $uploads, string $directory): array
|
||||
{
|
||||
$totalKb = array_sum(array_map(fn (UploadedFile $file) => $file->getSize() / 1024, $uploads));
|
||||
abort_if($totalKb > self::MAX_TOTAL_KB, 422, 'Tổng dung lượng tệp đính kèm không được vượt quá 25MB.');
|
||||
|
||||
return array_map(fn (UploadedFile $file) => [
|
||||
'disk' => self::DISK,
|
||||
'path' => $file->store($directory, self::DISK),
|
||||
'name' => $file->getClientOriginalName(),
|
||||
], $uploads);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Http\Services\SignRSA;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Cache\CacheKey;
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class MailSettings
|
||||
{
|
||||
private const TTL_SECONDS = 3600;
|
||||
|
||||
private const NON_DELIVERING_MAILERS = ['array', 'log'];
|
||||
|
||||
public static function cacheKey(): string
|
||||
{
|
||||
return CacheKey::build('settings', 'smtp');
|
||||
}
|
||||
|
||||
public static function apply(): void
|
||||
{
|
||||
$settings = self::resolve();
|
||||
if ($settings === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = config('mail');
|
||||
if (!in_array($config['default'], self::NON_DELIVERING_MAILERS, true)) {
|
||||
$config['default'] = $settings['transport'];
|
||||
}
|
||||
$config['from'] = [
|
||||
'address' => $settings['from_address'] ?: ($config['from']['address'] ?? null) ?: 'no-reply@example.com',
|
||||
'name' => $settings['from_name'],
|
||||
];
|
||||
$config['mailers'][$settings['transport']] = array_merge(
|
||||
$config['mailers'][$settings['transport']] ?? [],
|
||||
$settings['mailer'],
|
||||
);
|
||||
|
||||
Config::set('mail', $config);
|
||||
}
|
||||
|
||||
public static function refresh(object $mailer): void
|
||||
{
|
||||
self::apply();
|
||||
if ($mailer instanceof MailManager) {
|
||||
$mailer->forgetMailers();
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolve(): ?array
|
||||
{
|
||||
return Cache::remember(self::cacheKey(), self::TTL_SECONDS, function () {
|
||||
if (!Schema::hasTable('setting')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mail = Setting::where('key', 'smtp')->first();
|
||||
$setting = Setting::where('key', 'setting')->first();
|
||||
|
||||
if (!$mail || !$setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mail = json_decode($mail->value);
|
||||
$company = json_decode($setting->value)->company ?? 'Your Company';
|
||||
|
||||
$password = '';
|
||||
if (isset($mail->password)) {
|
||||
$decrypted = (new SignRSA())->DecryptFunc($mail->password);
|
||||
$password = $decrypted ?: $mail->password;
|
||||
}
|
||||
|
||||
$transport = ($mail->transport ?? null) ?: 'smtp';
|
||||
$encryption = $mail->encryption ?? null;
|
||||
|
||||
if ($encryption === 0 || $encryption === '0' || $encryption === 'starttls') {
|
||||
$encryption = 'tls';
|
||||
}
|
||||
|
||||
return [
|
||||
'transport' => $transport,
|
||||
'from_address' => ($mail->from_address ?? null) ?: ($mail->user ?? null),
|
||||
'from_name' => ($mail->from_name ?? null) ?: $company,
|
||||
'mailer' => array_filter([
|
||||
'transport' => $transport,
|
||||
'host' => $mail->host ?? '',
|
||||
'port' => $mail->port ?? 587,
|
||||
'encryption' => $encryption,
|
||||
'username' => $mail->user ?? '',
|
||||
'password' => $password,
|
||||
], fn ($value) => $value !== null),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user