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:
baodev0
2026-10-15 19:58:36 +07:00
co-authored by Claude Opus 5.5
parent ea5697cb81
commit 46aa138eec
95 changed files with 4247 additions and 454 deletions
+16
View File
@@ -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');
+72
View File
@@ -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;
}
}
+27
View File
@@ -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;
}
+190
View File
@@ -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,
};
}
}
+68
View File
@@ -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'),
];
}
}
+60
View File
@@ -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.
+423
View File
@@ -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.',
];
}
}
+1 -2
View File
@@ -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,
];
}
}
+14 -30
View File
@@ -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,
])),
];
}
}
-143
View File
@@ -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.
+20
View File
@@ -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,
);
}
}
+4 -2
View File
@@ -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);
}
}
+8 -1
View File
@@ -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];
+114
View File
@@ -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>';
}
}
+21
View File
@@ -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');
+1
View File
@@ -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
View File
@@ -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');
+60
View File
@@ -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);
}
}
+23
View File
@@ -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
View File
@@ -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()
+8 -69
View File
@@ -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}%");
});
+15 -5
View File
@@ -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'])) {
+11 -9
View File
@@ -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,
+278
View File
@@ -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);
}
}
+185
View File
@@ -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;
}
}
+258
View File
@@ -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;
}
}
+117
View File
@@ -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,
+8
View File
@@ -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'],
+43
View File
@@ -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);
}
}
+100
View File
@@ -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),
];
});
}
}