From 46aa138eec31d2abbcda2ddedce3b6fa2d272501 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Thu, 15 Oct 2026 19:58:36 +0700 Subject: [PATCH] feat(admin): IP pool allocation, billing exports, service attachments - IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke with notification email (ip_allocated/ip_revoked), ip_pool_logs history. - Orders/invoices: Excel export on branded template (BrandedReportWriter), shared billing filters, order item service info in list responses. - Web design quote Excel export; activity log export. - Service attachments with copy-to-invoice; deployment invoice attachments. - Customer email attachments stored on disk and attached by path. - Admin password change endpoint; user.fullname backfill migration; orders.user_id backfill from logs. - Disable deploy workflow until CI/CD is set up. - Ignore stored customer email and service attachment uploads. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 89 ++-- .gitignore | 2 + app/Enums/WebDesign/WebDesignPlatform.php | 16 + app/Exports/ActivityLogsExport.php | 72 +++ app/Exports/BrandedReport.php | 27 ++ app/Exports/BrandedReportWriter.php | 190 ++++++++ app/Exports/Concerns/FormatsBillingCells.php | 36 ++ app/Exports/InvoicesExport.php | 68 +++ app/Exports/OrdersExport.php | 60 +++ .../Templates/report_pricing_service.xlsx | Bin 0 -> 94202 bytes app/Exports/Templates/report_template.xlsx | Bin 0 -> 74401 bytes app/Exports/WebDesignQuoteSpreadsheet.php | 423 ++++++++++++++++++ .../ActivityLog/ActivityLogController.php | 79 +++- .../Api/Admin/Auth/AuthController.php | 23 + .../Concerns/ManagesCustomerAccount.php | 2 + .../Concerns/HandlesInvoiceAttachments.php | 2 +- .../Api/Admin/Invoice/InvoiceController.php | 25 +- .../Concerns/ManagesIpPoolAllocation.php | 95 ++++ .../Concerns/ManagesIpPoolBulkActions.php | 43 +- .../Api/Admin/IpPool/IpPoolController.php | 72 +-- .../Api/Admin/Order/OrderController.php | 26 +- .../Concerns/HandlesServiceAttachments.php | 82 ++++ .../Api/Admin/Service/ServiceController.php | 24 +- .../Api/Admin/Setting/SettingController.php | 5 +- .../Api/Admin/Staff/StaffOnlineController.php | 2 +- .../WebDesign/WebDesignQuoteController.php | 19 +- app/Http/Requests/BillingExportRequest.php | 27 ++ .../Requests/UpdateAdminPasswordRequest.php | 54 +++ app/Http/Requests/UpdateProfileRequest.php | 3 +- .../Resources/Api/Admin/InvoiceResource.php | 4 + .../Api/Admin/OrderItemServiceResource.php | 39 ++ .../Resources/Api/Admin/OrderResource.php | 44 +- app/Imports/IpPoolImport.php | 143 ------ app/Jobs/VirtualMachine/ChangeIpVMJob.php | 3 + .../Concerns/ManagesIpReservation.php | 3 + app/Mail/Concerns/AttachesStoredFiles.php | 20 + app/Mail/Customer/ManualCustomerEmail.php | 6 +- app/Mail/GenericTemplateMail.php | 9 +- app/Mail/IpAllocationMail.php | 114 +++++ app/Models/Invoice.php | 21 + app/Models/InvoiceAttachment.php | 1 + app/Models/IpPool.php | 13 +- app/Models/IpPoolLog.php | 60 +++ app/Models/ServiceAttachment.php | 23 + app/Models/User.php | 2 +- app/Providers/MailConfigServiceProvider.php | 77 +--- .../ActivityLog/ActivityLogRepository.php | 8 +- .../ActivityLogRepositoryInterface.php | 3 + .../Concerns/FiltersBillingRecords.php | 37 ++ .../Employee/EmployeeRepository.php | 3 +- .../Invoice/InvoiceRepository.php | 20 +- app/Repositories/Order/OrderRepository.php | 20 +- .../Customer/CustomerBillingService.php | 9 +- app/Services/Customer/CustomerMailService.php | 7 +- app/Services/IpPool/IpAllocationService.php | 278 ++++++++++++ app/Services/IpPool/IpPoolSheetImporter.php | 185 ++++++++ app/Services/IpPool/IpPoolSheetReader.php | 258 +++++++++++ app/Services/IpPool/IpPoolUsageResolver.php | 117 +++++ app/Services/Order/ServiceOrderRecorder.php | 2 + app/Support/EmailScenarioRegistry.php | 8 + app/Support/MailAttachments.php | 43 ++ app/Support/MailSettings.php | 100 +++++ database/factories/UserFactory.php | 2 - ...00001_create_service_attachments_table.php | 28 ++ ...0002_backfill_orders_user_id_from_logs.php | 30 ++ ...0_15_000003_add_fullname_to_user_table.php | 26 ++ ...0004_reshape_ip_pools_for_sheet_import.php | 35 ++ ...10_15_000005_create_ip_pool_logs_table.php | 41 ++ ...6_10_15_000006_seed_ip_email_templates.php | 20 + ...007_reseed_ip_allocated_email_template.php | 14 + ...ice_and_effective_date_to_ip_pool_logs.php | 23 + .../EmailTemplates/BillingTemplates.php | 53 ++- .../seeders/EmailTemplates/IpTemplates.php | 62 +++ .../EmailTemplates/MarketingTemplates.php | 33 ++ .../EmailTemplates/TemplateVariables.php | 7 + database/seeders/TemplateSeeder.php | 52 ++- phpunit.xml | 1 + .../views/pdf/web-design-quote.blade.php | 12 +- routes/api/admin/auth.php | 2 + routes/api/admin/billing.php | 2 + routes/api/admin/catalog.php | 1 + routes/api/admin/customers.php | 4 + routes/api/admin/infrastructure.php | 6 + routes/api/admin/system.php | 2 + .../ActivityLog/ActivityLogManagementTest.php | 103 +++++ .../Admin/Billing/OrderInvoiceExportTest.php | 101 +++++ .../Admin/Customer/SendTemplateEmailTest.php | 34 ++ .../Api/Admin/IpPool/IpAllocationTest.php | 251 +++++++++++ .../Admin/IpPool/IpPoolSheetImportTest.php | 136 ++++++ .../Api/Admin/IpPool/IpPoolUsageTest.php | 57 +++ .../Admin/Service/ServiceAttachmentTest.php | 117 +++++ .../Feature/Api/Admin/UpdatePasswordTest.php | 61 +++ tests/Feature/Api/Admin/UpdateProfileTest.php | 30 ++ .../WebDesign/WebDesignQuoteXlsxTest.php | 61 +++ tests/Feature/Mail/MailSettingsQueueTest.php | 48 ++ 95 files changed, 4247 insertions(+), 454 deletions(-) create mode 100644 app/Exports/ActivityLogsExport.php create mode 100644 app/Exports/BrandedReport.php create mode 100644 app/Exports/BrandedReportWriter.php create mode 100644 app/Exports/Concerns/FormatsBillingCells.php create mode 100644 app/Exports/InvoicesExport.php create mode 100644 app/Exports/OrdersExport.php create mode 100644 app/Exports/Templates/report_pricing_service.xlsx create mode 100644 app/Exports/Templates/report_template.xlsx create mode 100644 app/Exports/WebDesignQuoteSpreadsheet.php create mode 100644 app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php create mode 100644 app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php create mode 100644 app/Http/Requests/BillingExportRequest.php create mode 100644 app/Http/Requests/UpdateAdminPasswordRequest.php create mode 100644 app/Http/Resources/Api/Admin/OrderItemServiceResource.php delete mode 100644 app/Imports/IpPoolImport.php create mode 100644 app/Mail/Concerns/AttachesStoredFiles.php create mode 100644 app/Mail/IpAllocationMail.php create mode 100644 app/Models/IpPoolLog.php create mode 100644 app/Models/ServiceAttachment.php create mode 100644 app/Repositories/Concerns/FiltersBillingRecords.php create mode 100644 app/Services/IpPool/IpAllocationService.php create mode 100644 app/Services/IpPool/IpPoolSheetImporter.php create mode 100644 app/Services/IpPool/IpPoolSheetReader.php create mode 100644 app/Services/IpPool/IpPoolUsageResolver.php create mode 100644 app/Support/MailAttachments.php create mode 100644 app/Support/MailSettings.php create mode 100644 database/migrations/2026_10_15_000001_create_service_attachments_table.php create mode 100644 database/migrations/2026_10_15_000002_backfill_orders_user_id_from_logs.php create mode 100644 database/migrations/2026_10_15_000003_add_fullname_to_user_table.php create mode 100644 database/migrations/2026_10_15_000004_reshape_ip_pools_for_sheet_import.php create mode 100644 database/migrations/2026_10_15_000005_create_ip_pool_logs_table.php create mode 100644 database/migrations/2026_10_15_000006_seed_ip_email_templates.php create mode 100644 database/migrations/2026_10_15_000007_reseed_ip_allocated_email_template.php create mode 100644 database/migrations/2026_10_15_000008_add_service_and_effective_date_to_ip_pool_logs.php create mode 100644 database/seeders/EmailTemplates/IpTemplates.php create mode 100644 tests/Feature/Api/Admin/ActivityLog/ActivityLogManagementTest.php create mode 100644 tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php create mode 100644 tests/Feature/Api/Admin/IpPool/IpAllocationTest.php create mode 100644 tests/Feature/Api/Admin/IpPool/IpPoolSheetImportTest.php create mode 100644 tests/Feature/Api/Admin/IpPool/IpPoolUsageTest.php create mode 100644 tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php create mode 100644 tests/Feature/Api/Admin/UpdatePasswordTest.php create mode 100644 tests/Feature/Api/Admin/UpdateProfileTest.php create mode 100644 tests/Feature/Api/Admin/WebDesign/WebDesignQuoteXlsxTest.php create mode 100644 tests/Feature/Mail/MailSettingsQueueTest.php 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 0000000000000000000000000000000000000000..12a28226fd75bc75c4ecb89189e648e0726f6fbe GIT binary patch literal 94202 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 K{@&P`3Xf^ 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 literal 0 HcmV?d00001 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()); + } +}