diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ff5548b..86e1c08 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,44 +1,45 @@ -name: Deploy Portal API - -on: - push: - branches: - - production - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Deploy - uses: appleboy/ssh-action - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - port: ${{ secrets.PORT }} - script_stop: true - - script: | - set -e - - cd ~/httpdocs - - echo "==> Pulling latest code..." - git fetch origin production - git reset --hard origin/production - - echo "==> Installing dependencies..." - composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader - - echo "==> Running migrations..." - php artisan migrate --force - - echo "==> Optimizing Laravel..." - php artisan optimize - - echo "==> Restarting queue workers..." - php artisan queue:restart - - echo "==> Deploy completed!" +# Tạm tắt: chưa triển khai CI/CD. Bỏ comment toàn bộ file để bật lại. +# name: Deploy Portal API +# +# on: +# push: +# branches: +# - production +# workflow_dispatch: +# +# jobs: +# deploy: +# runs-on: ubuntu-latest +# +# steps: +# - name: Deploy +# uses: appleboy/ssh-action +# with: +# host: ${{ secrets.HOST }} +# username: ${{ secrets.USERNAME }} +# key: ${{ secrets.SSH_PRIVATE_KEY }} +# port: ${{ secrets.PORT }} +# script_stop: true +# +# script: | +# set -e +# +# cd ~/httpdocs +# +# echo "==> Pulling latest code..." +# git fetch origin production +# git reset --hard origin/production +# +# echo "==> Installing dependencies..." +# composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader +# +# echo "==> Running migrations..." +# php artisan migrate --force +# +# echo "==> Optimizing Laravel..." +# php artisan optimize +# +# echo "==> Restarting queue workers..." +# php artisan queue:restart +# +# echo "==> Deploy completed!" diff --git a/.gitignore b/.gitignore index d93d566..66fef6a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ yarn-error.log /storage/app/cccd/ /storage/app/deposit-proofs/ /storage/app/public/popup-banners/ +/storage/app/customer-emails +/storage/app/public/service-attachments diff --git a/app/Enums/WebDesign/WebDesignPlatform.php b/app/Enums/WebDesign/WebDesignPlatform.php index 1d37c69..7fc9d3f 100644 --- a/app/Enums/WebDesign/WebDesignPlatform.php +++ b/app/Enums/WebDesign/WebDesignPlatform.php @@ -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'); diff --git a/app/Exports/ActivityLogsExport.php b/app/Exports/ActivityLogsExport.php new file mode 100644 index 0000000..5a83c9b --- /dev/null +++ b/app/Exports/ActivityLogsExport.php @@ -0,0 +1,72 @@ + '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; + } +} diff --git a/app/Exports/BrandedReport.php b/app/Exports/BrandedReport.php new file mode 100644 index 0000000..7687a76 --- /dev/null +++ b/app/Exports/BrandedReport.php @@ -0,0 +1,27 @@ + + */ + public function headings(): array; + + /** + * @return list + */ + public function map($row): array; + + /** + * @return list + */ + public function moneyColumns(): array; +} diff --git a/app/Exports/BrandedReportWriter.php b/app/Exports/BrandedReportWriter.php new file mode 100644 index 0000000..9450bdc --- /dev/null +++ b/app/Exports/BrandedReportWriter.php @@ -0,0 +1,190 @@ +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)); + } +} diff --git a/app/Exports/Concerns/FormatsBillingCells.php b/app/Exports/Concerns/FormatsBillingCells.php new file mode 100644 index 0000000..44814c5 --- /dev/null +++ b/app/Exports/Concerns/FormatsBillingCells.php @@ -0,0 +1,36 @@ +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, + }; + } +} diff --git a/app/Exports/InvoicesExport.php b/app/Exports/InvoicesExport.php new file mode 100644 index 0000000..2fd84bc --- /dev/null +++ b/app/Exports/InvoicesExport.php @@ -0,0 +1,68 @@ + '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'), + ]; + } +} diff --git a/app/Exports/OrdersExport.php b/app/Exports/OrdersExport.php new file mode 100644 index 0000000..9ce3ff4 --- /dev/null +++ b/app/Exports/OrdersExport.php @@ -0,0 +1,60 @@ +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'), + ]; + } +} diff --git a/app/Exports/Templates/report_pricing_service.xlsx b/app/Exports/Templates/report_pricing_service.xlsx new file mode 100644 index 0000000..12a2822 Binary files /dev/null and b/app/Exports/Templates/report_pricing_service.xlsx differ diff --git a/app/Exports/Templates/report_template.xlsx b/app/Exports/Templates/report_template.xlsx new file mode 100644 index 0000000..7da8e73 Binary files /dev/null and b/app/Exports/Templates/report_template.xlsx differ diff --git a/app/Exports/WebDesignQuoteSpreadsheet.php b/app/Exports/WebDesignQuoteSpreadsheet.php new file mode 100644 index 0000000..34bdd25 --- /dev/null +++ b/app/Exports/WebDesignQuoteSpreadsheet.php @@ -0,0 +1,423 @@ + 'A7', + 'meta_label' => 'D7', + 'meta_date' => 'E7', + 'section' => 'A10', + 'head' => 'A23', + 'info_label' => 'A12', + 'text' => 'C12', + 'center' => 'A24', + 'item_name' => 'B24', + 'qty' => 'D24', + 'money' => 'E24', + 'sum_label' => 'A33', + 'sum_money' => 'E33', + 'percent' => 'D46', + 'total_label' => 'A49', + 'total_money' => 'E49', + 'sign_title' => 'B137', + 'sign_company' => 'D137', + 'sign_hint' => 'B138', + ]; + + private const FIRST_BODY_ROW = 9; + + private const COLUMN_WIDTHS = ['A' => 7, 'B' => 30, 'C' => 50, 'D' => 10, 'E' => 18, 'F' => 22]; + + private const LAST_COLUMN = 'F'; + + private const DEFAULT_VALIDITY_DAYS = 30; + + private const LINE_HEIGHT = 15; + + private const MIN_ROW_HEIGHT = 24; + + private Worksheet $sheet; + + /** @var array */ + private array $styles = []; + + private int $row = self::FIRST_BODY_ROW; + + private string $moneyFormat; + + public function download(WebDesignQuote $quote): BinaryFileResponse + { + $path = tempnam(sys_get_temp_dir(), 'quote') . '.xlsx'; + $this->write($quote, $path); + + return response()->download($path, self::fileName($quote))->deleteFileAfterSend(); + } + + public static function fileName(WebDesignQuote $quote): string + { + return 'Bao-gia-' . $quote->quote_number . '.xlsx'; + } + + public function write(WebDesignQuote $quote, string $path): void + { + $quote->loadMissing('creator'); + + $book = IOFactory::load(app_path(self::TEMPLATE)); + while ($book->getSheetCount() > 1) { + $book->removeSheetByIndex(1); + } + + $this->sheet = $book->getSheet(0); + $this->row = self::FIRST_BODY_ROW; + $this->moneyFormat = $quote->currency === 'USD' ? '[$$-409]#,##0.00' : '#,##0\ [$đ-42A]'; + $this->captureStyles(); + $this->resetBody(); + + $this->writeHeader($quote); + $this->writeInfo($quote); + $this->writeItems($quote); + $this->writeTerms($quote); + $this->writeSignature($quote); + + $lastRow = $this->row - 1; + $this->sheet->getPageSetup()->setPrintArea('A1:' . self::LAST_COLUMN . $lastRow); + $this->sheet->setSelectedCell('A1'); + + IOFactory::createWriter($book, 'Xlsx')->save($path); + } + + private function captureStyles(): void + { + foreach (self::STYLE_SOURCES as $name => $cell) { + $this->styles[$name] = $this->sheet->getCell($cell)->getXfIndex(); + } + } + + private function resetBody(): void + { + foreach (array_keys($this->sheet->getMergeCells()) as $range) { + $this->sheet->unmergeCells($range); + } + + $highestRow = $this->sheet->getHighestRow(); + $this->sheet->removeRow(self::FIRST_BODY_ROW, $highestRow - self::FIRST_BODY_ROW + 1); + + foreach (self::COLUMN_WIDTHS as $column => $width) { + $this->sheet->getColumnDimension($column)->setWidth($width); + } + } + + private function writeHeader(WebDesignQuote $quote): void + { + foreach ([1, 2, 3, 4] as $companyRow) { + $this->sheet->mergeCells("C{$companyRow}:" . self::LAST_COLUMN . $companyRow); + } + + $title = 'Báo giá thiết kế website ' . $quote->website_name; + $this->sheet->setCellValueExplicit('A6', mb_strtoupper($title), DataType::TYPE_STRING); + $this->sheet->mergeCells('A6:' . self::LAST_COLUMN . '6'); + + $customer = implode(' | ', array_filter([ + 'Khách hàng: ' . $quote->customer_name, + $quote->customer_company ? 'Công ty: ' . $quote->customer_company : null, + ])); + $quoteDate = $quote->created_at ?? now(); + $validUntil = $quote->valid_until ?? $quoteDate->copy()->addDays(self::DEFAULT_VALIDITY_DAYS); + + $this->writeMetaRow(7, $customer, 'Ngày báo giá', $quoteDate); + $this->writeMetaRow(8, 'Mã báo giá: ' . $quote->quote_number, 'Hiệu lực đến', $validUntil); + } + + private function writeMetaRow(int $row, string $text, string $label, CarbonInterface $date): void + { + $this->styleRange("A{$row}:D{$row}", 'meta_text'); + $this->text("A{$row}", $text); + $this->sheet->mergeCells("A{$row}:D{$row}"); + $this->styleRange("E{$row}", 'meta_label'); + $this->text("E{$row}", $label); + $this->styleRange("F{$row}", 'meta_date'); + $this->sheet->setCellValue("F{$row}", Date::PHPToExcel($date->copy()->startOfDay())); + } + + private function writeInfo(WebDesignQuote $quote): void + { + $this->blankRow(); + $this->sectionRow('1. Thông tin khách hàng và dự án'); + $this->headRow([['A', 'B', 'Thông tin'], ['C', 'F', 'Chi tiết']]); + + $platform = WebDesignPlatform::tryFrom((string) $quote->platform); + $frontend = WebDesignPlatform::frontendLabel($quote->frontend_framework); + $platformText = implode(' — ', array_filter([ + $platform?->label() ?? $quote->platform, + $frontend ? 'Frontend: ' . $frontend : null, + $quote->custom_admin ? 'Custom Admin' : null, + ])); + $licenseFee = $platform?->annualLicenseFeeUsd() ?? 0; + + $rows = [ + 'Khách hàng' => $quote->customer_name, + 'Công ty' => $quote->customer_company, + 'Điện thoại' => $quote->customer_phone, + 'Email' => $quote->customer_email, + 'Website' => $quote->website_name, + 'Thể loại' => $quote->website_category, + 'Nền tảng' => $platformText, + 'Bản quyền' => $licenseFee > 0 ? "Elementor Pro — {$licenseFee}$/năm (chưa gồm trong tổng báo giá)" : null, + 'Mô tả' => $quote->description, + 'Yêu cầu' => $quote->requirements, + 'Website tham khảo' => implode("\n", $quote->reference_websites ?? []), + ]; + + foreach ($rows as $label => $value) { + if ($value === null || trim((string) $value) === '') { + continue; + } + + $r = $this->row++; + $this->styleRange("A{$r}:B{$r}", 'info_label'); + $this->text("A{$r}", $label); + $this->sheet->mergeCells("A{$r}:B{$r}"); + $this->styleRange("C{$r}:F{$r}", 'text'); + $this->text("C{$r}", (string) $value); + $this->sheet->mergeCells("C{$r}:F{$r}"); + $this->fitHeight($r, (string) $value, $this->widthOf('C', 'F')); + } + } + + private function writeItems(WebDesignQuote $quote): void + { + $currency = $quote->currency ?: 'VND'; + + $this->blankRow(); + $this->sectionRow('2. Chi tiết hạng mục và giá'); + $this->headRow([ + ['A', 'A', 'STT'], + ['B', 'B', 'Hạng mục'], + ['C', 'C', 'Mô tả chi tiết'], + ['D', 'D', 'SL'], + ['E', 'E', "Đơn giá ({$currency})"], + ['F', 'F', "Thành tiền ({$currency})"], + ]); + + $firstItemRow = $this->row; + foreach (array_values($quote->items ?? []) as $index => $item) { + $r = $this->row++; + $description = self::htmlToText((string) ($item['description'] ?? '')); + + $this->styleRange("A{$r}", 'center'); + $this->sheet->setCellValue("A{$r}", $index + 1); + $this->styleRange("B{$r}", 'item_name'); + $this->text("B{$r}", (string) ($item['name'] ?? '')); + $this->styleRange("C{$r}", 'text'); + $this->text("C{$r}", $description); + $this->styleRange("D{$r}", 'qty'); + $this->sheet->setCellValue("D{$r}", (float) ($item['quantity'] ?? 0)); + $this->sheet->getStyle("D{$r}")->getNumberFormat()->setFormatCode('#,##0.##'); + $this->styleRange("E{$r}:F{$r}", 'money'); + $this->sheet->setCellValue("E{$r}", (float) ($item['unit_price'] ?? 0)); + $this->sheet->setCellValue("F{$r}", "=D{$r}*E{$r}"); + $this->moneyFormat("E{$r}:F{$r}"); + + $height = max( + $this->estimateHeight((string) ($item['name'] ?? ''), $this->widthOf('B', 'B')), + $this->estimateHeight($description, $this->widthOf('C', 'C')), + ); + $this->sheet->getRowDimension($r)->setRowHeight($height); + } + $lastItemRow = max($this->row - 1, $firstItemRow); + + $subtotalRow = $this->summaryRow('Tạm tính', "=SUM(F{$firstItemRow}:F{$lastItemRow})", 'sum_label', 'sum_money'); + $discountRow = $this->summaryRow('Giảm giá', -1 * (float) $quote->discount, 'text', 'money'); + + $vatRow = $this->row; + $this->summaryRow( + 'Thuế GTGT (VAT)', + "=ROUND(MAX(F{$subtotalRow}+F{$discountRow},0)*E{$vatRow}," . ($currency === 'USD' ? 2 : 0) . ')', + 'text', + 'money', + 'D', + ); + $this->styleRange("E{$vatRow}", 'percent'); + $this->sheet->setCellValue("E{$vatRow}", (float) $quote->vat_percent / 100); + $this->sheet->getStyle("E{$vatRow}")->getNumberFormat()->setFormatCode('0.##%'); + + $this->summaryRow( + 'Tổng giá trị báo giá', + "=MAX(F{$subtotalRow}+F{$discountRow},0)+F{$vatRow}", + 'total_label', + 'total_money', + ); + } + + private function summaryRow( + string $label, + float|string $value, + string $labelStyle, + string $moneyStyle, + string $labelTo = 'E', + ): int { + $r = $this->row++; + $this->styleRange("A{$r}:E{$r}", $labelStyle); + $this->text("A{$r}", $label); + $this->sheet->mergeCells("A{$r}:{$labelTo}{$r}"); + $this->styleRange("F{$r}", $moneyStyle); + $this->sheet->setCellValue("F{$r}", $value); + $this->moneyFormat("F{$r}"); + $this->sheet->getRowDimension($r)->setRowHeight(self::MIN_ROW_HEIGHT); + + return $r; + } + + private function writeTerms(WebDesignQuote $quote): void + { + $validUntil = $quote->valid_until + ? 'đến ngày ' . $quote->valid_until->format('d/m/Y') + : 'trong ' . self::DEFAULT_VALIDITY_DAYS . ' ngày kể từ ngày báo giá'; + + $terms = array_filter([ + $quote->note, + "Báo giá có hiệu lực {$validUntil}.", + ]); + + $this->blankRow(); + $this->sectionRow('3. Ghi chú và hiệu lực báo giá'); + foreach (array_values($terms) as $index => $term) { + $r = $this->row++; + $this->styleRange("A{$r}", 'center'); + $this->sheet->setCellValue("A{$r}", $index + 1); + $this->styleRange("B{$r}:F{$r}", 'text'); + $this->text("B{$r}", $term); + $this->sheet->mergeCells("B{$r}:F{$r}"); + $this->fitHeight($r, $term, $this->widthOf('B', 'F')); + } + } + + private function writeSignature(WebDesignQuote $quote): void + { + $this->blankRow(); + $this->blankRow(); + + $r = $this->row++; + $this->styleRange("B{$r}", 'sign_title'); + $this->text("B{$r}", 'ĐẠI DIỆN KHÁCH HÀNG'); + $this->styleRange("D{$r}:F{$r}", 'sign_company'); + $this->text("D{$r}", 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ'); + $this->sheet->mergeCells("D{$r}:F{$r}"); + $this->sheet->getRowDimension($r)->setRowHeight(41.2); + + $r = $this->row++; + foreach (['B', 'D'] as $column) { + $this->styleRange("{$column}{$r}", 'sign_hint'); + $this->text("{$column}{$r}", '(Ký, ghi rõ họ tên)'); + } + $this->sheet->mergeCells("D{$r}:F{$r}"); + + $creator = $quote->creator?->name; + if ($creator) { + $this->row += 3; + $r = $this->row++; + $this->styleRange("D{$r}:F{$r}", 'sign_company'); + $this->text("D{$r}", $creator); + $this->sheet->mergeCells("D{$r}:F{$r}"); + } + } + + private function sectionRow(string $title): void + { + $r = $this->row++; + $this->styleRange("A{$r}:" . self::LAST_COLUMN . $r, 'section'); + $this->text("A{$r}", mb_strtoupper($title)); + $this->sheet->mergeCells("A{$r}:" . self::LAST_COLUMN . $r); + $this->sheet->getRowDimension($r)->setRowHeight(30); + } + + /** + * @param list $cells + */ + private function headRow(array $cells): void + { + $r = $this->row++; + foreach ($cells as [$from, $to, $label]) { + $this->styleRange("{$from}{$r}:{$to}{$r}", 'head'); + $this->text("{$from}{$r}", $label); + if ($from !== $to) { + $this->sheet->mergeCells("{$from}{$r}:{$to}{$r}"); + } + } + $this->sheet->getRowDimension($r)->setRowHeight(30); + } + + private function blankRow(): void + { + $this->row++; + } + + private function styleRange(string $range, string $style): void + { + foreach ($this->sheet->rangeToArray($range, null, false, false, true) as $row => $columns) { + foreach (array_keys($columns) as $column) { + $this->sheet->getCell("{$column}{$row}")->setXfIndex($this->styles[$style]); + } + } + } + + private function text(string $cell, string $value): void + { + $this->sheet->setCellValueExplicit($cell, $value, DataType::TYPE_STRING); + } + + private function moneyFormat(string $range): void + { + $this->sheet->getStyle($range)->getNumberFormat()->setFormatCode($this->moneyFormat); + } + + private function widthOf(string $from, string $to): float + { + $width = 0; + foreach (range(Coordinate::columnIndexFromString($from), Coordinate::columnIndexFromString($to)) as $index) { + $width += self::COLUMN_WIDTHS[Coordinate::stringFromColumnIndex($index)]; + } + + return $width; + } + + private function fitHeight(int $row, string $text, float $width): void + { + $this->sheet->getRowDimension($row)->setRowHeight($this->estimateHeight($text, $width)); + } + + private function estimateHeight(string $text, float $width): float + { + $charsPerLine = max((int) floor($width * 1.1), 1); + $lines = 0; + foreach (explode("\n", $text) as $line) { + $lines += max((int) ceil(mb_strlen($line) / $charsPerLine), 1); + } + + return max($lines * self::LINE_HEIGHT + 9, self::MIN_ROW_HEIGHT); + } + + public static function htmlToText(string $html): string + { + $text = preg_replace('/]*>/i', '- ', $html); + $text = preg_replace('/|<\/(p|div|li|h[1-6]|tr)>/i', "\n", $text); + $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $lines = array_filter(array_map('trim', explode("\n", $text)), fn (string $line) => $line !== ''); + + return implode("\n", $lines); + } +} diff --git a/app/Http/Controllers/Api/Admin/ActivityLog/ActivityLogController.php b/app/Http/Controllers/Api/Admin/ActivityLog/ActivityLogController.php index 0120a36..2c7deeb 100644 --- a/app/Http/Controllers/Api/Admin/ActivityLog/ActivityLogController.php +++ b/app/Http/Controllers/Api/Admin/ActivityLog/ActivityLogController.php @@ -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); + } } diff --git a/app/Http/Controllers/Api/Admin/Auth/AuthController.php b/app/Http/Controllers/Api/Admin/Auth/AuthController.php index e77e010..ce9f3bf 100644 --- a/app/Http/Controllers/Api/Admin/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Admin/Auth/AuthController.php @@ -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 { diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php index 3c9dbdc..16af138 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerAccount.php @@ -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()); diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php index bac587e..d9eb2f5 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php +++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php @@ -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, ]); diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php index 4dc9789..dadcb7b 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php @@ -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ý). diff --git a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php new file mode 100644 index 0000000..6db514a --- /dev/null +++ b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php @@ -0,0 +1,95 @@ +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(); + } +} diff --git a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolBulkActions.php b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolBulkActions.php index dcec2ac..f2a80d9 100644 --- a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolBulkActions.php +++ b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolBulkActions.php @@ -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'; diff --git a/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php b/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php index 20c7255..5ba0137 100644 --- a/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php +++ b/app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php @@ -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, diff --git a/app/Http/Controllers/Api/Admin/Order/OrderController.php b/app/Http/Controllers/Api/Admin/Order/OrderController.php index 964d317..c2ccb76 100644 --- a/app/Http/Controllers/Api/Admin/Order/OrderController.php +++ b/app/Http/Controllers/Api/Admin/Order/OrderController.php @@ -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([ diff --git a/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php b/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php new file mode 100644 index 0000000..6d39d32 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php @@ -0,0 +1,82 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index ecd3ee0..3035196 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -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 $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([ diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 9a97cf9..c9ce0d4 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -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')) { diff --git a/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php b/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php index 0f44fd2..13a0a6a 100644 --- a/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php +++ b/app/Http/Controllers/Api/Admin/Staff/StaffOnlineController.php @@ -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, diff --git a/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php b/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php index d5094cb..04fa4ff 100644 --- a/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php +++ b/app/Http/Controllers/Api/Admin/WebDesign/WebDesignQuoteController.php @@ -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). */ diff --git a/app/Http/Requests/BillingExportRequest.php b/app/Http/Requests/BillingExportRequest.php new file mode 100644 index 0000000..81b3d32 --- /dev/null +++ b/app/Http/Requests/BillingExportRequest.php @@ -0,0 +1,27 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/UpdateAdminPasswordRequest.php b/app/Http/Requests/UpdateAdminPasswordRequest.php new file mode 100644 index 0000000..f98aaaf --- /dev/null +++ b/app/Http/Requests/UpdateAdminPasswordRequest.php @@ -0,0 +1,54 @@ + ['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.', + ]; + } +} diff --git a/app/Http/Requests/UpdateProfileRequest.php b/app/Http/Requests/UpdateProfileRequest.php index 8859796..88e0163 100644 --- a/app/Http/Requests/UpdateProfileRequest.php +++ b/app/Http/Requests/UpdateProfileRequest.php @@ -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', diff --git a/app/Http/Resources/Api/Admin/InvoiceResource.php b/app/Http/Resources/Api/Admin/InvoiceResource.php index cf595d5..b09feb6 100644 --- a/app/Http/Resources/Api/Admin/InvoiceResource.php +++ b/app/Http/Resources/Api/Admin/InvoiceResource.php @@ -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, diff --git a/app/Http/Resources/Api/Admin/OrderItemServiceResource.php b/app/Http/Resources/Api/Admin/OrderItemServiceResource.php new file mode 100644 index 0000000..b2ccf62 --- /dev/null +++ b/app/Http/Resources/Api/Admin/OrderItemServiceResource.php @@ -0,0 +1,39 @@ + "{$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, + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index b5a1425..551d4ed 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -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, + ])), ]; } } diff --git a/app/Imports/IpPoolImport.php b/app/Imports/IpPoolImport.php deleted file mode 100644 index 50f80c9..0000000 --- a/app/Imports/IpPoolImport.php +++ /dev/null @@ -1,143 +0,0 @@ - */ - 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; - } -} diff --git a/app/Jobs/VirtualMachine/ChangeIpVMJob.php b/app/Jobs/VirtualMachine/ChangeIpVMJob.php index 6c0b729..c777e3d 100644 --- a/app/Jobs/VirtualMachine/ChangeIpVMJob.php +++ b/app/Jobs/VirtualMachine/ChangeIpVMJob.php @@ -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); } } diff --git a/app/Jobs/VirtualMachine/Provision/Concerns/ManagesIpReservation.php b/app/Jobs/VirtualMachine/Provision/Concerns/ManagesIpReservation.php index 5a4d5e9..8a3ade0 100644 --- a/app/Jobs/VirtualMachine/Provision/Concerns/ManagesIpReservation.php +++ b/app/Jobs/VirtualMachine/Provision/Concerns/ManagesIpReservation.php @@ -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. diff --git a/app/Mail/Concerns/AttachesStoredFiles.php b/app/Mail/Concerns/AttachesStoredFiles.php new file mode 100644 index 0000000..7f2c9ec --- /dev/null +++ b/app/Mail/Concerns/AttachesStoredFiles.php @@ -0,0 +1,20 @@ + $files + * @return array + */ + protected function storedFileAttachments(array $files): array + { + return array_map( + fn (array $file) => Attachment::fromStorageDisk($file['disk'], $file['path'])->as($file['name']), + $files, + ); + } +} diff --git a/app/Mail/Customer/ManualCustomerEmail.php b/app/Mail/Customer/ManualCustomerEmail.php index 5629289..0f9d962 100644 --- a/app/Mail/Customer/ManualCustomerEmail.php +++ b/app/Mail/Customer/ManualCustomerEmail.php @@ -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); } } diff --git a/app/Mail/GenericTemplateMail.php b/app/Mail/GenericTemplateMail.php index 0e43e00..131ba5c 100644 --- a/app/Mail/GenericTemplateMail.php +++ b/app/Mail/GenericTemplateMail.php @@ -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]; diff --git a/app/Mail/IpAllocationMail.php b/app/Mail/IpAllocationMail.php new file mode 100644 index 0000000..24fcc80 --- /dev/null +++ b/app/Mail/IpAllocationMail.php @@ -0,0 +1,114 @@ + $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 $pools + * @return array + */ + 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 = '') => '' . $value . ''; + + return '' + . $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) + . ''; + })->implode(''); + + $head = collect(['STT', 'IP Address', 'Dịch vụ', 'Ngày cấp phát']) + ->map(fn (string $title) => '' . $title . '') + ->implode(''); + + return '' + . '' . $head . '' . $rows + . '
'; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 8ceadc8..f790189 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -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 $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'); diff --git a/app/Models/InvoiceAttachment.php b/app/Models/InvoiceAttachment.php index 10440fe..b2ea55e 100644 --- a/app/Models/InvoiceAttachment.php +++ b/app/Models/InvoiceAttachment.php @@ -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'; diff --git a/app/Models/IpPool.php b/app/Models/IpPool.php index 8edab25..76e0f16 100644 --- a/app/Models/IpPool.php +++ b/app/Models/IpPool.php @@ -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'); diff --git a/app/Models/IpPoolLog.php b/app/Models/IpPoolLog.php new file mode 100644 index 0000000..3e76daf --- /dev/null +++ b/app/Models/IpPoolLog.php @@ -0,0 +1,60 @@ + '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); + } +} diff --git a/app/Models/ServiceAttachment.php b/app/Models/ServiceAttachment.php new file mode 100644 index 0000000..71bd4c1 --- /dev/null +++ b/app/Models/ServiceAttachment.php @@ -0,0 +1,23 @@ +belongsTo(Service::class); + } + + public function getUrlAttribute(): string + { + return Storage::disk($this->disk)->url($this->path); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c6cae19..91581ac 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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() diff --git a/app/Providers/MailConfigServiceProvider.php b/app/Providers/MailConfigServiceProvider.php index b86c464..d984563 100644 --- a/app/Providers/MailConfigServiceProvider.php +++ b/app/Providers/MailConfigServiceProvider.php @@ -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'))); } } diff --git a/app/Repositories/ActivityLog/ActivityLogRepository.php b/app/Repositories/ActivityLog/ActivityLogRepository.php index c7a5b46..d53f0e3 100644 --- a/app/Repositories/ActivityLog/ActivityLogRepository.php +++ b/app/Repositories/ActivityLog/ActivityLogRepository.php @@ -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; } } diff --git a/app/Repositories/ActivityLog/ActivityLogRepositoryInterface.php b/app/Repositories/ActivityLog/ActivityLogRepositoryInterface.php index 268cc15..0475f2f 100644 --- a/app/Repositories/ActivityLog/ActivityLogRepositoryInterface.php +++ b/app/Repositories/ActivityLog/ActivityLogRepositoryInterface.php @@ -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; } diff --git a/app/Repositories/Concerns/FiltersBillingRecords.php b/app/Repositories/Concerns/FiltersBillingRecords.php new file mode 100644 index 0000000..a75cb93 --- /dev/null +++ b/app/Repositories/Concerns/FiltersBillingRecords.php @@ -0,0 +1,37 @@ + $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}%")); + } +} diff --git a/app/Repositories/Employee/EmployeeRepository.php b/app/Repositories/Employee/EmployeeRepository.php index 639f9a8..f8415b0 100644 --- a/app/Repositories/Employee/EmployeeRepository.php +++ b/app/Repositories/Employee/EmployeeRepository.php @@ -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}%"); }); diff --git a/app/Repositories/Invoice/InvoiceRepository.php b/app/Repositories/Invoice/InvoiceRepository.php index 56a89b6..60a8c4d 100644 --- a/app/Repositories/Invoice/InvoiceRepository.php +++ b/app/Repositories/Invoice/InvoiceRepository.php @@ -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'])) { diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index 4775770..f652179 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -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); }); } diff --git a/app/Services/Customer/CustomerBillingService.php b/app/Services/Customer/CustomerBillingService.php index 6e2c0d2..2db7407 100644 --- a/app/Services/Customer/CustomerBillingService.php +++ b/app/Services/Customer/CustomerBillingService.php @@ -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'), + ]); } /** diff --git a/app/Services/Customer/CustomerMailService.php b/app/Services/Customer/CustomerMailService.php index 3ec5a99..10870c6 100644 --- a/app/Services/Customer/CustomerMailService.php +++ b/app/Services/Customer/CustomerMailService.php @@ -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, diff --git a/app/Services/IpPool/IpAllocationService.php b/app/Services/IpPool/IpAllocationService.php new file mode 100644 index 0000000..98b1598 --- /dev/null +++ b/app/Services/IpPool/IpAllocationService.php @@ -0,0 +1,278 @@ + $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 $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 $ids + * @return array> + */ + 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 $ids + * @param array $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 $ids + * @return Collection + */ + 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> + */ + 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); + } +} diff --git a/app/Services/IpPool/IpPoolSheetImporter.php b/app/Services/IpPool/IpPoolSheetImporter.php new file mode 100644 index 0000000..49561ca --- /dev/null +++ b/app/Services/IpPool/IpPoolSheetImporter.php @@ -0,0 +1,185 @@ +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> $rows + * @return array{0: array>, 1: array>} + */ + 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 $ips + * @return Collection> + */ + 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 $ips + * @return array + */ + 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; + } +} diff --git a/app/Services/IpPool/IpPoolSheetReader.php b/app/Services/IpPool/IpPoolSheetReader.php new file mode 100644 index 0000000..d9ae157 --- /dev/null +++ b/app/Services/IpPool/IpPoolSheetReader.php @@ -0,0 +1,258 @@ + '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>, errors: array, 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> + */ + 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> $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 + */ + 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 $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; + } +} diff --git a/app/Services/IpPool/IpPoolUsageResolver.php b/app/Services/IpPool/IpPoolUsageResolver.php new file mode 100644 index 0000000..392314b --- /dev/null +++ b/app/Services/IpPool/IpPoolUsageResolver.php @@ -0,0 +1,117 @@ + $pools + * @return array> + */ + 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 $inUse + * @return Collection + */ + 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 $vmwares + * @return Collection + */ + 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 $ips + * @param Collection $vmNames + * @return Collection + */ + 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())); + } +} diff --git a/app/Services/Order/ServiceOrderRecorder.php b/app/Services/Order/ServiceOrderRecorder.php index 7b5187e..504fa0f 100644 --- a/app/Services/Order/ServiceOrderRecorder.php +++ b/app/Services/Order/ServiceOrderRecorder.php @@ -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, diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php index 30a377c..89ecd2d 100644 --- a/app/Support/EmailScenarioRegistry.php +++ b/app/Support/EmailScenarioRegistry.php @@ -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'], diff --git a/app/Support/MailAttachments.php b/app/Support/MailAttachments.php new file mode 100644 index 0000000..1236f56 --- /dev/null +++ b/app/Support/MailAttachments.php @@ -0,0 +1,43 @@ + + */ + public static function rules(): array + { + return [ + 'attachments' => 'nullable|array|max:' . self::MAX_FILES, + 'attachments.*' => self::RULE, + ]; + } + + /** + * @param array $uploads + * @return array + */ + 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); + } +} diff --git a/app/Support/MailSettings.php b/app/Support/MailSettings.php new file mode 100644 index 0000000..2dc4e20 --- /dev/null +++ b/app/Support/MailSettings.php @@ -0,0 +1,100 @@ + $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), + ]; + }); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index fe4bc14..d993e32 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -18,8 +18,6 @@ class UserFactory extends Factory public function definition() { return [ - // Bảng `user` dùng một cột `fullname`; firstname/lastname không tồn tại nên mọi - // test cần tài khoản admin đều chết ở đây với "Unknown column 'firstname'". 'fullname' => 'System Admin', 'username' => "admin", 'email' => 'admin@tgs.com.vn', diff --git a/database/migrations/2026_10_15_000001_create_service_attachments_table.php b/database/migrations/2026_10_15_000001_create_service_attachments_table.php new file mode 100644 index 0000000..d3be69b --- /dev/null +++ b/database/migrations/2026_10_15_000001_create_service_attachments_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('service_id')->constrained('services')->cascadeOnDelete(); + $table->string('filename'); + $table->string('path'); + $table->string('disk', 50)->default('public'); + $table->string('mime_type', 100)->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->string('uploaded_by', 100)->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_attachments'); + } +}; diff --git a/database/migrations/2026_10_15_000002_backfill_orders_user_id_from_logs.php b/database/migrations/2026_10_15_000002_backfill_orders_user_id_from_logs.php new file mode 100644 index 0000000..59c47cd --- /dev/null +++ b/database/migrations/2026_10_15_000002_backfill_orders_user_id_from_logs.php @@ -0,0 +1,30 @@ +pluck('id', 'username'); + + DB::table('log') + ->where('description', 'like', '% created new Order with Order id: %') + ->where('username', '!=', 'System') + ->orderBy('id') + ->each(function ($log) use ($userIds) { + $userId = $userIds[$log->username] ?? null; + if (! $userId || ! preg_match('/Order id: (\d+)$/', $log->description, $match)) { + return; + } + + DB::table('orders') + ->where('id', (int) $match[1]) + ->whereNull('user_id') + ->update(['user_id' => $userId]); + }); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_15_000003_add_fullname_to_user_table.php b/database/migrations/2026_10_15_000003_add_fullname_to_user_table.php new file mode 100644 index 0000000..80107e8 --- /dev/null +++ b/database/migrations/2026_10_15_000003_add_fullname_to_user_table.php @@ -0,0 +1,26 @@ +string('fullname', 255)->nullable()->after('id'); + }); + } + + if (Schema::hasColumns('user', ['firstname', 'lastname'])) { + DB::table('user') + ->where(fn ($query) => $query->whereNull('fullname')->orWhere('fullname', '')) + ->update(['fullname' => DB::raw("TRIM(CONCAT(COALESCE(firstname, ''), ' ', COALESCE(lastname, '')))")]); + } + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_15_000004_reshape_ip_pools_for_sheet_import.php b/database/migrations/2026_10_15_000004_reshape_ip_pools_for_sheet_import.php new file mode 100644 index 0000000..97a74c5 --- /dev/null +++ b/database/migrations/2026_10_15_000004_reshape_ip_pools_for_sheet_import.php @@ -0,0 +1,35 @@ +string('ip_block')->nullable()->after('ip_address'); + $table->unsignedBigInteger('module_network_id')->nullable()->change(); + }); + + DB::table('ip_pools') + ->where('status', 'free') + ->whereNull('service_id') + ->whereRaw("UPPER(TRIM(allocation_status)) = 'ALLOCATED'") + ->update(['status' => 'assigned']); + + Schema::table('ip_pools', function (Blueprint $table) { + $table->dropColumn('allocation_status'); + }); + } + + public function down(): void + { + Schema::table('ip_pools', function (Blueprint $table) { + $table->string('allocation_status')->nullable()->after('revoked_at'); + $table->dropColumn('ip_block'); + }); + } +}; diff --git a/database/migrations/2026_10_15_000005_create_ip_pool_logs_table.php b/database/migrations/2026_10_15_000005_create_ip_pool_logs_table.php new file mode 100644 index 0000000..f3e7ad6 --- /dev/null +++ b/database/migrations/2026_10_15_000005_create_ip_pool_logs_table.php @@ -0,0 +1,41 @@ +foreignId('customer_id')->nullable()->after('customer_name')->constrained('customers')->nullOnDelete(); + }); + + Schema::create('ip_pool_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('ip_pool_id')->constrained('ip_pools')->cascadeOnDelete(); + $table->string('ip_address', 45); + $table->string('action', 30)->index(); + $table->string('status_from', 20)->nullable(); + $table->string('status_to', 20)->nullable(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('customer_name')->nullable(); + $table->unsignedBigInteger('service_id')->nullable(); + $table->string('vm_name')->nullable(); + $table->text('email_to')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->text('note')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ip_pool_logs'); + + Schema::table('ip_pools', function (Blueprint $table) { + $table->dropConstrainedForeignId('customer_id'); + }); + } +}; diff --git a/database/migrations/2026_10_15_000006_seed_ip_email_templates.php b/database/migrations/2026_10_15_000006_seed_ip_email_templates.php new file mode 100644 index 0000000..fb29129 --- /dev/null +++ b/database/migrations/2026_10_15_000006_seed_ip_email_templates.php @@ -0,0 +1,20 @@ +seedScenarioIfMissing('ip_allocated'); + $seeder->seedScenarioIfMissing('ip_revoked'); + } + + public function down(): void + { + DB::table('template')->whereIn('scenario', ['ip_allocated', 'ip_revoked'])->delete(); + } +}; diff --git a/database/migrations/2026_10_15_000007_reseed_ip_allocated_email_template.php b/database/migrations/2026_10_15_000007_reseed_ip_allocated_email_template.php new file mode 100644 index 0000000..5c967ad --- /dev/null +++ b/database/migrations/2026_10_15_000007_reseed_ip_allocated_email_template.php @@ -0,0 +1,14 @@ +reseedScenario('ip_allocated'); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_15_000008_add_service_and_effective_date_to_ip_pool_logs.php b/database/migrations/2026_10_15_000008_add_service_and_effective_date_to_ip_pool_logs.php new file mode 100644 index 0000000..f3bb7da --- /dev/null +++ b/database/migrations/2026_10_15_000008_add_service_and_effective_date_to_ip_pool_logs.php @@ -0,0 +1,23 @@ +date('effective_date')->nullable()->after('action'); + $table->string('service_name')->nullable()->after('customer_name'); + }); + } + + public function down(): void + { + Schema::table('ip_pool_logs', function (Blueprint $table) { + $table->dropColumn(['effective_date', 'service_name']); + }); + } +}; diff --git a/database/seeders/EmailTemplates/BillingTemplates.php b/database/seeders/EmailTemplates/BillingTemplates.php index 718548b..f9536f6 100644 --- a/database/seeders/EmailTemplates/BillingTemplates.php +++ b/database/seeders/EmailTemplates/BillingTemplates.php @@ -118,29 +118,7 @@ final class BillingTemplates 'scenario' => 'domain_order_approved', 'name' => 'Bàn giao tên miền', 'subject' => 'Bàn giao tên miền {{domain_name}} - {{company_name}}', - 'body' => T::title('Bàn giao tên miền') - . T::greeting('{{client_name}}') - . T::paragraph('Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.') - . T::heading('🌐 Thông tin tên miền') - . T::infoTable([ - 'Tên miền' => '{{domain_name}}', - 'Mã đơn hàng' => '{{order_num}}', - 'Ngày đăng ký' => '{{registered_at}}', - 'Ngày hết hạn' => '{{expires_at}}', - 'NS1' => '{{name_server_1}}', - 'NS2' => '{{name_server_2}}', - ]) - . T::heading('🔐 Thông tin tài khoản quản lý') - . T::infoTable([ - 'Trang đăng nhập' => '{{login_url}}', - 'Tên đăng nhập' => '{{customer_email}}', - 'Mật khẩu' => 'Mật khẩu tài khoản portal của Quý khách', - ]) - . T::note('Quý khách vui lòng bảo mật thông tin tài khoản. Nếu quên mật khẩu, sử dụng chức năng "Quên mật khẩu" tại trang đăng nhập.') - . '{{kyc_block}}' - . T::heading('📞 Hỗ trợ kỹ thuật 24/7') - . T::supportLine() - . T::paragraph('Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý khách.'), + 'body' => self::domainOrderApprovedBody(), ], [ 'scenario' => 'domain_order_rejected', @@ -160,4 +138,33 @@ final class BillingTemplates ], ]; } + + private static function domainOrderApprovedBody(): string + { + return <<<'HTML' +

Kính gửi Quý Khách {{client_name}},

+

Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý Khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.

+ +
🌐 I. THÔNG TIN TÊN MIỀN
+ + + +
Tên miềnNgày đăng kýNgày hết hạnNS1NS2
{{domain_name}}{{registered_at}}{{expires_at}}{{name_server_1}}{{name_server_2}}
+ +
🔐 II. THÔNG TIN TÀI KHOẢN QUẢN LÝ
+

🔗 Trang đăng nhập: {{login_url}}

👤 Tên đăng nhập: {{customer_email}}

🔑 Mật khẩu: Mật khẩu tài khoản portal của Quý Khách

+

🔒 Quý Khách vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên và bảo mật thông tin tài khoản.

+ +
📋 III. LƯU Ý QUAN TRỌNG – XÁC THỰC TÊN MIỀN
+

Quý Khách cần hoàn tất thủ tục xác thực tên miền theo hướng dẫn để tránh tình trạng gián đoạn dịch vụ do chưa hoàn tất xác thực.

+{{kyc_block}} + +

📮 Bản gốc vui lòng gửi về:

CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ

128/38 Tân Hương, Phường Phú Thọ Hòa, TP. Hồ Chí Minh

Người nhận: Ms. Vân Anh – 0818 006 119 (P. HCNS)

+ +
📞 IV. HỖ TRỢ KỸ THUẬT 24/7
+

☎ Hotline: (028) 7309 7379

+

✉ Email: support@tgs.com.vn

+

Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý Khách. Chúng tôi luôn sẵn sàng hỗ trợ để đảm bảo trải nghiệm dịch vụ tốt nhất.

+HTML; + } } diff --git a/database/seeders/EmailTemplates/IpTemplates.php b/database/seeders/EmailTemplates/IpTemplates.php new file mode 100644 index 0000000..48ea27e --- /dev/null +++ b/database/seeders/EmailTemplates/IpTemplates.php @@ -0,0 +1,62 @@ + + */ + public static function all(): array + { + return [ + [ + 'scenario' => 'ip_allocated', + 'name' => 'Cấp phát địa chỉ IP', + 'subject' => 'Thông báo cấp phát {{ip_count}} địa chỉ IP', + 'body' => '
' + . '
' + . '
THÔNG BÁO CẤP PHÁT TÀI NGUYÊN IP
' + . '
 
' + . '
TP. Hồ Chí Minh, {{issued_on}}
' + . '
' + . '

Kính gửi Quý Khách hàng {{client_name}},

' + . '

Thế Giới Số trân trọng gửi thông tin cấp phát tài nguyên IP phục vụ triển khai dịch vụ.

' + . '' + . '' + . '' + . '
Thông tin IP● Đã cấp phát
' + . '{{ip_table}}' + . '
' + . '
Thế Giới Số xin chân thành cảm ơn Quý Khách hàng đã tin tưởng lựa chọn dịch vụ của chúng tôi.
' + . '
Trong quá trình sử dụng, nếu cần hỗ trợ kỹ thuật, Quý Khách hàng vui lòng liên hệ Thế Giới Số qua các kênh hỗ trợ bên dưới.
' + . '
Chúc Quý Khách hàng sử dụng dịch vụ hiệu quả và thành công.
' + . '
' + . '{{note_block}}' + . '' + . '
 ' + . '
Trân trọng,
BỘ PHẬN KỸ THUẬT
THẾ GIỚI SỐ
' + . '
' + . '
', + ], + [ + 'scenario' => 'ip_revoked', + 'name' => 'Thu hồi địa chỉ IP', + 'subject' => 'Thông báo thu hồi {{ip_count}} địa chỉ IP', + 'body' => T::title('Thông báo thu hồi địa chỉ IP') + . T::greeting('{{client_name}}') + . T::paragraph('{{app_name}} xin thông báo sẽ thu hồi {{ip_count}} địa chỉ IP dưới đây kể từ ngày {{revoked_at}}. Quý khách vui lòng ngừng sử dụng các địa chỉ IP này để tránh gián đoạn dịch vụ.') + . T::heading('Danh sách địa chỉ IP') + . T::infoTable([], '{{ip_rows}}') + . '{{note_block}}' + . T::supportLine(), + ], + ]; + } +} diff --git a/database/seeders/EmailTemplates/MarketingTemplates.php b/database/seeders/EmailTemplates/MarketingTemplates.php index 03b4d04..c399cbb 100644 --- a/database/seeders/EmailTemplates/MarketingTemplates.php +++ b/database/seeders/EmailTemplates/MarketingTemplates.php @@ -40,6 +40,39 @@ final class MarketingTemplates . T::button('https://tgs.com.vn/premium-hosting/', 'Xem bảng giá'), 'variables' => [], ], + [ + 'name' => 'Thông báo bổ sung hồ sơ bản khai tên miền', + 'subject' => 'V/v: Thế Giới Số thông báo bổ sung hồ sơ bản khai tên miền của Quý Công ty - {{customer_name}}', + 'body' => self::domainProfileSupplementBody(), + 'variables' => [ + ['key' => 'customer_name', 'label' => 'Tên công ty', 'example' => 'CÔNG TY TNHH KHÁCH HÀNG MẪU'], + ['key' => 'domain_count', 'label' => 'Số lượng tên miền', 'example' => '1'], + ['key' => 'domain_name', 'label' => 'Tên miền cần bổ sung hồ sơ', 'example' => 'example.vn'], + ['key' => 'declaration_form_url', 'label' => 'Link tải mẫu bản khai', 'example' => 'https://example.com/mau-ban-khai'], + ['key' => 'digital_signature_url', 'label' => 'Link xác thực chữ ký số', 'example' => 'https://example.com/xac-thuc-chu-ky-so'], + ['key' => 'supplement_deadline', 'label' => 'Hạn bổ sung hồ sơ', 'example' => '31/12/2026'], + ], + ], ]; } + + private static function domainProfileSupplementBody(): string + { + return <<<'HTML' +

Kính gửi Quý Công ty {{customer_name}},

+

Thế Giới Số trân trọng cảm ơn Quý Công ty đã tin tưởng sử dụng dịch vụ của chúng tôi.

+

Với mục đích giúp Quý Công ty xác định quyền sở hữu hợp pháp đối với tên miền và hạn chế rủi ro vi phạm quy định pháp luật về quản lý tài nguyên Internet, quy định cụ thể tại:

+

- Nghị định 147/2024/NĐ-CP ngày 09/11/2024 của Chính phủ về quản lý, cung cấp, sử dụng dịch vụ Internet và thông tin trên mạng.

- Thông tư 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ hướng dẫn về quản lý và sử dụng tài nguyên Internet.

+

Hệ thống Thế Giới Số ghi nhận Quý Công ty hiện có {{domain_count}} tên miền chưa đủ hồ sơ để xác định quyền sở hữu hợp pháp theo quy định, cụ thể như sau:

+
STTDịch vụ
1{{domain_name}}
+

📎 Hoàn tất xác thực Tổ chức – chọn 1 trong 2 cách sau

+

CÁCH 1  |  Hoàn thiện Bản khai (bản giấy)

Tải mẫu Bản khai, in, ký tên, đóng dấu (và đóng dấu giáp lai nếu có nhiều trang), sau đó chụp ảnh hoặc scan và phản hồi email này.

Tải mẫu Bản khai →

Gửi bản gốc đã ký, đóng dấu về:
CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
128/38 Tân Hương, Phường Phú Thọ Hòa, TP. Hồ Chí Minh
Người nhận: Ms. Vân Anh – 0818 006 119 (P. HCNS)

+
HOẶC
+

CÁCH 2  |  Xác thực Tổ chức bằng chữ ký số

Xác thực Tổ chức trực tuyến bằng chữ ký số.

Xác thực Tổ chức bằng chữ ký số →
+
+

Lưu ý:

Quý Công ty vui lòng hoàn tất trước ngày {{supplement_deadline}} để chúng tôi cập nhật quyền sở hữu hợp pháp đối với tên miền tới hệ thống Trung tâm Internet Việt Nam, tránh ảnh hưởng đến quyền lợi của chủ thể sử dụng tên miền.

- Gián đoạn sử dụng tên miền.

- Ảnh hưởng đến các nghiệp vụ duy trì tên miền.

- Bất lợi khi xảy ra tranh chấp hoặc có thể bị thu hồi tên miền.

Nếu Quý Công ty đã hoàn tất việc bổ sung hồ sơ, xin vui lòng bỏ qua email này.

+

Trong suốt quá trình thực hiện, nếu cần hỗ trợ, đội ngũ chuyên viên Thế Giới Số luôn sẵn sàng đồng hành cùng Quý Công ty.

+

Trân trọng,

+HTML; + } } diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php index b158407..ab79962 100644 --- a/database/seeders/EmailTemplates/TemplateVariables.php +++ b/database/seeders/EmailTemplates/TemplateVariables.php @@ -49,6 +49,13 @@ final class TemplateVariables 'price_row' => ['[Tự động] Dòng số tiền gia hạn', 'html', ''], 'invoice_url' => ['Link đơn hàng', 'url', 'https://tgs.com.vn/don-hang'], 'ip' => ['Địa chỉ IP', 'string', '103.0.0.10'], + 'ip_count' => ['Số địa chỉ IP', 'string', '2'], + 'ip_rows' => ['[Tự động] Danh sách địa chỉ IP', 'html', ''], + 'ip_table' => ['[Tự động] Bảng IP cấp phát (STT, IP, dịch vụ, ngày)', 'html', ''], + 'issued_on' => ['Ngày thông báo (ngày … tháng … năm …)', 'string', 'ngày 15 tháng 10 năm 2026'], + 'allocated_at' => ['Ngày cấp phát', 'string', '15/10/2026'], + 'revoked_at' => ['Ngày thu hồi', 'string', '31/10/2026'], + 'note_block' => ['[Tự động] Ghi chú', 'html', ''], 'service_url' => ['Link dịch vụ', 'url', 'https://tgs.com.vn/dich-vu'], 'new_password_row' => ['[Tự động] Dòng mật khẩu mới', 'html', ''], 'snapshot_name' => ['Tên snapshot', 'string', 'backup-2026-10-08'], diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index a80a612..750c1d1 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -8,6 +8,7 @@ use App\Support\MailPlaceholders; use Database\Seeders\EmailTemplates\AccountTemplates; use Database\Seeders\EmailTemplates\AdminTemplates; use Database\Seeders\EmailTemplates\BillingTemplates; +use Database\Seeders\EmailTemplates\IpTemplates; use Database\Seeders\EmailTemplates\LegacyTemplates; use Database\Seeders\EmailTemplates\MarketingTemplates; use Database\Seeders\EmailTemplates\ServiceTemplates; @@ -25,6 +26,8 @@ class TemplateSeeder extends Seeder foreach (MarketingTemplates::all() as $item) { $this->createMarketingIfMissing($item); } + + $this->createMarketingIfMissing(self::ipRevocationTemplate()); } public function seedScenarioIfMissing(string $scenario): void @@ -40,6 +43,15 @@ class TemplateSeeder extends Seeder } } + public function reseedScenario(string $scenario): void + { + foreach (self::templates() as $item) { + if ($item['scenario'] === $scenario) { + $this->upsert($item); + } + } + } + private function createMarketingIfMissing(array $item): void { $template = Template::firstOrNew(['name' => $item['name']]); @@ -51,7 +63,7 @@ class TemplateSeeder extends Seeder 'type_id' => 0, 'format' => 'html', 'subject' => $item['subject'], - 'message' => EmailTemplateShell::page($item['body']), + 'message' => $item['message'] ?? EmailTemplateShell::page($item['body']), 'status' => 1, ]); $template->syncVariables($item['variables']); @@ -75,6 +87,7 @@ class TemplateSeeder extends Seeder [ ...AccountTemplates::all(), ...ServiceTemplates::all(), + ...IpTemplates::all(), ...BillingTemplates::all(), ...AdminTemplates::all(), ], @@ -94,6 +107,43 @@ class TemplateSeeder extends Seeder ); } + /** + * Template mẫu thông báo danh sách tài nguyên IP đã thu hồi. + * + * @return array{name: string, subject: string, message: string, variables: array{}} + */ + private static function ipRevocationTemplate(): array + { + $subject = 'V/v: Thế Giới Số gửi thông tin Thu hồi IP — {{service_name}} — {{customer_name}}'; + + $message = <<<'HTML' +
+ + + + + +
  
Thế Giới Số
THÔNG BÁO THU HỒI TÀI NGUYÊN IP
   
{{notification_date}}

Kính gửi Quý Khách hàng {{customer_name}},

Thế Giới Số trân trọng thông báo thông tin thu hồi tài nguyên IP theo yêu cầu vận hành/dịch vụ.

+
Thông tin IP● Đã thu hồi
+
STTIP AddressDịch vụNgày thu hồi
1{{ip_address}}{{service_name}}{{revocation_date}}
+
Thế Giới Số xin chân thành cảm ơn Quý Khách hàng đã tin tưởng và đồng hành cùng chúng tôi trong thời gian qua.
Khi có nhu cầu sử dụng lại dịch vụ hoặc cấp mới tài nguyên IP,
Quý Khách hàng vui lòng liên hệ Thế Giới Số qua các kênh hỗ trợ sau.
Rất mong tiếp tục được phục vụ Quý Khách hàng trong thời gian tới.
+
Trân trọng,
BỘ PHẬN KỸ THUẬT
THẾ GIỚI SỐ
CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
✆ Tel: (028) 7309 7379  |  ✉ Support@tgs.com.vn
🥇 Thế Giới Số 🥇 - Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự 🚀
+HTML; + + return [ + 'name' => 'Thông báo thu hồi tài nguyên IP', + 'subject' => $subject, + 'message' => $message, + 'variables' => [ + ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'example' => 'CÔNG TY TNHH KHÁCH HÀNG MẪU'], + ['key' => 'service_name', 'label' => 'Tên dịch vụ', 'example' => 'Dịch vụ IP'], + ['key' => 'notification_date', 'label' => 'Ngày lập thông báo', 'example' => 'TP. Hồ Chí Minh, ngày 15 tháng 10 năm 2026'], + ['key' => 'ip_address', 'label' => 'Địa chỉ IP', 'example' => '192.0.2.1'], + ['key' => 'revocation_date', 'label' => 'Ngày thu hồi', 'example' => '15/10/2026'], + ], + ]; + } + private function upsert(array $item): void { $scenario = $item['scenario']; diff --git a/phpunit.xml b/phpunit.xml index 9865965..2860ca2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,6 +16,7 @@ + diff --git a/resources/views/pdf/web-design-quote.blade.php b/resources/views/pdf/web-design-quote.blade.php index 3ab5436..71a62fd 100644 --- a/resources/views/pdf/web-design-quote.blade.php +++ b/resources/views/pdf/web-design-quote.blade.php @@ -2,17 +2,7 @@ $brandColor = $brandColor ?? '#1a56db'; $companyLogo = $companyLogo ?? setting('setting.logo') ?? ''; -$frontendLabels = [ - 'nextjs' => 'Next.js', - 'react' => 'React', - 'vue' => 'Vue', - 'nuxt' => 'Nuxt', - 'astro' => 'Astro', - 'angular' => 'Angular', -]; -$frontendLabel = $quote->frontend_framework - ? ($frontendLabels[$quote->frontend_framework] ?? $quote->frontend_framework) - : null; +$frontendLabel = \App\Enums\WebDesign\WebDesignPlatform::frontendLabel($quote->frontend_framework); $items = is_array($quote->items) ? $quote->items : []; $referenceWebsites = is_array($quote->reference_websites) ? $quote->reference_websites : []; diff --git a/routes/api/admin/auth.php b/routes/api/admin/auth.php index 45c65a1..0d032d7 100644 --- a/routes/api/admin/auth.php +++ b/routes/api/admin/auth.php @@ -13,6 +13,8 @@ Route::get('me', [AuthController::class, 'me']); Route::put('profile', [AuthController::class, 'updateProfile']); +Route::put('password', [AuthController::class, 'updatePassword']); + // Role Permissions Route::prefix('role-permissions')->group(function () { // Accessible bởi mọi admin — trả về permissions hiệu lực của user hiện tại diff --git a/routes/api/admin/billing.php b/routes/api/admin/billing.php index b4e1434..01e40f4 100644 --- a/routes/api/admin/billing.php +++ b/routes/api/admin/billing.php @@ -13,6 +13,7 @@ Route::prefix('orders') ->controller(OrderController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:order.view'); + Route::get('export', 'export')->middleware('perm:order.view'); Route::get('/domain-pricing', 'domainPricing')->middleware('perm:order.create'); Route::post('/upload-proof', 'uploadProof')->middleware('perm:order.create'); Route::post('/', 'store')->middleware('perm:order.create'); @@ -28,6 +29,7 @@ Route::prefix('invoices') ->controller(InvoiceController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:invoice.view'); + Route::get('export', 'export')->middleware('perm:invoice.view'); Route::post('/', 'store')->middleware('perm:invoice.create'); Route::get('{id}', 'show')->middleware('perm:invoice.view'); Route::put('{id}', 'update')->middleware('perm:invoice.edit'); diff --git a/routes/api/admin/catalog.php b/routes/api/admin/catalog.php index d61fc47..8e852ae 100644 --- a/routes/api/admin/catalog.php +++ b/routes/api/admin/catalog.php @@ -97,5 +97,6 @@ Route::prefix('web-design-quotes') Route::put('{id}', 'update')->middleware('perm:order.edit'); Route::patch('{id}/status', 'updateStatus')->middleware('perm:order.edit'); Route::get('{id}/pdf', 'downloadPdf')->middleware('perm:order.view'); + Route::get('{id}/xlsx', 'downloadXlsx')->middleware('perm:order.view'); Route::delete('{id}', 'destroy')->middleware('perm:order.destroy'); }); diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php index 9e2c550..40f8193 100644 --- a/routes/api/admin/customers.php +++ b/routes/api/admin/customers.php @@ -89,4 +89,8 @@ Route::prefix('services')->controller(ServiceController::class)->group(function Route::get('{id}', 'show')->middleware('perm:customer.view'); Route::patch('{id}/detach-customer', 'detachCustomer')->middleware('perm:customer.edit'); Route::post('{id}/resend-credentials', 'resendCredentials')->middleware('perm:customer.edit'); + Route::get('{id}/attachments', 'listAttachments')->middleware('perm:customer.view'); + Route::post('{id}/attachments', 'uploadAttachment')->middleware('perm:customer.edit'); + Route::delete('{id}/attachments/{attachmentId}', 'deleteAttachment')->middleware('perm:customer.edit'); + Route::post('{id}/attachments/{attachmentId}/copy-to-invoice', 'copyAttachmentToInvoice')->middleware('perm:invoice.edit'); }); diff --git a/routes/api/admin/infrastructure.php b/routes/api/admin/infrastructure.php index b8a6239..7afcbff 100644 --- a/routes/api/admin/infrastructure.php +++ b/routes/api/admin/infrastructure.php @@ -106,9 +106,15 @@ Route::prefix('ip-pool') Route::get('/', 'index')->middleware('perm:module-vcenter.view'); Route::post('/', 'store')->middleware('perm:module-vcenter.manager'); Route::post('sync', 'sync')->middleware('perm:module-vcenter.manager'); + Route::post('import/preview', 'importPreview')->middleware('perm:module-vcenter.manager'); Route::post('import', 'import')->middleware('perm:module-vcenter.manager'); Route::delete('bulk', 'bulkDelete')->middleware('perm:module-vcenter.manager'); Route::post('bulk-update-gateway', 'bulkUpdateGateway')->middleware('perm:module-vcenter.manager'); + Route::post('allocation/preview', 'allocationPreview')->middleware('perm:module-vcenter.manager'); + Route::post('allocate', 'allocate')->middleware('perm:module-vcenter.manager'); + Route::post('revocation/preview', 'revocationPreview')->middleware('perm:module-vcenter.manager'); + Route::post('revoke', 'revoke')->middleware('perm:module-vcenter.manager'); + Route::get('{id}/logs', 'logs')->middleware('perm:module-vcenter.view'); Route::get('{id}', 'show')->middleware('perm:module-vcenter.view'); Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager'); Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager'); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index ed2f40c..a068ed7 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -140,8 +140,10 @@ Route::prefix('activity-logs') ->middleware('perm:system-log.manager') ->group(function () { Route::get('/', 'index'); + Route::get('export', 'export'); Route::get('modules', 'modules'); Route::get('stats', 'stats'); + Route::delete('/', 'destroy'); Route::get('{id}', 'show'); }); diff --git a/tests/Feature/Api/Admin/ActivityLog/ActivityLogManagementTest.php b/tests/Feature/Api/Admin/ActivityLog/ActivityLogManagementTest.php new file mode 100644 index 0000000..4871d6d --- /dev/null +++ b/tests/Feature/Api/Admin/ActivityLog/ActivityLogManagementTest.php @@ -0,0 +1,103 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + } + + public function test_export_uses_branded_template_and_selected_filters(): void + { + $this->createLog(['log_name' => 'orders', 'description' => 'Tạo đơn hàng']); + $this->createLog(['log_name' => 'customers', 'description' => 'Cập nhật khách hàng']); + + $response = $this->actingAs($this->admin, 'admin') + ->get('/api/v1/administrator/activity-logs/export?log_name=orders') + ->assertOk(); + + $sheet = IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet(); + + $this->assertStringStartsWith('CÔNG TY', $sheet->getCell('C1')->getValue()); + $this->assertCount(1, $sheet->getDrawingCollection()); + $this->assertSame('LỊCH SỬ HOẠT ĐỘNG', $sheet->getCell('A6')->getValue()); + $this->assertSame('ID', $sheet->getCell('A7')->getValue()); + $this->assertSame('orders', $sheet->getCell('B8')->getValue()); + $this->assertSame(8, $sheet->getHighestDataRow()); + } + + public function test_delete_by_date_range_keeps_logs_outside_range(): void + { + $selected = $this->createLog(['log_name' => 'orders']); + $remaining = $this->createLog(['log_name' => 'customers']); + $remaining->forceFill(['created_at' => now()->subDays(10)])->saveQuietly(); + + $this->actingAs($this->admin, 'admin') + ->deleteJson('/api/v1/administrator/activity-logs', [ + 'mode' => 'date_range', + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + ]) + ->assertOk(); + + $this->assertDatabaseMissing('activity_logs', ['id' => $selected->id]); + $this->assertDatabaseHas('activity_logs', ['id' => $remaining->id]); + } + + public function test_delete_all_removes_every_log(): void + { + $this->createLog(); + $this->createLog(); + $total = ActivityLog::query()->count(); + + $this->actingAs($this->admin, 'admin') + ->deleteJson('/api/v1/administrator/activity-logs', ['mode' => 'all']) + ->assertOk() + ->assertJsonPath('data.deleted', $total); + + $this->assertDatabaseCount('activity_logs', 0); + } + + public function test_delete_date_range_requires_valid_dates(): void + { + $this->actingAs($this->admin, 'admin') + ->deleteJson('/api/v1/administrator/activity-logs', [ + 'mode' => 'date_range', + 'date_from' => '2026-10-15', + 'date_to' => '2026-10-14', + ]) + ->assertUnprocessable(); + } + + private function createLog(array $attributes = []): ActivityLog + { + return ActivityLog::query()->create(array_merge([ + 'log_name' => 'system', + 'event' => 'updated', + 'description' => 'Thay đổi dữ liệu', + 'causer_type' => 'admin', + 'causer_id' => $this->admin->id, + 'causer_name' => $this->admin->name, + 'ip_address' => '127.0.0.1', + ], $attributes)); + } +} diff --git a/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php b/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php new file mode 100644 index 0000000..8732608 --- /dev/null +++ b/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php @@ -0,0 +1,101 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + + $customer = $this->customer = Customer::factory()->create(); + $product = Product::factory()->create(['product_type' => 'domain']); + + foreach (['first.vn', 'second.vn'] as $serverName) { + $this->actingAs($this->admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$customer->uuid}/services", + ['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0], + )->assertCreated(); + } + } + + public static function exports(): array + { + return [ + 'orders' => ['/api/v1/administrator/orders/export', 'Mã đơn hàng', 'DANH SÁCH ĐƠN HÀNG'], + 'invoices' => ['/api/v1/administrator/invoices/export', 'Mã hoá đơn', 'DANH SÁCH HOÁ ĐƠN'], + ]; + } + + #[DataProvider('exports')] + public function test_exports_every_row_into_branded_template(string $url, string $firstHeading, string $title): void + { + $response = $this->actingAs($this->admin, 'admin')->get("{$url}?per_page=1&page=1")->assertOk(); + + $sheet = IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet(); + + $this->assertStringStartsWith('CÔNG TY', $sheet->getCell('C1')->getValue()); + $this->assertCount(1, $sheet->getDrawingCollection()); + $this->assertSame($title, $sheet->getCell('A6')->getValue()); + $this->assertSame($firstHeading, $sheet->getCell('A7')->getValue()); + $this->assertSame('A8', $sheet->getFreezePane()); + $this->assertSame(9, $sheet->getHighestDataRow()); + + $services = collect($sheet->toArray())->flatten()->implode(' '); + $this->assertStringContainsString('first.vn', $services); + $this->assertStringContainsString('second.vn', $services); + } + + #[DataProvider('exports')] + public function test_export_applies_selected_filters(string $url): void + { + $this->assertSame(9, $this->exportedLastRow("{$url}?customer_id={$this->customer->id}")); + $this->assertSame(7, $this->exportedLastRow("{$url}?customer_id=" . ($this->customer->id + 1))); + $this->assertSame(7, $this->exportedLastRow("{$url}?status[]=0&status[]=3")); + $this->assertSame(8, $this->exportedLastRow("{$url}?filter=first")); + $this->assertSame(7, $this->exportedLastRow("{$url}?date_from=" . now()->addDay()->toDateString())); + } + + public function test_invoice_list_filters_by_numeric_status(): void + { + $this->actingAs($this->admin, 'admin')->getJson('/api/v1/administrator/invoices?status=2') + ->assertOk()->assertJsonPath('data.total', 2); + $this->actingAs($this->admin, 'admin')->getJson('/api/v1/administrator/invoices?status=1') + ->assertOk()->assertJsonPath('data.total', 0); + } + + public function test_export_rejects_unknown_filter_shapes(): void + { + $this->actingAs($this->admin, 'admin') + ->getJson('/api/v1/administrator/invoices/export?status=abc') + ->assertStatus(422); + } + + private function exportedLastRow(string $url): int + { + $response = $this->actingAs($this->admin, 'admin')->get($url)->assertOk(); + + return IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet()->getHighestDataRow(); + } +} diff --git a/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php b/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php index ffbb243..84e53a4 100644 --- a/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php +++ b/tests/Feature/Api/Admin/Customer/SendTemplateEmailTest.php @@ -10,7 +10,9 @@ use App\Models\Customer; use App\Models\Template; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; use Spatie\Permission\Models\Role; use Tests\TestCase; @@ -158,4 +160,36 @@ class SendTemplateEmailTest extends TestCase $this->assertDatabaseMissing('mail', ['customer_id' => $this->customer->id]); } + + public function test_send_attaches_uploaded_files(): void + { + Mail::fake(); + Storage::fake('local'); + $base = "/api/v1/administrator/customers/{$this->customer->uuid}/send-email"; + + $this->actingAs($this->admin, 'admin') + ->post($base, [ + 'subject' => 'Hợp đồng', + 'message' => '

Gửi hợp đồng

', + 'to' => $this->customer->email, + 'attachments' => [ + UploadedFile::fake()->create('hop-dong.pdf', 100, 'application/pdf'), + UploadedFile::fake()->create('phu-luc.docx', 50), + ], + ], ['Accept' => 'application/json']) + ->assertOk(); + + Mail::assertQueued(ManualCustomerEmail::class, function (ManualCustomerEmail $mail) { + $names = array_map(fn ($attachment) => $attachment->as, $mail->attachments()); + + return $names === ['hop-dong.pdf', 'phu-luc.docx']; + }); + $this->assertCount(2, Storage::disk('local')->allFiles("customer-emails/{$this->customer->id}")); + + $this->post($base, [ + 'subject' => 'Hợp đồng', + 'message' => '

Gửi hợp đồng

', + 'attachments' => [UploadedFile::fake()->create('script.php', 1)], + ], ['Accept' => 'application/json'])->assertStatus(422); + } } diff --git a/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php b/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php new file mode 100644 index 0000000..fb959b1 --- /dev/null +++ b/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php @@ -0,0 +1,251 @@ +seed(\Database\Seeders\TemplateSeeder::class); + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(['username' => 'kythuat']); + $this->admin->assignRole('Full Administrator'); + $this->customer = Customer::factory()->create(['fullname' => 'Công ty A', 'email' => 'a@example.com']); + $this->network = ModuleNetwork::create(['name' => 'VM Network 56']); + } + + private function pool(string $ip, array $attributes = []): IpPool + { + return IpPool::create([ + 'ip_address' => $ip, 'module_network_id' => $this->network->id, 'cidr' => 22, + 'gateway' => '10.0.0.1', 'status' => 'free', ...$attributes, + ]); + } + + private function allocation(array $ids): array + { + return [ + 'ids' => $ids, 'customer_uuid' => $this->customer->uuid, 'to' => 'a@example.com', + 'cc' => 'kythuat@tgs.com.vn', 'date' => '2026-10-15', 'service_name' => 'Colo 1U', + ]; + } + + public function test_preview_lists_ips_without_changing_them(): void + { + $pool = $this->pool('10.0.0.5'); + + $response = $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocation/preview', $this->allocation([$pool->id])) + ->assertOk() + ->assertJsonPath('data.subject', 'Thông báo cấp phát 1 địa chỉ IP'); + + $this->assertStringContainsString('10.0.0.5', $response->json('data.html')); + $this->assertStringContainsString('Colo <b>1U</b>', $response->json('data.html')); + $this->assertSame('free', $pool->fresh()->status); + } + + public function test_allocate_assigns_ips_logs_and_emails_customer(): void + { + Mail::fake(); + $first = $this->pool('10.0.0.5'); + $second = $this->pool('10.0.0.6'); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocate', $this->allocation([$first->id, $second->id])) + ->assertOk(); + + $first->refresh(); + $this->assertSame('assigned', $first->status); + $this->assertSame($this->customer->id, $first->customer_id); + $this->assertSame('Công ty A', $first->customer_name); + $this->assertSame('2026-10-15', $first->allocated_at->toDateString()); + $this->assertSame('ALLOCATED_SENT', $first->last_email_type); + $this->assertSame('kythuat', $first->updated_by); + + $log = IpPoolLog::where('ip_pool_id', $first->id)->sole(); + $this->assertSame([IpPoolLog::ALLOCATED, 'free', 'assigned', $this->admin->id], [$log->action, $log->status_from, $log->status_to, $log->user_id]); + $this->assertSame(['2026-10-15', 'Colo 1U'], [$log->effective_date->toDateString(), $log->service_name]); + + Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->type === IpAllocationMail::ALLOCATED + && count($mail->ips) === 2 + && $mail->hasTo('a@example.com') + && $mail->hasCc('kythuat@tgs.com.vn')); + $this->assertSame(1, MailLog::where('customer_id', $this->customer->id)->count()); + } + + public function test_each_ip_can_have_its_own_service(): void + { + Mail::fake(); + $first = $this->pool('10.0.0.5'); + $second = $this->pool('10.0.0.6'); + $payload = [...$this->allocation([$first->id, $second->id]), 'service_name' => 'Dịch vụ IP', 'services' => [$second->id => 'CLOUD VPS SILVER']]; + + $html = $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocation/preview', $payload) + ->assertOk() + ->json('data.html'); + $this->assertStringContainsString('CLOUD VPS SILVER', $html); + $this->assertStringContainsString('Dịch vụ IP', $html); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocate', $payload) + ->assertOk(); + + Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'Thông báo cấp phát 2 địa chỉ IP'); + $this->assertSame('Dịch vụ IP', $first->fresh()->service_name); + $this->assertSame('CLOUD VPS SILVER', $second->fresh()->service_name); + } + + public function test_admin_can_override_email_subject(): void + { + Mail::fake(); + $pool = $this->pool('10.0.0.5'); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocate', [...$this->allocation([$pool->id]), 'subject' => 'Bàn giao IP cho Công ty A']) + ->assertOk(); + + Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'Bàn giao IP cho Công ty A'); + $this->assertSame('Bàn giao IP cho Công ty A', MailLog::where('customer_id', $this->customer->id)->value('subject')); + } + + public function test_allocation_email_carries_uploaded_attachments(): void + { + Mail::fake(); + Storage::fake('local'); + $pool = $this->pool('10.0.0.5'); + + $this->actingAs($this->admin, 'admin') + ->post(self::BASE . '/allocate', [ + ...$this->allocation([$pool->id]), + 'attachments' => [UploadedFile::fake()->createWithContent('bien-ban.pdf', "%PDF-1.4\n\xff\xfe\x00\x9c binary")], + ], ['Accept' => 'application/json']) + ->assertOk(); + + Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => count($mail->storedFiles) === 1 + && $mail->storedFiles[0]['name'] === 'bien-ban.pdf' + && Storage::disk('local')->exists($mail->storedFiles[0]['path']) + && count($mail->attachments()) === 1 + && json_encode(serialize($mail)) !== false); + } + + public function test_failed_email_dispatch_rolls_back_the_allocation(): void + { + $pool = $this->pool('10.0.0.5'); + Mail::shouldReceive('to')->andThrow(new \RuntimeException('queue down')); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocate', $this->allocation([$pool->id])) + ->assertStatus(500); + + $this->assertSame('free', $pool->fresh()->status); + $this->assertNull($pool->fresh()->customer_id); + $this->assertSame(0, IpPoolLog::count()); + $this->assertSame(0, MailLog::count()); + } + + public function test_allocate_rejects_ips_that_are_not_free(): void + { + Mail::fake(); + $free = $this->pool('10.0.0.5'); + $taken = $this->pool('10.0.0.6', ['status' => 'assigned']); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/allocate', $this->allocation([$free->id, $taken->id])) + ->assertStatus(422) + ->assertJsonFragment(['ids' => ['Chỉ cấp phát được IP đang trống. IP không hợp lệ: 10.0.0.6']]); + + $this->assertSame('free', $free->fresh()->status); + Mail::assertNothingQueued(); + } + + public function test_revoke_groups_by_customer_and_frees_ips(): void + { + Mail::fake(); + $other = Customer::factory()->create(['fullname' => 'Công ty B']); + $a = $this->pool('10.0.0.5', ['status' => 'assigned', 'customer_id' => $this->customer->id, 'customer_name' => 'Công ty A', 'service_name' => 'CLOUD VPS SILVER', 'contact_email' => 'a@example.com']); + $b = $this->pool('10.0.0.6', ['status' => 'assigned', 'customer_id' => $other->id, 'customer_name' => 'Công ty B']); + + $groups = $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/revocation/preview', ['ids' => [$a->id, $b->id], 'date' => '2026-10-31']) + ->assertOk() + ->assertJsonCount(2, 'data') + ->json('data'); + + $missingEmail = collect($groups)->firstWhere('customer_name', 'Công ty B'); + $this->assertNull($missingEmail['to']); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/revoke', [ + 'ids' => [$a->id, $b->id], 'date' => '2026-10-31', + 'recipients' => collect($groups)->map(fn ($g) => ['key' => $g['key'], 'to' => $g['to']])->all(), + ]) + ->assertStatus(422); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/revoke', [ + 'ids' => [$a->id, $b->id], 'date' => '2026-10-31', + 'recipients' => collect($groups)->map(fn ($g) => ['key' => $g['key'], 'to' => $g['to'] ?? 'b@example.com'])->all(), + ]) + ->assertOk(); + + $a->refresh(); + $this->assertSame('free', $a->status); + $this->assertNull($a->customer_id); + $this->assertSame('2026-10-31', $a->revoked_at->toDateString()); + $log = IpPoolLog::where('ip_pool_id', $a->id)->sole(); + $this->assertSame([IpPoolLog::REVOKED, 'Công ty A', $this->customer->id], [$log->action, $log->customer_name, $log->customer_id]); + $this->assertSame(['2026-10-31', 'CLOUD VPS SILVER'], [$log->effective_date->toDateString(), $log->service_name]); + $this->assertNull($a->service_name); + Mail::assertQueuedCount(2); + } + + public function test_revoke_refuses_ips_used_by_a_vm(): void + { + $pool = $this->pool('10.0.0.7', ['status' => 'assigned']); + ModuleIpAddress::create(['ip' => '10.0.0.7', 'module_network_id' => $this->network->id, 'status' => 1]); + + $this->actingAs($this->admin, 'admin') + ->postJson(self::BASE . '/revocation/preview', ['ids' => [$pool->id], 'date' => '2026-10-31']) + ->assertStatus(422); + } + + public function test_logs_endpoint_returns_history(): void + { + $pool = $this->pool('10.0.0.8'); + IpPoolLog::record($pool, IpPoolLog::ALLOCATED, 'free', ['customer_name' => 'Công ty A']); + + $this->actingAs($this->admin, 'admin') + ->getJson(self::BASE . "/{$pool->id}/logs") + ->assertOk() + ->assertJsonPath('data.0.action', IpPoolLog::ALLOCATED) + ->assertJsonPath('data.0.customer_name', 'Công ty A'); + } +} diff --git a/tests/Feature/Api/Admin/IpPool/IpPoolSheetImportTest.php b/tests/Feature/Api/Admin/IpPool/IpPoolSheetImportTest.php new file mode 100644 index 0000000..ed1b4cb --- /dev/null +++ b/tests/Feature/Api/Admin/IpPool/IpPoolSheetImportTest.php @@ -0,0 +1,136 @@ + 'role-' . uniqid(), 'guard_name' => 'admin']); + $role->givePermissionTo(Permission::firstOrCreate(['name' => 'module-vcenter.manager', 'guard_name' => 'admin'])); + $admin = User::factory()->create(); + $admin->assignRole($role); + + return $admin; + } + + private function row(array $values): array + { + return array_map(fn ($header) => $values[$header] ?? null, self::HEADER); + } + + private function xlsx(array $rows): UploadedFile + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->setTitle('CONFIG'); + $sheet = $spreadsheet->createSheet()->setTitle('IP_POOL'); + $sheet->fromArray([self::HEADER, ...$rows]); + $sheet->setCellValue('Y2', '=XLOOKUP(D2,CONFIG!A:A,CONFIG!B:B)'); + + $path = tempnam(sys_get_temp_dir(), 'ip') . '.xlsx'; + (new Xlsx($spreadsheet))->setPreCalculateFormulas(false)->save($path); + + return new UploadedFile($path, 'ip.xlsx', null, null, true); + } + + public function test_status_follows_sheet_unless_the_system_owns_the_ip(): void + { + $this->assertSame('assigned', IpPoolSheetImporter::resolveStatus(null, false, 'ALLOCATED')); + $this->assertSame('assigned', IpPoolSheetImporter::resolveStatus('free', false, 'ALLOCATED')); + $this->assertSame('free', IpPoolSheetImporter::resolveStatus('assigned', false, 'FREE')); + $this->assertSame('assigned', IpPoolSheetImporter::resolveStatus('assigned', true, 'FREE')); + $this->assertSame('reserved', IpPoolSheetImporter::resolveStatus('reserved', true, 'ALLOCATED')); + $this->assertSame('free', IpPoolSheetImporter::resolveStatus(null, false, null)); + } + + public function test_preview_reports_changes_without_writing_and_import_applies_them(): void + { + $network = ModuleNetwork::create(['name' => 'VM Network 56']); + IpPool::create([ + 'ip_address' => '10.0.0.5', 'module_network_id' => $network->id, 'cidr' => 24, + 'gateway' => '10.0.0.1', 'status' => 'free', + ]); + IpPool::create([ + 'ip_address' => '10.0.0.6', 'module_network_id' => $network->id, 'cidr' => 24, 'status' => 'free', + ]); + ModuleIpAddress::create(['ip' => '10.0.0.6', 'module_network_id' => $network->id, 'status' => 1]); + + $file = $this->xlsx([ + $this->row([ + 'TEN_MANG' => "THEGIOISO-VN\t\t", 'CIDR' => '/22', 'OWNER_TYPE' => 'thế giới số', + 'IP_ADDRESS' => '10.0.0.5', 'TEN_KHACH_HANG' => 'Khách A', 'TRANG_THAI' => 'ALLOCATED', + 'NGAY_CAP_PHAT' => ExcelDate::PHPToExcel(new \DateTime('2026-04-15')), + 'CONTACT_EMAIL' => "\ta@example.com; b@example.com", 'CONTACT_PHONE' => 835123959, + 'MONTHLY_COST' => 60000, + ]), + $this->row(['TEN_MANG' => 'THEGIOISO-VN', 'IP_ADDRESS' => '10.0.0.6', 'TRANG_THAI' => 'FREE']), + $this->row(['TEN_MANG' => 'HOITUSO-VN', 'IP_ADDRESS' => '10.0.1.9', 'TRANG_THAI' => 'ALLOCATED']), + $this->row(['CONTACT_CC' => 'kythuat@tgs.com.vn']), + $this->row(['TEN_MANG' => 'HOITUSO-VN', 'IP_ADDRESS' => '10.0.1.10', 'CONTACT_EMAIL' => 'not-an-email']), + ]); + + $admin = $this->admin(); + + $this->actingAs($admin, 'admin') + ->post('/api/v1/administrator/ip-pool/import/preview', ['file' => $file], ['Accept' => 'application/json']) + ->assertOk() + ->assertJsonPath('data.valid', 3) + ->assertJsonPath('data.skipped_blank', 1) + ->assertJsonPath('data.error_count', 1) + ->assertJsonPath('data.errors.0.row', 6) + ->assertJsonPath('data.create', 1) + ->assertJsonPath('data.update', 2) + ->assertJsonPath('data.to_assigned', 2) + ->assertJsonPath('data.kept_by_system', 1); + + $this->assertSame(2, IpPool::count()); + + $this->actingAs($admin, 'admin') + ->post('/api/v1/administrator/ip-pool/import', ['file' => $file], ['Accept' => 'application/json']) + ->assertOk(); + + $updated = IpPool::where('ip_address', '10.0.0.5')->sole(); + $this->assertSame($network->id, $updated->module_network_id); + $this->assertSame('assigned', $updated->status); + $this->assertSame('THEGIOISO-VN', $updated->ip_block); + $this->assertSame('THẾ GIỚI SỐ', $updated->owner_type); + $this->assertSame(22, $updated->cidr); + $this->assertSame('10.0.0.1', $updated->gateway); + $this->assertSame('a@example.com, b@example.com', $updated->contact_email); + $this->assertSame('0835123959', $updated->contact_phone); + $this->assertSame('2026-04-15', $updated->allocated_at->toDateString()); + $this->assertSame('60000.00', $updated->monthly_cost); + + $this->assertSame('free', IpPool::where('ip_address', '10.0.0.6')->value('status')); + + $created = IpPool::where('ip_address', '10.0.1.9')->sole(); + $this->assertNull($created->module_network_id); + $this->assertSame('assigned', $created->status); + $this->assertFalse(IpPool::where('ip_address', '10.0.1.10')->exists()); + } +} diff --git a/tests/Feature/Api/Admin/IpPool/IpPoolUsageTest.php b/tests/Feature/Api/Admin/IpPool/IpPoolUsageTest.php new file mode 100644 index 0000000..27c63b7 --- /dev/null +++ b/tests/Feature/Api/Admin/IpPool/IpPoolUsageTest.php @@ -0,0 +1,57 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + $network = ModuleNetwork::create(['name' => 'VM Network 56']); + $customer = Customer::factory()->create(['fullname' => 'Công ty A']); + foreach (['10.0.0.5', '10.0.0.6', '10.0.0.7'] as $ip) { + IpPool::create(['ip_address' => $ip, 'module_network_id' => $network->id, 'cidr' => 24, 'status' => 'assigned']); + ModuleIpAddress::create(['ip' => $ip, 'module_network_id' => $network->id, 'status' => 1, 'vm_name' => "{$ip}_vm"]); + } + + $vmware = ModuleVmware::create(['name' => '10.0.0.5_customer-vm', 'ip' => '10.0.0.5', 'customer_id' => $customer->id]); + $service = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'server_name' => 'vps-a', 'vm_id' => $vmware->id]); + Schema::disableForeignKeyConstraints(); + $vcenterVm = VirtualMachines::create(['uuid' => (string) Str::uuid(), 'vm_id' => 'vm-1', 'name' => '10.0.0.6_test-vm', 'datacenter_id' => 1, 'host_id' => 1]); + + $items = collect($this->actingAs($admin, 'admin') + ->getJson('/api/v1/administrator/ip-pool?all=1') + ->assertOk() + ->json('data.items')) + ->keyBy('ip_address'); + + $this->assertSame($service->id, $items['10.0.0.5']['usage']['service_id']); + $this->assertSame('vps-a', $items['10.0.0.5']['usage']['service_name']); + $this->assertSame($customer->uuid, $items['10.0.0.5']['usage']['customer_uuid']); + $this->assertSame($vcenterVm->id, $items['10.0.0.6']['usage']['vcenter_vm_id']); + $this->assertNull($items['10.0.0.6']['usage']['service_id']); + $this->assertSame('10.0.0.7_vm', $items['10.0.0.7']['usage']['vm_name']); + $this->assertNull($items['10.0.0.7']['usage']['vcenter_vm_id']); + } +} diff --git a/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php b/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php new file mode 100644 index 0000000..0d175d9 --- /dev/null +++ b/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php @@ -0,0 +1,117 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + + $customer = Customer::factory()->create(); + $order = $this->makeOrder($customer); + $item = OrderItem::create(['order_id' => $order->id, 'product_id' => null, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 100000]); + $this->service = Service::create(['customer_id' => $customer->id, 'order_item_id' => $item->id, 'status' => 'active']); + $this->invoice = $this->makeInvoice($customer, $order); + $this->foreignInvoice = $this->makeInvoice($customer, $this->makeOrder($customer)); + } + + public function test_upload_list_copy_to_invoice_and_delete(): void + { + $base = "/api/v1/administrator/services/{$this->service->id}/attachments"; + $admin = $this->actingAs($this->admin, 'admin'); + + $id = $admin->post($base, ['file' => UploadedFile::fake()->create('spec.pdf', 10, 'application/pdf')], ['Accept' => 'application/json']) + ->assertCreated() + ->json('data.id'); + + $admin->getJson($base) + ->assertOk() + ->assertJsonPath('data.attachments.0.filename', 'spec.pdf') + ->assertJsonCount(1, 'data.invoices') + ->assertJsonPath('data.invoices.0.id', $this->invoice->id); + + $admin->postJson("$base/$id/copy-to-invoice", ['invoice_id' => $this->foreignInvoice->id])->assertNotFound(); + + $admin->postJson("$base/$id/copy-to-invoice", ['invoice_id' => $this->invoice->id])->assertCreated(); + $admin->postJson("$base/$id/copy-to-invoice", ['invoice_id' => $this->invoice->id])->assertCreated(); + $copy = InvoiceAttachment::where('invoice_id', $this->invoice->id)->sole(); + $this->assertSame(InvoiceAttachment::TYPE_DEPLOYMENT, $copy->type); + Storage::disk('public')->assertExists($copy->path); + + $admin->deleteJson("$base/$id")->assertOk(); + $this->assertDatabaseCount('service_attachments', 0); + Storage::disk('public')->assertExists($copy->path); + } + + public function test_list_and_detail_expose_linked_order_and_invoices(): void + { + $admin = $this->actingAs($this->admin, 'admin'); + + $admin->getJson('/api/v1/administrator/services') + ->assertOk() + ->assertJsonPath('data.items.0.order_item.order.order_num', $this->invoice->order->order_num) + ->assertJsonCount(1, 'data.items.0.linked_invoices') + ->assertJsonPath('data.items.0.linked_invoices.0.invoice_number', $this->invoice->invoice_number); + + $admin->getJson("/api/v1/administrator/services/{$this->service->id}") + ->assertOk() + ->assertJsonPath('data.linked_invoices.0.id', $this->invoice->id); + } + + public function test_rejects_unsupported_file_type(): void + { + $this->actingAs($this->admin, 'admin') + ->post("/api/v1/administrator/services/{$this->service->id}/attachments", ['file' => UploadedFile::fake()->create('x.exe', 10)], ['Accept' => 'application/json']) + ->assertStatus(422); + } + + private function makeOrder(Customer $customer): Order + { + return Order::create(['order_num' => 'ORD-' . uniqid(), 'customer_id' => $customer->id, 'sub_total' => 100_000, 'total' => 100_000, 'status' => OrderStatus::PENDING->value]); + } + + private function makeInvoice(Customer $customer, Order $order): Invoice + { + return Invoice::create([ + 'invoice_number' => 'INV-' . uniqid(), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => 100_000, + 'tax' => 0, + 'total' => 100_000, + 'status' => InvoiceStatus::UNPAID->value, + 'due_date' => now()->addDays(5)->toDateString(), + ]); + } +} diff --git a/tests/Feature/Api/Admin/UpdatePasswordTest.php b/tests/Feature/Api/Admin/UpdatePasswordTest.php new file mode 100644 index 0000000..688a439 --- /dev/null +++ b/tests/Feature/Api/Admin/UpdatePasswordTest.php @@ -0,0 +1,61 @@ +create(['password' => Hash::make('OldPassword1')]); + + $this->actingAs($user, 'admin') + ->putJson('/api/v1/administrator/password', [ + 'current_password' => 'OldPassword1', + 'password' => 'NewPassword2', + 'password_confirmation' => 'NewPassword2', + ]) + ->assertOk() + ->assertJsonPath('error', false); + + $this->assertTrue(Hash::check('NewPassword2', $user->fresh()->password)); + } + + public function test_admin_must_provide_the_correct_current_password(): void + { + $user = User::factory()->create(['password' => Hash::make('OldPassword1')]); + + $this->actingAs($user, 'admin') + ->putJson('/api/v1/administrator/password', [ + 'current_password' => 'WrongPassword', + 'password' => 'NewPassword2', + 'password_confirmation' => 'NewPassword2', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('current_password'); + + $this->assertTrue(Hash::check('OldPassword1', $user->fresh()->password)); + } + + public function test_new_password_must_be_confirmed_and_different(): void + { + $user = User::factory()->create(['password' => Hash::make('OldPassword1')]); + + $this->actingAs($user, 'admin') + ->putJson('/api/v1/administrator/password', [ + 'current_password' => 'OldPassword1', + 'password' => 'OldPassword1', + 'password_confirmation' => 'DifferentPassword2', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('password'); + } +} diff --git a/tests/Feature/Api/Admin/UpdateProfileTest.php b/tests/Feature/Api/Admin/UpdateProfileTest.php new file mode 100644 index 0000000..20de287 --- /dev/null +++ b/tests/Feature/Api/Admin/UpdateProfileTest.php @@ -0,0 +1,30 @@ +create(['fullname' => 'Old Name']); + + $this->actingAs($user, 'admin') + ->putJson('/api/v1/administrator/profile', [ + 'fullname' => 'Nguyễn Văn A', + 'phone' => '0901234567', + ]) + ->assertOk() + ->assertJsonPath('data.user.fullname', 'Nguyễn Văn A'); + + $this->assertSame('Nguyễn Văn A', $user->fresh()->fullname); + $this->assertSame('0901234567', $user->fresh()->phone); + } +} diff --git a/tests/Feature/Api/Admin/WebDesign/WebDesignQuoteXlsxTest.php b/tests/Feature/Api/Admin/WebDesign/WebDesignQuoteXlsxTest.php new file mode 100644 index 0000000..8e7a896 --- /dev/null +++ b/tests/Feature/Api/Admin/WebDesign/WebDesignQuoteXlsxTest.php @@ -0,0 +1,61 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + $quote = WebDesignQuote::create([ + 'quote_number' => 'BG-WEB-202610-0001', + 'status' => 'draft', + 'customer_name' => 'Nguyễn Văn A', + 'website_name' => 'shop.vn', + 'website_category' => 'Thương mại điện tử', + 'platform' => 'wordpress_elementor', + 'items' => [ + ['name' => 'Thiết kế UI', 'description' => '
  • Trang chủ
  • Sản phẩm
', 'quantity' => 1, 'unit_price' => 8000000], + ['name' => '=HYPERLINK("x")', 'description' => null, 'quantity' => 2, 'unit_price' => 1000000], + ], + 'subtotal' => 10000000, + 'discount' => 500000, + 'vat_percent' => 8, + 'total' => 10260000, + 'currency' => 'VND', + ]); + + $response = $this->actingAs($admin, 'admin') + ->get("/api/v1/administrator/web-design-quotes/{$quote->id}/xlsx") + ->assertOk(); + + $book = IOFactory::load($response->baseResponse->getFile()->getPathname()); + $sheet = $book->getActiveSheet(); + $cells = collect($sheet->toArray(null, true, false, true)); + + $this->assertSame(1, $book->getSheetCount()); + $this->assertCount(1, $sheet->getDrawingCollection()); + $this->assertSame('BÁO GIÁ THIẾT KẾ WEBSITE SHOP.VN', $sheet->getCell('A6')->getValue()); + + $itemRow = $cells->search(fn (array $row) => $row['B'] === 'Thiết kế UI'); + $this->assertSame("- Trang chủ\n- Sản phẩm", $sheet->getCell("C{$itemRow}")->getValue()); + $this->assertSame('=HYPERLINK("x")', $sheet->getCell('B' . ($itemRow + 1))->getCalculatedValue()); + + $totalRow = $cells->search(fn (array $row) => $row['A'] === 'Tổng giá trị báo giá'); + $this->assertEquals($quote->total, $sheet->getCell("F{$totalRow}")->getCalculatedValue()); + } +} diff --git a/tests/Feature/Mail/MailSettingsQueueTest.php b/tests/Feature/Mail/MailSettingsQueueTest.php new file mode 100644 index 0000000..44e149c --- /dev/null +++ b/tests/Feature/Mail/MailSettingsQueueTest.php @@ -0,0 +1,48 @@ + 'setting'], ['value' => json_encode(['company' => 'Thế Giới Số'])]); + Setting::updateOrCreate(['key' => 'smtp'], ['value' => json_encode([ + 'host' => $host, 'port' => 465, 'user' => 'noreply@tgs.com.vn', + 'password' => SignRSA::EncryptFunc('secret'), 'encryption' => 'ssl', + 'from_address' => 'noreply@tgs.com.vn', 'from_name' => 'TGS', + ])]); + Cache::forget(MailSettings::cacheKey()); + } + + private function hostSeenByQueuedJob(): ?string + { + dispatch(fn () => Cache::put('test:queued-mail-host', config('mail.mailers.smtp.host'))); + + return Cache::pull('test:queued-mail-host'); + } + + public function test_queued_jobs_use_smtp_from_settings_and_pick_up_changes(): void + { + config(['queue.default' => 'sync']); + + $this->saveSmtp('smtp.settings-one.test'); + $this->assertSame('smtp.settings-one.test', $this->hostSeenByQueuedJob()); + $this->assertSame('noreply@tgs.com.vn', config('mail.from.address')); + $this->assertSame('secret', config('mail.mailers.smtp.password')); + + $this->saveSmtp('smtp.settings-two.test'); + $this->assertSame('smtp.settings-two.test', $this->hostSeenByQueuedJob()); + } +}