From a443ec3bf65035e1045ca1769add9798436e6ced Mon Sep 17 00:00:00 2001 From: baodev0 Date: Wed, 21 Oct 2026 16:55:19 +0700 Subject: [PATCH] feat(portal): lead module, WHMCS sync report/contacts/mapping, service lifecycle email templates Co-Authored-By: Claude Sonnet 5.5 --- .../Commands/Whmcs/ExportMappingCommand.php | 153 +++++++++ app/Console/Commands/Whmcs/SyncAllCommand.php | 13 +- .../Commands/Whmcs/SyncClientsCommand.php | 11 +- .../Commands/Whmcs/SyncContactsCommand.php | 34 ++ .../Whmcs/SyncCustomerDomainsCommand.php | 11 +- .../Commands/Whmcs/SyncInvoicesCommand.php | 11 +- .../Commands/Whmcs/SyncOrderItemsCommand.php | 11 +- .../Commands/Whmcs/SyncOrdersCommand.php | 11 +- .../Commands/Whmcs/SyncServicesCommand.php | 11 +- .../Api/Admin/DnsManager/DomainController.php | 6 +- .../MailCampaign/MailCampaignController.php | 2 +- .../Resources/Api/Admin/ProductResource.php | 5 +- .../Whmcs/Extractors/ClientExtractor.php | 11 +- .../Extractors/CustomerDomainExtractor.php | 12 +- .../Whmcs/Extractors/InvoiceExtractor.php | 12 +- .../Whmcs/Extractors/OrderExtractor.php | 12 +- .../Whmcs/Extractors/OrderItemExtractor.php | 12 +- .../Whmcs/Extractors/ServiceExtractor.php | 12 +- .../Whmcs/Support/CustomerIdResolver.php | 104 +++++- app/Integrations/Whmcs/Support/SyncReport.php | 66 ++++ .../Whmcs/Support/SyncedLegacyIds.php | 21 ++ .../Whmcs/Transformers/ClientTransformer.php | 56 +-- .../CustomerDomainTransformer.php | 2 +- .../Whmcs/Transformers/InvoiceTransformer.php | 2 +- .../Whmcs/Transformers/OrderTransformer.php | 2 +- .../Whmcs/Transformers/ServiceTransformer.php | 2 +- .../Whmcs/Writers/ClientWriter.php | 82 +++-- .../Whmcs/Writers/CustomerDomainWriter.php | 76 +++-- app/Integrations/Whmcs/Writers/LeadWriter.php | 87 +++++ .../ServiceClient/ServiceClientService.php | 21 +- app/Services/Email/EmailTemplateRenderer.php | 3 + app/Support/DomainServiceSummary.php | 82 +++++ app/Support/MailPlaceholders.php | 18 +- app/Support/PhoneNumber.php | 21 ++ ...00001_seed_service_lifecycle_templates.php | 14 + ...00002_split_trial_templates_by_account.php | 16 + ...0003_prefix_service_lifecycle_subjects.php | 23 ++ ...26_10_21_000004_widen_template_subject.php | 18 + ...21_000005_prefix_all_template_subjects.php | 33 ++ ...06_rewrite_domain_new_service_template.php | 26 ++ ..._000007_seed_service_renewal_templates.php | 14 + ...0008_add_saler_to_customers_if_missing.php | 21 ++ ...2026_10_21_100001_add_lead_permissions.php | 31 ++ .../ServiceLifecycleTemplates.php | 319 ++++++++++++++++++ .../seeders/EmailTemplates/SubjectFormat.php | 72 ++++ database/seeders/RolesAndPermissions.php | 7 + database/seeders/TemplateSeeder.php | 17 +- .../Controllers/Api/Admin/LeadController.php | 97 ++++++ .../Lead/App/Http/Requests/LeadRequest.php | 49 +++ .../Lead/App/Http/Resources/LeadResource.php | 38 +++ modules/Lead/App/Models/Lead.php | 68 ++++ .../App/Providers/LeadServiceProvider.php | 14 + .../2026_10_21_100000_create_leads_table.php | 38 +++ modules/Lead/composer.json | 31 ++ modules/Lead/config/config.php | 5 + modules/Lead/module.json | 11 + modules_statuses.json | 3 +- routes/api.php | 1 + routes/api/admin/leads.php | 16 + .../Api/Admin/IpPool/IpAllocationTest.php | 4 +- tests/Feature/Api/Admin/LeadApiTest.php | 92 +++++ .../Api/Admin/MarketingEmailOptOutTest.php | 18 + .../Mail/HostingAccountCreatedInvoiceTest.php | 6 +- .../MailPlaceholdersConditionalTest.php | 23 ++ tests/Feature/TemplateSeederTest.php | 17 + tests/Unit/AdminProductResourceTest.php | 19 ++ 66 files changed, 2032 insertions(+), 124 deletions(-) create mode 100644 app/Console/Commands/Whmcs/ExportMappingCommand.php create mode 100644 app/Console/Commands/Whmcs/SyncContactsCommand.php create mode 100644 app/Integrations/Whmcs/Support/SyncReport.php create mode 100644 app/Integrations/Whmcs/Support/SyncedLegacyIds.php create mode 100644 app/Integrations/Whmcs/Writers/LeadWriter.php create mode 100644 app/Support/DomainServiceSummary.php create mode 100644 app/Support/PhoneNumber.php create mode 100644 database/migrations/2026_10_21_000001_seed_service_lifecycle_templates.php create mode 100644 database/migrations/2026_10_21_000002_split_trial_templates_by_account.php create mode 100644 database/migrations/2026_10_21_000003_prefix_service_lifecycle_subjects.php create mode 100644 database/migrations/2026_10_21_000004_widen_template_subject.php create mode 100644 database/migrations/2026_10_21_000005_prefix_all_template_subjects.php create mode 100644 database/migrations/2026_10_21_000006_rewrite_domain_new_service_template.php create mode 100644 database/migrations/2026_10_21_000007_seed_service_renewal_templates.php create mode 100644 database/migrations/2026_10_21_000008_add_saler_to_customers_if_missing.php create mode 100644 database/migrations/2026_10_21_100001_add_lead_permissions.php create mode 100644 database/seeders/EmailTemplates/ServiceLifecycleTemplates.php create mode 100644 database/seeders/EmailTemplates/SubjectFormat.php create mode 100644 modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php create mode 100644 modules/Lead/App/Http/Requests/LeadRequest.php create mode 100644 modules/Lead/App/Http/Resources/LeadResource.php create mode 100644 modules/Lead/App/Models/Lead.php create mode 100644 modules/Lead/App/Providers/LeadServiceProvider.php create mode 100644 modules/Lead/Database/migrations/2026_10_21_100000_create_leads_table.php create mode 100644 modules/Lead/composer.json create mode 100644 modules/Lead/config/config.php create mode 100644 modules/Lead/module.json create mode 100644 routes/api/admin/leads.php create mode 100644 tests/Feature/Api/Admin/LeadApiTest.php create mode 100644 tests/Feature/MailPlaceholdersConditionalTest.php diff --git a/app/Console/Commands/Whmcs/ExportMappingCommand.php b/app/Console/Commands/Whmcs/ExportMappingCommand.php new file mode 100644 index 0000000..a28a003 --- /dev/null +++ b/app/Console/Commands/Whmcs/ExportMappingCommand.php @@ -0,0 +1,153 @@ +.xlsx)}'; + + protected $description = 'Export unresolved WHMCS domains and unusual customer emails (non-gmail / .kgm) to xlsx for manual mapping'; + + private const SUSPICIOUS = '/(\.kgm|@(gmail|yahoo|hotmail|outlook)\.com\.[a-z]+)$/i'; + + public function handle(CustomerIdResolver $resolver): int + { + $path = $this->option('path') ?: storage_path('app/whmcs-mapping-' . now()->format('Ymd-His') . '.xlsx'); + + $spreadsheet = new Spreadsheet(); + $this->sheet($spreadsheet, 0, 'Tên miền chưa có chủ', [ + 'Tên miền', 'WHMCS domain id', 'Trạng thái', 'WHMCS userid (đã xoá)', 'Registrar', 'Hạn (nextduedate)', 'Email liên hệ còn lại', 'Gợi ý email đã sửa', 'Khách portal khớp gợi ý', 'EMAIL KHÁCH CẦN MAP (điền)', + ], $this->unresolvedDomains($resolver)); + $this->sheet($spreadsheet, 1, 'Email lạ (.kgm)', [ + 'Nguồn', 'ID', 'Họ tên', 'Công ty', 'Email hiện tại', 'Gợi ý email đã sửa', 'Khách portal khớp gợi ý', 'EMAIL ĐÚNG (điền)', + ], $this->suspiciousEmails()); + $this->sheet($spreadsheet, 2, 'Email khác @gmail', [ + 'Nguồn', 'ID', 'Họ tên', 'Công ty', 'Email', 'Tên miền email', 'Có trên portal', 'Có trên WHMCS', + ], $this->nonGmailEmails()); + + (new Xlsx($spreadsheet))->save($path); + $this->info("Đã xuất: {$path}"); + + return Command::SUCCESS; + } + + private function unresolvedDomains(CustomerIdResolver $resolver): array + { + $whmcs = DB::connection('whmcs'); + $rows = []; + $clientIds = $whmcs->table('tblclients')->pluck('id')->flip()->all(); + + foreach ($whmcs->table('tbldomains')->orderBy('id')->cursor() as $domain) { + if (isset($clientIds[$domain->userid]) || $resolver->resolveDomainOwner((string) $domain->domain, (int) $domain->userid) !== null) { + continue; + } + + $emails = $whmcs->table('tblcontacts')->where('userid', $domain->userid)->pluck('email')->filter()->all(); + $suggested = array_map(fn ($email) => $this->fixEmail($email), $emails); + $rows[] = [ + trim($domain->domain), $domain->id, $domain->status, $domain->userid, $domain->registrar, + $domain->nextduedate, implode('; ', $emails), implode('; ', $suggested), $this->portalMatches($suggested), '', + ]; + } + + return $rows; + } + + private function suspiciousEmails(): array + { + $rows = []; + foreach ($this->people() as [$source, $id, $name, $company, $email]) { + if (! preg_match(self::SUSPICIOUS, $email) && filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $fixed = $this->fixEmail($email); + $rows[] = [$source, $id, $name, $company, $email, $fixed, $this->portalMatches([$fixed]), '']; + } + + return $rows; + } + + private function nonGmailEmails(): array + { + $portal = DB::table('customers')->pluck('id', DB::raw('LOWER(email)'))->all(); + $whmcsEmails = DB::connection('whmcs')->table('tblclients')->pluck('id', DB::raw('LOWER(email)'))->all(); + + $rows = []; + foreach ($this->people() as [$source, $id, $name, $company, $email]) { + $domain = strtolower((string) substr(strrchr($email, '@') ?: '', 1)); + if ($domain === 'gmail.com') { + continue; + } + $key = strtolower($email); + $rows[] = [$source, $id, $name, $company, $email, $domain, isset($portal[$key]) ? "#{$portal[$key]}" : 'Không', isset($whmcsEmails[$key]) ? "#{$whmcsEmails[$key]}" : 'Không']; + } + usort($rows, fn ($a, $b) => [$a[5], $a[4]] <=> [$b[5], $b[4]]); + + return $rows; + } + + /** + * @return \Generator + */ + private function people(): \Generator + { + foreach (DB::connection('whmcs')->table('tblclients')->orderBy('id')->get(['id', 'firstname', 'lastname', 'companyname', 'email']) as $client) { + yield ['WHMCS khách', $client->id, trim("{$client->firstname} {$client->lastname}"), $client->companyname, trim((string) $client->email)]; + } + foreach (DB::table('customers')->orderBy('id')->get(['id', 'fullname', 'company', 'email']) as $customer) { + yield ['Portal khách', $customer->id, $customer->fullname, $customer->company, trim((string) $customer->email)]; + } + foreach (DB::connection('whmcs')->table('tblcontacts')->orderBy('id')->get(['id', 'firstname', 'lastname', 'companyname', 'email']) as $contact) { + yield ['WHMCS liên hệ', $contact->id, trim("{$contact->firstname} {$contact->lastname}"), $contact->companyname, trim((string) $contact->email)]; + } + } + + private function fixEmail(string $email): string + { + return preg_replace('/(\.kgm|(?<=@gmail\.com|@yahoo\.com|@hotmail\.com|@outlook\.com)\.[a-z]+)$/i', '', trim($email)) ?? $email; + } + + private function portalMatches(array $emails): string + { + $emails = array_values(array_filter(array_map('strtolower', $emails))); + if ($emails === []) { + return ''; + } + + return DB::table('customers')->whereIn(DB::raw('LOWER(email)'), $emails)->get(['id', 'email', 'fullname']) + ->map(fn ($customer) => "#{$customer->id} {$customer->email} ({$customer->fullname})")->implode('; '); + } + + private function sheet(Spreadsheet $spreadsheet, int $index, string $title, array $headers, array $rows): void + { + $sheet = $index === 0 ? $spreadsheet->getActiveSheet() : $spreadsheet->createSheet($index); + $sheet->setTitle($title); + + foreach ($headers as $column => $header) { + $sheet->setCellValueExplicit([$column + 1, 1], $header, DataType::TYPE_STRING); + } + $last = $sheet->getHighestColumn(); + $sheet->getStyle("A1:{$last}1")->getFont()->setBold(true); + $sheet->getStyle("A1:{$last}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('FED7AA'); + + foreach ($rows as $rowIndex => $row) { + foreach ($row as $column => $value) { + $sheet->setCellValueExplicit([$column + 1, $rowIndex + 2], (string) ($value ?? ''), DataType::TYPE_STRING); + } + } + + foreach (range(1, count($headers)) as $column) { + $sheet->getColumnDimensionByColumn($column)->setAutoSize(true); + } + $sheet->freezePane('A2'); + $sheet->setAutoFilter("A1:{$last}" . max(1, count($rows) + 1)); + } +} diff --git a/app/Console/Commands/Whmcs/SyncAllCommand.php b/app/Console/Commands/Whmcs/SyncAllCommand.php index f786b8a..a1e5bd6 100644 --- a/app/Console/Commands/Whmcs/SyncAllCommand.php +++ b/app/Console/Commands/Whmcs/SyncAllCommand.php @@ -2,11 +2,12 @@ namespace App\Console\Commands\Whmcs; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; class SyncAllCommand extends Command { - protected $signature = 'whmcs:sync-all {--with-domain-sync : Also run domain:sync before whmcs:sync-domains}'; + protected $signature = 'whmcs:sync-all {--with-domain-sync : Also run domain:sync before whmcs:sync-domains} {--missing-only : Only sync records not yet synced, leaving existing rows untouched}'; protected $description = 'Run every whmcs:sync-* command in dependency order (clients, products, orders, order-items, invoices, services, domains)'; @@ -20,8 +21,10 @@ class SyncAllCommand extends Command ['command' => 'whmcs:sync-domains', 'forceSync' => false], ]; - public function handle(): int + public function handle(SyncReport $report): int { + $report->beginBatch(); + if ($this->option('with-domain-sync')) { $exitCode = $this->runStep('domain:sync', []); if ($exitCode !== self::SUCCESS) { @@ -33,14 +36,20 @@ class SyncAllCommand extends Command foreach (self::STEPS as $step) { $arguments = $step['forceSync'] ? ['--sync' => true] : []; + if ($this->option('missing-only') && $step['command'] !== 'whmcs:sync-products') { + $arguments['--missing-only'] = true; + } $exitCode = $this->runStep($step['command'], $arguments); if ($exitCode !== self::SUCCESS) { + $report->finishBatch($this); + return $exitCode; } } $this->info('whmcs:sync-all completed successfully.'); + $report->finishBatch($this); return self::SUCCESS; } diff --git a/app/Console/Commands/Whmcs/SyncClientsCommand.php b/app/Console/Commands/Whmcs/SyncClientsCommand.php index bd6c8e3..d6ef9ec 100644 --- a/app/Console/Commands/Whmcs/SyncClientsCommand.php +++ b/app/Console/Commands/Whmcs/SyncClientsCommand.php @@ -4,24 +4,25 @@ namespace App\Console\Commands\Whmcs; use App\Integrations\Whmcs\Extractors\ClientExtractor; use App\Jobs\Whmcs\SyncClientJob; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class SyncClientsCommand extends Command { - protected $signature = 'whmcs:sync-clients {--sync : Run each record inline instead of queueing it}'; + protected $signature = 'whmcs:sync-clients {--sync : Run each record inline instead of queueing it} {--missing-only : Only records not yet synced (new, or order without customer)}'; protected $description = 'Extract clients from the WHMCS database and upsert them into customers'; public function handle(ClientExtractor $extractor): int { $sync = (bool) $this->option('sync'); - $total = DB::connection('whmcs')->table('tblclients')->count(); + $missingOnly = (bool) $this->option('missing-only'); + $total = $extractor->extract($missingOnly)->count(); $bar = $this->output->createProgressBar($total); $bar->start(); - foreach ($extractor->extract() as $row) { + foreach ($extractor->extract($missingOnly) as $row) { $job = new SyncClientJob((array) $row); $sync ? dispatch_sync($job) : dispatch($job); $bar->advance(); @@ -31,6 +32,8 @@ class SyncClientsCommand extends Command $this->newLine(); $this->info("Dispatched {$total} client(s) for sync."); + app(SyncReport::class)->finish($this); + return Command::SUCCESS; } } diff --git a/app/Console/Commands/Whmcs/SyncContactsCommand.php b/app/Console/Commands/Whmcs/SyncContactsCommand.php new file mode 100644 index 0000000..72797b5 --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncContactsCommand.php @@ -0,0 +1,34 @@ +table('tblcontacts')->orderBy('id'); + $bar = $this->output->createProgressBar($contacts->count()); + + foreach ($contacts->cursor() as $contact) { + $writer->write($contact, $customers->resolve((int) $contact->userid)); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info('Đã nhập liên hệ vào khách tiềm năng (leads).'); + app(SyncReport::class)->finish($this); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php b/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php index fa13ff5..2277b2f 100644 --- a/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php +++ b/app/Console/Commands/Whmcs/SyncCustomerDomainsCommand.php @@ -5,24 +5,25 @@ namespace App\Console\Commands\Whmcs; use App\Integrations\Whmcs\Extractors\CustomerDomainExtractor; use App\Integrations\Whmcs\Transformers\CustomerDomainTransformer; use App\Integrations\Whmcs\Writers\CustomerDomainWriter; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class SyncCustomerDomainsCommand extends Command { - protected $signature = 'whmcs:sync-domains'; + protected $signature = 'whmcs:sync-domains {--missing-only : Only records not yet synced (new, or order without customer)}'; protected $description = 'Extract customer domain registrations (tbldomains) from WHMCS and upsert them into module_domain (run after whmcs:sync-clients, whmcs:sync-orders, domain:sync)'; public function handle(CustomerDomainExtractor $extractor, CustomerDomainTransformer $transformer, CustomerDomainWriter $writer): int { - $total = DB::connection('whmcs')->table('tbldomains')->count(); + $missingOnly = (bool) $this->option('missing-only'); + $total = $extractor->extract($missingOnly)->count(); $bar = $this->output->createProgressBar($total); $bar->start(); $skipped = 0; - foreach ($extractor->extract() as $row) { + foreach ($extractor->extract($missingOnly) as $row) { if (! $writer->write($transformer->transform($row))) { $skipped++; } @@ -33,6 +34,8 @@ class SyncCustomerDomainsCommand extends Command $this->newLine(); $this->info("Processed {$total} domain(s), skipped {$skipped} (unresolved customer)."); + app(SyncReport::class)->finish($this); + return Command::SUCCESS; } } diff --git a/app/Console/Commands/Whmcs/SyncInvoicesCommand.php b/app/Console/Commands/Whmcs/SyncInvoicesCommand.php index dfe1425..b9b7acd 100644 --- a/app/Console/Commands/Whmcs/SyncInvoicesCommand.php +++ b/app/Console/Commands/Whmcs/SyncInvoicesCommand.php @@ -4,24 +4,25 @@ namespace App\Console\Commands\Whmcs; use App\Integrations\Whmcs\Extractors\InvoiceExtractor; use App\Jobs\Whmcs\SyncInvoiceJob; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class SyncInvoicesCommand extends Command { - protected $signature = 'whmcs:sync-invoices {--sync : Run each record inline instead of queueing it}'; + protected $signature = 'whmcs:sync-invoices {--sync : Run each record inline instead of queueing it} {--missing-only : Only records not yet synced (new, or order without customer)}'; protected $description = 'Extract invoices (with their line items) from WHMCS and upsert them into invoices/invoice_items'; public function handle(InvoiceExtractor $extractor): int { $sync = (bool) $this->option('sync'); - $total = DB::connection('whmcs')->table('tblinvoices')->count(); + $missingOnly = (bool) $this->option('missing-only'); + $total = $extractor->extract($missingOnly)->count(); $bar = $this->output->createProgressBar($total); $bar->start(); - foreach ($extractor->extract() as $row) { + foreach ($extractor->extract($missingOnly) as $row) { $job = new SyncInvoiceJob((array) $row); $sync ? dispatch_sync($job) : dispatch($job); $bar->advance(); @@ -31,6 +32,8 @@ class SyncInvoicesCommand extends Command $this->newLine(); $this->info("Dispatched {$total} invoice(s) for sync."); + app(SyncReport::class)->finish($this); + return Command::SUCCESS; } } diff --git a/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php b/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php index 672b41a..2676b3a 100644 --- a/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php +++ b/app/Console/Commands/Whmcs/SyncOrderItemsCommand.php @@ -5,24 +5,25 @@ namespace App\Console\Commands\Whmcs; use App\Integrations\Whmcs\Extractors\OrderItemExtractor; use App\Integrations\Whmcs\Transformers\OrderItemTransformer; use App\Integrations\Whmcs\Writers\OrderItemWriter; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class SyncOrderItemsCommand extends Command { - protected $signature = 'whmcs:sync-order-items'; + protected $signature = 'whmcs:sync-order-items {--missing-only : Only records not yet synced (new, or order without customer)}'; protected $description = 'Extract provisioned services (tblhosting) from WHMCS and upsert them into order_items (run after whmcs:sync-orders)'; public function handle(OrderItemExtractor $extractor, OrderItemTransformer $transformer, OrderItemWriter $writer): int { - $total = DB::connection('whmcs')->table('tblhosting')->count(); + $missingOnly = (bool) $this->option('missing-only'); + $total = $extractor->extract($missingOnly)->count(); $bar = $this->output->createProgressBar($total); $bar->start(); $skipped = 0; - foreach ($extractor->extract() as $row) { + foreach ($extractor->extract($missingOnly) as $row) { if (! $writer->write($transformer->transform($row))) { $skipped++; } @@ -33,6 +34,8 @@ class SyncOrderItemsCommand extends Command $this->newLine(); $this->info("Processed {$total} order item(s), skipped {$skipped} (unresolved order)."); + app(SyncReport::class)->finish($this); + return Command::SUCCESS; } } diff --git a/app/Console/Commands/Whmcs/SyncOrdersCommand.php b/app/Console/Commands/Whmcs/SyncOrdersCommand.php index ff9cc77..261e1bb 100644 --- a/app/Console/Commands/Whmcs/SyncOrdersCommand.php +++ b/app/Console/Commands/Whmcs/SyncOrdersCommand.php @@ -4,24 +4,25 @@ namespace App\Console\Commands\Whmcs; use App\Integrations\Whmcs\Extractors\OrderExtractor; use App\Jobs\Whmcs\SyncOrderJob; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class SyncOrdersCommand extends Command { - protected $signature = 'whmcs:sync-orders {--sync : Run each record inline instead of queueing it}'; + protected $signature = 'whmcs:sync-orders {--sync : Run each record inline instead of queueing it} {--missing-only : Only records not yet synced (new, or order without customer)}'; protected $description = 'Extract orders from the WHMCS database and upsert them into orders (run before whmcs:sync-invoices)'; public function handle(OrderExtractor $extractor): int { $sync = (bool) $this->option('sync'); - $total = DB::connection('whmcs')->table('tblorders')->count(); + $missingOnly = (bool) $this->option('missing-only'); + $total = $extractor->extract($missingOnly)->count(); $bar = $this->output->createProgressBar($total); $bar->start(); - foreach ($extractor->extract() as $row) { + foreach ($extractor->extract($missingOnly) as $row) { $job = new SyncOrderJob((array) $row); $sync ? dispatch_sync($job) : dispatch($job); $bar->advance(); @@ -31,6 +32,8 @@ class SyncOrdersCommand extends Command $this->newLine(); $this->info("Dispatched {$total} order(s) for sync."); + app(SyncReport::class)->finish($this); + return Command::SUCCESS; } } diff --git a/app/Console/Commands/Whmcs/SyncServicesCommand.php b/app/Console/Commands/Whmcs/SyncServicesCommand.php index 7861586..b738be7 100644 --- a/app/Console/Commands/Whmcs/SyncServicesCommand.php +++ b/app/Console/Commands/Whmcs/SyncServicesCommand.php @@ -5,24 +5,25 @@ namespace App\Console\Commands\Whmcs; use App\Integrations\Whmcs\Extractors\ServiceExtractor; use App\Integrations\Whmcs\Transformers\ServiceTransformer; use App\Integrations\Whmcs\Writers\ServiceWriter; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class SyncServicesCommand extends Command { - protected $signature = 'whmcs:sync-services'; + protected $signature = 'whmcs:sync-services {--missing-only : Only records not yet synced (new, or order without customer)}'; protected $description = 'Extract provisioned services (tblhosting) from WHMCS and upsert them into services (run after whmcs:sync-clients, whmcs:sync-products, whmcs:sync-order-items)'; public function handle(ServiceExtractor $extractor, ServiceTransformer $transformer, ServiceWriter $writer): int { - $total = DB::connection('whmcs')->table('tblhosting')->count(); + $missingOnly = (bool) $this->option('missing-only'); + $total = $extractor->extract($missingOnly)->count(); $bar = $this->output->createProgressBar($total); $bar->start(); $skipped = 0; - foreach ($extractor->extract() as $row) { + foreach ($extractor->extract($missingOnly) as $row) { if (! $writer->write($transformer->transform($row))) { $skipped++; } @@ -33,6 +34,8 @@ class SyncServicesCommand extends Command $this->newLine(); $this->info("Processed {$total} service(s), skipped {$skipped} (unresolved customer)."); + app(SyncReport::class)->finish($this); + return Command::SUCCESS; } } diff --git a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php index cba4770..11bd14f 100644 --- a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php +++ b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php @@ -10,6 +10,7 @@ use App\Jobs\DnsManager\SyncDnsFromPlesk; use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface; use App\Repositories\DnsManager\Record\DnsRecordRepositoryInterface; use App\Services\ServerService; +use App\Support\DomainServiceSummary; use App\Services\Domain\CustomerDomainServiceSync; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -65,7 +66,10 @@ class DomainController extends Controller $paginator = $query->orderByDesc('id')->paginate($perPage); - $items = $paginator->getCollection()->map(fn($domain) => $this->formatDomain($domain)); + $summaries = DomainServiceSummary::forDomains($paginator->getCollection()); + $items = $paginator->getCollection()->map(fn($domain) => $this->formatDomain($domain) + [ + 'service' => $summaries[DomainServiceSummary::key($domain->domain_name)] ?? null, + ]); return response()->json([ 'error' => false, diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index dc55714..4565f54 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -238,7 +238,7 @@ final class MailCampaignController extends Controller } $subject = $subject !== '' ? $subject : trim((string) $template->subject); - $message = trim((string) $template->message); + $message = $message !== '' ? $message : trim((string) $template->message); } if ($subject === '' || $message === '') { diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index d355de2..1354bbb 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -55,9 +55,10 @@ class ProductResource extends JsonResource 'price_annually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'annually'))->price), 'price_biennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'biennially'))->price), 'price_triennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'triennially'))->price), - 'custom_prices' => $this->whenLoaded('pricings', fn () => $this->pricings + 'custom_prices' => $this->whenLoaded('pricings', fn () => (object) $this->pricings ->filter(fn ($pricing) => str_starts_with((string) $pricing->billing_cycle, BillingCycleCatalog::CODE_PREFIX)) - ->mapWithKeys(fn ($pricing) => [$pricing->billing_cycle => $pricing->price])), + ->mapWithKeys(fn ($pricing) => [$pricing->billing_cycle => $pricing->price]) + ->all()), 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->first(fn ($pricing) => in_array($pricing->billing_cycle, ['onetime', 'one_time'], true)))->price), 'is_active' => $this->is_active, 'created_at' => $this->created_at, diff --git a/app/Integrations/Whmcs/Extractors/ClientExtractor.php b/app/Integrations/Whmcs/Extractors/ClientExtractor.php index 40dcb89..f64639c 100644 --- a/app/Integrations/Whmcs/Extractors/ClientExtractor.php +++ b/app/Integrations/Whmcs/Extractors/ClientExtractor.php @@ -7,8 +7,15 @@ use Illuminate\Support\LazyCollection; class ClientExtractor { - public function extract(): LazyCollection + public function extract(bool $missingOnly = false): LazyCollection { - return DB::connection('whmcs')->table('tblclients')->orderBy('id')->cursor(); + $rows = DB::connection('whmcs')->table('tblclients')->orderBy('id')->cursor(); + if (! $missingOnly) { + return $rows; + } + + $uuids = DB::table('customers')->pluck('uuid')->flip()->all(); + + return $rows->reject(fn ($row) => isset($uuids[$row->uuid])); } } diff --git a/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php b/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php index 967fb5d..2dc157b 100644 --- a/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php +++ b/app/Integrations/Whmcs/Extractors/CustomerDomainExtractor.php @@ -2,13 +2,21 @@ namespace App\Integrations\Whmcs\Extractors; +use App\Integrations\Whmcs\Support\SyncedLegacyIds; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; class CustomerDomainExtractor { - public function extract(): LazyCollection + public function extract(bool $missingOnly = false): LazyCollection { - return DB::connection('whmcs')->table('tbldomains')->orderBy('id')->cursor(); + $rows = DB::connection('whmcs')->table('tbldomains')->orderBy('id')->cursor(); + if (! $missingOnly) { + return $rows; + } + + $synced = SyncedLegacyIds::of('module_domain'); + + return $rows->reject(fn ($row) => isset($synced[$row->id])); } } diff --git a/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php b/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php index ca50522..bc28171 100644 --- a/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php +++ b/app/Integrations/Whmcs/Extractors/InvoiceExtractor.php @@ -2,14 +2,22 @@ namespace App\Integrations\Whmcs\Extractors; +use App\Integrations\Whmcs\Support\SyncedLegacyIds; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; class InvoiceExtractor { - public function extract(): LazyCollection + public function extract(bool $missingOnly = false): LazyCollection { - return DB::connection('whmcs')->table('tblinvoices')->orderBy('id')->cursor(); + $rows = DB::connection('whmcs')->table('tblinvoices')->orderBy('id')->cursor(); + if (! $missingOnly) { + return $rows; + } + + $synced = SyncedLegacyIds::of('invoices'); + + return $rows->reject(fn ($row) => isset($synced[$row->id])); } /** diff --git a/app/Integrations/Whmcs/Extractors/OrderExtractor.php b/app/Integrations/Whmcs/Extractors/OrderExtractor.php index ad08c7b..46019e1 100644 --- a/app/Integrations/Whmcs/Extractors/OrderExtractor.php +++ b/app/Integrations/Whmcs/Extractors/OrderExtractor.php @@ -2,13 +2,21 @@ namespace App\Integrations\Whmcs\Extractors; +use App\Integrations\Whmcs\Support\SyncedLegacyIds; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; class OrderExtractor { - public function extract(): LazyCollection + public function extract(bool $missingOnly = false): LazyCollection { - return DB::connection('whmcs')->table('tblorders')->orderBy('id')->cursor(); + $rows = DB::connection('whmcs')->table('tblorders')->orderBy('id')->cursor(); + if (! $missingOnly) { + return $rows; + } + + $synced = SyncedLegacyIds::of('orders', true); + + return $rows->reject(fn ($row) => isset($synced[$row->id])); } } diff --git a/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php b/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php index 52498e3..fd82cd3 100644 --- a/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php +++ b/app/Integrations/Whmcs/Extractors/OrderItemExtractor.php @@ -2,6 +2,7 @@ namespace App\Integrations\Whmcs\Extractors; +use App\Integrations\Whmcs\Support\SyncedLegacyIds; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; @@ -11,8 +12,15 @@ class OrderItemExtractor * Each `tblhosting` row (a provisioned service instance) is the closest WHMCS equivalent * of an order line item — WHMCS orders have no real line-item table of their own. */ - public function extract(): LazyCollection + public function extract(bool $missingOnly = false): LazyCollection { - return DB::connection('whmcs')->table('tblhosting')->orderBy('id')->cursor(); + $rows = DB::connection('whmcs')->table('tblhosting')->orderBy('id')->cursor(); + if (! $missingOnly) { + return $rows; + } + + $synced = SyncedLegacyIds::of('order_items'); + + return $rows->reject(fn ($row) => isset($synced[$row->id])); } } diff --git a/app/Integrations/Whmcs/Extractors/ServiceExtractor.php b/app/Integrations/Whmcs/Extractors/ServiceExtractor.php index 8b547e8..eff397b 100644 --- a/app/Integrations/Whmcs/Extractors/ServiceExtractor.php +++ b/app/Integrations/Whmcs/Extractors/ServiceExtractor.php @@ -2,13 +2,21 @@ namespace App\Integrations\Whmcs\Extractors; +use App\Integrations\Whmcs\Support\SyncedLegacyIds; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; class ServiceExtractor { - public function extract(): LazyCollection + public function extract(bool $missingOnly = false): LazyCollection { - return DB::connection('whmcs')->table('tblhosting')->orderBy('id')->cursor(); + $rows = DB::connection('whmcs')->table('tblhosting')->orderBy('id')->cursor(); + if (! $missingOnly) { + return $rows; + } + + $synced = SyncedLegacyIds::of('services'); + + return $rows->reject(fn ($row) => isset($synced[$row->id])); } } diff --git a/app/Integrations/Whmcs/Support/CustomerIdResolver.php b/app/Integrations/Whmcs/Support/CustomerIdResolver.php index a3d9785..ed9430b 100644 --- a/app/Integrations/Whmcs/Support/CustomerIdResolver.php +++ b/app/Integrations/Whmcs/Support/CustomerIdResolver.php @@ -5,14 +5,22 @@ namespace App\Integrations\Whmcs\Support; use Illuminate\Support\Facades\DB; /** - * Resolves a WHMCS `tblclients.id` to the matching local `customers.id`, - * bridged through the uuid both sides already share (see ClientWriter). + * Resolves a WHMCS `tblclients.id` to the matching local `customers.id`: by the uuid both + * sides share (see ClientWriter), falling back to email for customers that already existed + * on the portal with a different uuid. */ class CustomerIdResolver { /** @var array|null whmcs client id => local customer id */ private static ?array $map = null; + /** @var array|null whmcs client ids that exist in tblclients */ + private static ?array $known = null; + + public function __construct(private SyncReport $report) + { + } + public function resolve(int $whmcsClientId): ?int { self::$map ??= $this->build(); @@ -20,15 +28,97 @@ class CustomerIdResolver return self::$map[$whmcsClientId] ?? null; } + public function resolveFor(string $entity, string|int $ref, int $whmcsClientId): ?int + { + $customerId = $this->resolve($whmcsClientId); + + if ($customerId === null) { + $exists = isset(self::$known[$whmcsClientId]); + $this->report->note( + $entity, + (string) $ref, + $whmcsClientId, + $exists ? 'khach_chua_co_tren_portal' : 'khach_khong_ton_tai_trong_whmcs', + $exists + ? "Khách WHMCS #{$whmcsClientId} chưa có tài khoản portal khớp uuid/email — bỏ qua {$entity}." + : "Khách WHMCS #{$whmcsClientId} không còn trong tblclients (đã xoá) — bỏ qua {$entity}.", + ); + } + + return $customerId; + } + + public function resolveDomainOwner(string $domain, int $whmcsClientId): ?int + { + $customerId = $this->resolve($whmcsClientId); + if ($customerId !== null) { + return $customerId; + } + + $name = mb_strtolower(trim($domain)); + $whmcs = DB::connection('whmcs'); + + $holderClientId = $whmcs->table('tbldomains as d') + ->join('tblclients as c', 'c.id', '=', 'd.userid') + ->whereRaw('LOWER(TRIM(d.domain)) = ?', [$name]) + ->orderByRaw("d.status = 'Active' DESC") + ->orderByDesc('d.id') + ->value('d.userid'); + if ($holderClientId && ($customerId = $this->resolve((int) $holderClientId)) !== null) { + return $this->traced($name, $whmcsClientId, $customerId, 'domain_cung_ten_khach_khac', "Tên miền cùng tên đang do khách WHMCS #{$holderClientId} giữ."); + } + + $linkedCustomerId = DB::table('dns_domain_user as l') + ->join('dns_manager_domain as d', 'd.id', '=', 'l.domain_id') + ->whereRaw('LOWER(d.domain_name) = ?', [$name]) + ->orderBy('l.id') + ->value('l.user_id'); + if ($linkedCustomerId) { + return $this->traced($name, $whmcsClientId, (int) $linkedCustomerId, 'domain_da_gan_khach_trong_dns', 'Tên miền đã gắn khách trong DNS Manager của portal.'); + } + + $contactEmails = $whmcs->table('tblcontacts')->where('userid', $whmcsClientId)->pluck('email')->map(fn ($email) => strtolower((string) $email))->filter()->all(); + if ($contactEmails !== [] && ($customerId = DB::table('customers')->whereIn(DB::raw('LOWER(email)'), $contactEmails)->value('id'))) { + return $this->traced($name, $whmcsClientId, (int) $customerId, 'domain_khop_email_lien_he', 'Khớp theo email liên hệ của khách đã xoá.'); + } + + $this->report->note( + 'domain', + $name, + $whmcsClientId, + 'khach_khong_ton_tai_trong_whmcs', + "Khách WHMCS #{$whmcsClientId} đã xoá, không suy ra được chủ. Email liên hệ còn lại: " . ($contactEmails ? implode(', ', $contactEmails) : 'không có'), + ); + + return null; + } + + private function traced(string $domain, int $whmcsClientId, int $customerId, string $issue, string $detail): int + { + $this->report->note('domain', $domain, $whmcsClientId, $issue, "Khách WHMCS #{$whmcsClientId} đã xoá; chủ suy ra → khách portal #{$customerId}. {$detail}"); + + return $customerId; + } + + public static function flush(): void + { + self::$map = null; + self::$known = null; + } + private function build(): array { - $uuidsByWhmcsId = DB::connection('whmcs')->table('tblclients')->pluck('uuid', 'id'); - $customerIdsByUuid = DB::table('customers')->pluck('id', 'uuid'); + $clients = DB::connection('whmcs')->table('tblclients')->get(['id', 'uuid', 'email']); + $byUuid = DB::table('customers')->pluck('id', 'uuid'); + $byEmail = DB::table('customers')->whereNotNull('email')->pluck('id', DB::raw('LOWER(email)')); $map = []; - foreach ($uuidsByWhmcsId as $whmcsId => $uuid) { - if (isset($customerIdsByUuid[$uuid])) { - $map[$whmcsId] = $customerIdsByUuid[$uuid]; + self::$known = []; + foreach ($clients as $client) { + self::$known[$client->id] = true; + $customerId = $byUuid[$client->uuid] ?? $byEmail[strtolower((string) $client->email)] ?? null; + if ($customerId !== null) { + $map[$client->id] = $customerId; } } diff --git a/app/Integrations/Whmcs/Support/SyncReport.php b/app/Integrations/Whmcs/Support/SyncReport.php new file mode 100644 index 0000000..0faeaa0 --- /dev/null +++ b/app/Integrations/Whmcs/Support/SyncReport.php @@ -0,0 +1,66 @@ + */ + private array $notes = []; + + private bool $batch = false; + + public function note(string $entity, string $ref, int|string $whmcsId, string $issue, string $detail): void + { + $this->notes[] = ['entity' => $entity, 'ref' => $ref, 'whmcs_id' => (string) $whmcsId, 'issue' => $issue, 'detail' => $detail]; + } + + public function beginBatch(): void + { + $this->batch = true; + } + + public function finishBatch(Command $command): void + { + $this->batch = false; + $this->finish($command); + } + + public function finish(Command $command): void + { + if ($this->batch || $this->notes === []) { + return; + } + + $summary = []; + foreach ($this->notes as $note) { + $key = $note['entity'] . ' / ' . $note['issue']; + $summary[$key] = ($summary[$key] ?? 0) + 1; + } + ksort($summary); + + $command->newLine(); + $command->warn('Ghi chú đồng bộ (' . count($this->notes) . ' mục):'); + $command->table(['Loại / vấn đề', 'Số lượng'], array_map(fn ($key, $count) => [$key, $count], array_keys($summary), $summary)); + $command->info('Chi tiết: ' . $this->writeCsv()); + + $this->notes = []; + } + + private function writeCsv(): string + { + $path = storage_path('logs/whmcs-sync-report-' . now()->format('Ymd-His-u') . '.csv'); + $handle = fopen($path, 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + fputcsv($handle, ['entity', 'ref', 'whmcs_id', 'issue', 'detail']); + foreach ($this->notes as $note) { + fputcsv($handle, $note); + } + fclose($handle); + + return $path; + } +} diff --git a/app/Integrations/Whmcs/Support/SyncedLegacyIds.php b/app/Integrations/Whmcs/Support/SyncedLegacyIds.php new file mode 100644 index 0000000..1d7b224 --- /dev/null +++ b/app/Integrations/Whmcs/Support/SyncedLegacyIds.php @@ -0,0 +1,21 @@ + legacy ids already present locally, as a lookup set + */ + public static function of(string $table, bool $requireCustomer = false): array + { + $query = DB::table($table)->whereNotNull('legacy_id'); + if ($requireCustomer) { + $query->whereNotNull('customer_id'); + } + + return $query->pluck('legacy_id')->flip()->all(); + } +} diff --git a/app/Integrations/Whmcs/Transformers/ClientTransformer.php b/app/Integrations/Whmcs/Transformers/ClientTransformer.php index 5c8711e..3f82939 100644 --- a/app/Integrations/Whmcs/Transformers/ClientTransformer.php +++ b/app/Integrations/Whmcs/Transformers/ClientTransformer.php @@ -3,6 +3,8 @@ namespace App\Integrations\Whmcs\Transformers; use App\Integrations\Whmcs\DTOs\ClientData; +use App\Integrations\Whmcs\Support\SyncReport; +use App\Support\PhoneNumber; use Illuminate\Support\Facades\DB; class ClientTransformer @@ -10,6 +12,10 @@ class ClientTransformer /** @var array|null fullname => user id, cached for the process lifetime */ private static ?array $salerIdsByName = null; + public function __construct(private SyncReport $report) + { + } + /** * Map a raw `tblclients` row (array or stdClass) to a ClientData DTO. */ @@ -22,6 +28,8 @@ class ClientTransformer $fullname = $row['companyname'] ?: null; } + $this->auditMissingInfo($row, $fullname); + return new ClientData( uuid: $row['uuid'], fullname: $fullname, @@ -32,7 +40,7 @@ class ClientTransformer // ponytail: WHMCS repurposes the `postcode` column to store the salesperson's fullname saler: $this->resolveSalerId($row['postcode'] ?? null), country: $row['country'] ?: null, - phone: $this->formatPhone($row['phonenumber'] ?? null), + phone: PhoneNumber::normalize($row['phonenumber'] ?? null), email: $row['email'] ? strtolower($row['email']) : null, // ponytail: WHMCS hashes are not Laravel bcrypt, copied as-is; customer must reset password to log into portal password: $row['password'] ?: null, @@ -45,32 +53,38 @@ class ClientTransformer ); } + private function auditMissingInfo(array $row, ?string $fullname): void + { + $ref = (string) ($row['email'] ?: "#{$row['id']}"); + $missing = []; + if (! $fullname) { + $missing[] = 'họ tên'; + } + if (! PhoneNumber::normalize($row['phonenumber'] ?? null)) { + $missing[] = 'số điện thoại'; + } + if (trim(($row['address1'] ?? '') . ($row['address2'] ?? '')) === '') { + $missing[] = 'địa chỉ'; + } + if ($missing) { + $this->report->note('client', $ref, $row['id'], 'thieu_thong_tin', 'Thiếu: ' . implode(', ', $missing)); + } + if (! filter_var($row['email'] ?? '', FILTER_VALIDATE_EMAIL) || preg_match('/@(gmail|yahoo|hotmail|outlook)\.com\.\w+$/i', (string) $row['email'])) { + $this->report->note('client', $ref, $row['id'], 'email_nghi_sai', "Email WHMCS có vẻ không hợp lệ: {$row['email']}"); + } + if (! empty($row['postcode']) && $this->resolveSalerId($row['postcode']) === null) { + $this->report->note('client', $ref, $row['id'], 'khong_map_duoc_sale', "Không tìm thấy nhân viên sale tên \"{$row['postcode']}\" trong bảng user."); + } + } + private function resolveSalerId(?string $fullname): ?int { if (! $fullname) { return null; } - self::$salerIdsByName ??= DB::table('user')->pluck('id', 'fullname')->all(); + self::$salerIdsByName ??= DB::table('user')->get(['id', 'fullname'])->mapWithKeys(fn ($user) => [trim((string) $user->fullname) => $user->id])->all(); - return self::$salerIdsByName[$fullname] ?? null; - } - - /** - * "+84.93 613 68 14" -> "0936136814"; other country codes keep their digits as-is (e.g. "+62..." -> "62..."). - */ - private function formatPhone(?string $raw): ?string - { - if (! $raw) { - return null; - } - - $digits = preg_replace('/\D+/', '', $raw); - - if (str_starts_with($digits, '84')) { - $digits = '0' . substr($digits, 2); - } - - return $digits ?: null; + return self::$salerIdsByName[trim($fullname)] ?? null; } } diff --git a/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php b/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php index 8a867b9..4478dc2 100644 --- a/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php +++ b/app/Integrations/Whmcs/Transformers/CustomerDomainTransformer.php @@ -41,7 +41,7 @@ class CustomerDomainTransformer return new CustomerDomainData( legacyId: (int) $row['id'], - customerId: $this->customers->resolve((int) $row['userid']), + customerId: $this->customers->resolveDomainOwner($name, (int) $row['userid']), orderId: $this->orders->resolve((int) $row['orderid']) ?? 0, name: $name, firstPayment: (int) round((float) $row['firstpaymentamount']), diff --git a/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php b/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php index db86024..38d96af 100644 --- a/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php +++ b/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php @@ -42,7 +42,7 @@ class InvoiceTransformer return new InvoiceData( legacyId: $legacyId, invoiceNumber: $row['invoicenum'] ?: "WHMCS-{$legacyId}", - customerId: $this->customers->resolve((int) $row['userid']), + customerId: $this->customers->resolveFor('invoice', $row['invoicenum'] ?: $row['id'], (int) $row['userid']), orderId: $whmcsOrderId ? $this->orders->resolve($whmcsOrderId) : null, date: $this->nullableDate($row['date'] ?? null), subTotal: (int) round((float) $row['subtotal']), diff --git a/app/Integrations/Whmcs/Transformers/OrderTransformer.php b/app/Integrations/Whmcs/Transformers/OrderTransformer.php index cce1c1e..358d64e 100644 --- a/app/Integrations/Whmcs/Transformers/OrderTransformer.php +++ b/app/Integrations/Whmcs/Transformers/OrderTransformer.php @@ -18,7 +18,7 @@ class OrderTransformer return new OrderData( legacyId: (int) $row['id'], orderNum: (string) $row['ordernum'], - customerId: $this->customers->resolve((int) $row['userid']), + customerId: $this->customers->resolveFor('order', $row['ordernum'] ?? $row['id'], (int) $row['userid']), date: $this->nullableDate($row['date'] ?? null), total: (int) round((float) $row['amount']), status: $this->mapStatus($row['status']), diff --git a/app/Integrations/Whmcs/Transformers/ServiceTransformer.php b/app/Integrations/Whmcs/Transformers/ServiceTransformer.php index c2bb019..4259397 100644 --- a/app/Integrations/Whmcs/Transformers/ServiceTransformer.php +++ b/app/Integrations/Whmcs/Transformers/ServiceTransformer.php @@ -23,7 +23,7 @@ class ServiceTransformer return new ServiceData( legacyId: $legacyId, - customerId: $this->customers->resolve((int) $row['userid']), + customerId: $this->customers->resolveFor('service', $row['domain'] ?: $row['id'], (int) $row['userid']), productId: $this->products->resolve((int) $row['packageid']), legacyProductId: (int) $row['packageid'], orderItemId: $this->orderItems->resolve($legacyId), diff --git a/app/Integrations/Whmcs/Writers/ClientWriter.php b/app/Integrations/Whmcs/Writers/ClientWriter.php index fbd4f79..c2a5a46 100644 --- a/app/Integrations/Whmcs/Writers/ClientWriter.php +++ b/app/Integrations/Whmcs/Writers/ClientWriter.php @@ -3,35 +3,77 @@ namespace App\Integrations\Whmcs\Writers; use App\Integrations\Whmcs\DTOs\ClientData; +use App\Integrations\Whmcs\Support\CustomerIdResolver; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Support\Facades\DB; class ClientWriter { + private const FILL_BLANK_COLUMNS = ['fullname', 'company', 'address', 'state', 'city', 'saler', 'country', 'phone', 'note']; + + public function __construct(private SyncReport $report) + { + } + /** * Upsert into `customers` keyed by `uuid` (WHMCS already assigns a uuid per client). + * A customer that already exists on the portal under another uuid is matched by email and only + * has its blank fields filled — uuid, password, credit and status stay as the portal has them. */ public function write(ClientData $data): void { - DB::table('customers')->updateOrInsert( - ['uuid' => $data->uuid], - [ - 'fullname' => $data->fullname, - 'company' => $data->company, - 'address' => $data->address, - 'state' => $data->state, - 'city' => $data->city, - 'saler' => $data->saler, - 'country' => $data->country, - 'phone' => $data->phone, - 'email' => $data->email, - 'password' => $data->password, - 'note' => $data->note, - 'credit' => $data->credit, - 'status' => $data->status, - 'email_marketing' => $data->emailMarketing, - 'created_at' => $data->createdAt, - 'updated_at' => $data->updatedAt, - ] + $attributes = [ + 'fullname' => $data->fullname, + 'company' => $data->company, + 'address' => $data->address, + 'state' => $data->state, + 'city' => $data->city, + 'saler' => $data->saler, + 'country' => $data->country, + 'phone' => $data->phone, + 'email' => $data->email, + 'password' => $data->password, + 'note' => $data->note, + 'credit' => $data->credit, + 'status' => $data->status, + 'email_marketing' => $data->emailMarketing, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ]; + + if (DB::table('customers')->where('uuid', $data->uuid)->exists()) { + DB::table('customers')->where('uuid', $data->uuid)->update($attributes); + + return; + } + + $existing = $data->email ? DB::table('customers')->whereRaw('LOWER(email) = ?', [strtolower($data->email)])->first() : null; + + if ($existing === null) { + DB::table('customers')->insert(['uuid' => $data->uuid, ...$attributes]); + + return; + } + + $filled = []; + foreach (self::FILL_BLANK_COLUMNS as $column) { + if (($existing->{$column} === null || $existing->{$column} === '') && $attributes[$column] !== null) { + $filled[$column] = $attributes[$column]; + } + } + if ($filled !== []) { + DB::table('customers')->where('id', $existing->id)->update($filled); + } + + $this->report->note( + 'client', + (string) $data->email, + $data->uuid, + 'lech_uuid_khop_theo_email', + "Khách portal #{$existing->id} có uuid khác WHMCS ({$data->uuid}), đã khớp theo email." + . ($filled ? ' Đã bổ sung: ' . implode(', ', array_keys($filled)) . '.' : ' Không có trường nào cần bổ sung.'), ); + + CustomerIdResolver::flush(); } } diff --git a/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php b/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php index bcc2fd0..ebd7f84 100644 --- a/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php +++ b/app/Integrations/Whmcs/Writers/CustomerDomainWriter.php @@ -4,16 +4,18 @@ namespace App\Integrations\Whmcs\Writers; use App\Integrations\Whmcs\DTOs\CustomerDomainData; use App\Integrations\Whmcs\Support\DnsDomainLinker; +use App\Integrations\Whmcs\Support\SyncReport; use Illuminate\Support\Facades\DB; class CustomerDomainWriter { - public function __construct(private DnsDomainLinker $dnsLinker) + public function __construct(private DnsDomainLinker $dnsLinker, private SyncReport $report) { } /** * Skips domains whose WHMCS client couldn't be resolved to a local customer. + * A domain name that already exists under another legacy id is merged into that row instead of duplicated. */ public function write(CustomerDomainData $data): bool { @@ -21,26 +23,62 @@ class CustomerDomainWriter return false; } - DB::table('module_domain')->updateOrInsert( - ['legacy_id' => $data->legacyId], - [ - 'name' => $data->name, - 'order_id' => $data->orderId, - 'first_payment' => $data->firstPayment, - 'module_dns_id' => 0, - 'next_payment' => $data->nextPayment, - 'next_due_date' => $data->nextDueDate, - 'next_invoice_date' => $data->nextInvoiceDate, - 'customer_id' => $data->customerId, - 'domain_type_id' => 0, - 'registrar_id' => $data->registrarId, - 'note' => $data->note, - 'status' => $data->status, - ] - ); + $attributes = [ + 'name' => $data->name, + 'order_id' => $data->orderId, + 'first_payment' => $data->firstPayment, + 'module_dns_id' => 0, + 'next_payment' => $data->nextPayment, + 'next_due_date' => $data->nextDueDate, + 'next_invoice_date' => $data->nextInvoiceDate, + 'customer_id' => $data->customerId, + 'domain_type_id' => 0, + 'registrar_id' => $data->registrarId, + 'note' => $data->note, + 'status' => $data->status, + ]; - $this->dnsLinker->link($data->name, $data->customerId, $data->registrarId); + $duplicate = DB::table('module_domain')->where('legacy_id', $data->legacyId)->doesntExist() + ? DB::table('module_domain')->whereRaw('LOWER(TRIM(name)) = ?', [mb_strtolower(trim($data->name))])->orderBy('id')->first() + : null; + + if ($duplicate !== null) { + $this->mergeInto($duplicate, $attributes, $data); + } else { + DB::table('module_domain')->updateOrInsert(['legacy_id' => $data->legacyId], $attributes); + } + + $this->dnsLinker->link($data->name, $duplicate->customer_id ?? $data->customerId, $data->registrarId); return true; } + + private function mergeInto(object $existing, array $incoming, CustomerDomainData $data): void + { + $update = []; + foreach (['order_id', 'registrar_id', 'note', 'first_payment', 'next_payment'] as $column) { + if (in_array($existing->{$column}, [null, '', 0], true) && ! in_array($incoming[$column], [null, '', 0], true)) { + $update[$column] = $incoming[$column]; + } + } + if (($incoming['next_due_date'] ?? 0) > ($existing->next_due_date ?? 0)) { + $update += [ + 'next_due_date' => $incoming['next_due_date'], + 'next_invoice_date' => $incoming['next_invoice_date'], + 'next_payment' => $incoming['next_payment'], + ]; + } + if ($update !== []) { + DB::table('module_domain')->where('id', $existing->id)->update($update); + } + + $this->report->note( + 'domain', + $data->name, + $data->legacyId, + 'trung_ten_mien_da_gop', + "Tên miền đã có ở module_domain #{$existing->id}; gộp bản ghi WHMCS #{$data->legacyId}, " + . ($update ? 'cập nhật: ' . implode(', ', array_keys($update)) : 'không có trường nào cần cập nhật') . '.', + ); + } } diff --git a/app/Integrations/Whmcs/Writers/LeadWriter.php b/app/Integrations/Whmcs/Writers/LeadWriter.php new file mode 100644 index 0000000..6ecff96 --- /dev/null +++ b/app/Integrations/Whmcs/Writers/LeadWriter.php @@ -0,0 +1,87 @@ + 'invoiceemails', + 'receives_domain_emails' => 'domainemails', + 'receives_support_emails' => 'supportemails', + 'receives_product_emails' => 'productemails', + 'receives_general_emails' => 'generalemails', + ]; + + public function __construct(private SyncReport $report) + { + } + + public function write(object $row, ?int $customerId): void + { + $email = strtolower(trim((string) $row->email)); + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->report->note('contact', $email, $row->id, 'email_khong_hop_le', 'Email liên hệ không hợp lệ, bỏ qua.'); + + return; + } + if (preg_match('/\.kgm$/i', $email)) { + $this->report->note('contact', $email, $row->id, 'email_nghi_sai', 'Email có đuôi .kgm, giữ nguyên như WHMCS.'); + } + + $attributes = $this->attributes($row, $customerId); + $lead = Lead::query()->where('email', $email)->first(); + + if ($lead === null) { + Lead::create([...$attributes, 'email' => $email, 'legacy_id' => $row->id, 'source' => 'whmcs_contact', 'status' => 'new']); + + return; + } + + $this->merge($lead, $attributes, $row); + } + + private function attributes(object $row, ?int $customerId): array + { + $fullname = trim("{$row->firstname} {$row->lastname}") ?: ($row->companyname ?: null); + $address = implode(', ', array_filter([$row->address1, $row->address2, $row->city, $row->state, $row->country])); + $flags = array_map(fn (string $column) => (bool) $row->{$column}, self::FLAGS); + + return [ + 'customer_id' => $customerId, + 'whmcs_client_id' => $row->userid, + 'fullname' => $fullname, + 'company' => $row->companyname ?: null, + 'phone' => PhoneNumber::normalize($row->phonenumber), + 'address' => $address ?: null, + 'note' => $customerId === null ? "Liên hệ của khách WHMCS #{$row->userid} (khách đã bị xoá)." : null, + ...$flags, + ]; + } + + private function merge(Lead $lead, array $incoming, object $row): void + { + $update = []; + foreach (['customer_id', 'whmcs_client_id', 'fullname', 'company', 'phone', 'address', 'note'] as $column) { + if (blank($lead->{$column}) && filled($incoming[$column])) { + $update[$column] = $incoming[$column]; + } + } + foreach (array_keys(self::FLAGS) as $flag) { + if (! $lead->{$flag} && $incoming[$flag]) { + $update[$flag] = true; + } + } + if ($lead->legacy_id === null && ! Lead::where('legacy_id', $row->id)->exists()) { + $update['legacy_id'] = $row->id; + } + if ($update !== []) { + $lead->update($update); + } + + $this->report->note('contact', $lead->email, $row->id, 'email_trung_da_gop', "Email đã có ở khách tiềm năng {$lead->uuid}; gộp liên hệ WHMCS #{$row->id}" . ($update ? ', bổ sung: ' . implode(', ', array_keys($update)) : '') . '.'); + } +} diff --git a/app/Services/Api/ServiceClient/ServiceClientService.php b/app/Services/Api/ServiceClient/ServiceClientService.php index 249b0d0..2341360 100644 --- a/app/Services/Api/ServiceClient/ServiceClientService.php +++ b/app/Services/Api/ServiceClient/ServiceClientService.php @@ -39,10 +39,29 @@ class ServiceClientService 'vps' => (int) $counts->get('vps', 0), 'server' => (int) $counts->get('server', 0), 'hosting' => (int) $counts->get('hosting', 0), - 'domain' => (int) $counts->get('domain', 0), + 'domain' => (int) $counts->get('domain', 0) + $this->countZonesWithoutService($customerId), ]; } + private function countZonesWithoutService(int $customerId): int + { + $serviceDomains = Service::query() + ->where('customer_id', $customerId) + ->where('type', 'domain') + ->whereNotIn('status', ['cancelled', 'terminated']) + ->pluck('server_name') + ->map(fn ($name) => mb_strtolower(trim((string) $name))); + + return DnsDomain::query() + ->whereHas('customer', fn ($query) => $query->where('customers.id', $customerId)) + ->where('is_created', '!=', DnsDomain::STATUS_CANCELLED) + ->pluck('domain_name') + ->map(fn ($name) => mb_strtolower(trim((string) $name))) + ->unique() + ->diff($serviceDomains) + ->count(); + } + /** * @param iterable $services */ diff --git a/app/Services/Email/EmailTemplateRenderer.php b/app/Services/Email/EmailTemplateRenderer.php index caae97a..e3a64aa 100644 --- a/app/Services/Email/EmailTemplateRenderer.php +++ b/app/Services/Email/EmailTemplateRenderer.php @@ -42,6 +42,9 @@ class EmailTemplateRenderer private function replace(string $content, array $variables, array $data): string { + $examples = array_map(fn (array $definition) => $definition['example'] ?? '', $variables); + $content = MailPlaceholders::resolveConditionals($content, [...$examples, ...$data]); + return preg_replace_callback(self::PLACEHOLDER_PATTERN, function (array $match) use ($variables, $data) { $key = $match[1]; if (!isset($variables[$key]) && !array_key_exists($key, $data)) { diff --git a/app/Support/DomainServiceSummary.php b/app/Support/DomainServiceSummary.php new file mode 100644 index 0000000..60498fa --- /dev/null +++ b/app/Support/DomainServiceSummary.php @@ -0,0 +1,82 @@ + $domains + * @return array> + */ + public static function forDomains(Collection $domains): array + { + $keys = $domains->map(fn ($d) => self::key($d->domain_name))->unique()->values()->all(); + if (!$keys) { + return []; + } + + $services = Service::query() + ->with('orderItem:id,months') + ->where('type', 'domain') + ->whereIn(\DB::raw('LOWER(TRIM(server_name))'), $keys) + ->orderByRaw("status = 'cancelled'") + ->orderBy('id') + ->get() + ->unique(fn (Service $s) => self::key($s->server_name)); + + $lastRenewal = ActivityLog::query() + ->where('subject_type', Service::class) + ->where('event', 'renewed') + ->whereIn('subject_id', $services->pluck('id')) + ->selectRaw('subject_id, MAX(created_at) as last_at') + ->groupBy('subject_id') + ->pluck('last_at', 'subject_id'); + + return $services->mapWithKeys(fn (Service $s) => [ + self::key($s->server_name) => self::summarize($s, $lastRenewal[$s->id] ?? null), + ])->all(); + } + + public static function key(?string $name): string + { + return mb_strtolower(trim((string) $name)); + } + + private static function summarize(Service $service, $lastRenewalAt): array + { + $months = (int) $service->orderItem?->months; + + return [ + 'service_id' => $service->id, + 'service_state' => self::state($service, $lastRenewalAt), + 'registered_at' => ($service->started_at ?? $service->created_at)?->toDateString(), + 'expires_at' => $service->expires_at?->toDateString(), + 'cycle_years' => in_array($months, self::YEAR_CYCLES, true) ? intdiv($months, 12) : null, + ]; + } + + private static function state(Service $service, $lastRenewalAt): string + { + if ($service->status === 'cancelled' || $service->cancelled_at) { + return 'cancelled'; + } + if ($service->expires_at && $service->expires_at->lte(now()->addDays(self::DUE_WITHIN_DAYS))) { + return 'due'; + } + if (!$lastRenewalAt) { + return 'new'; + } + + return now()->parse($lastRenewalAt)->gte(now()->subDays(self::RECENT_RENEWAL_DAYS)) + ? 'recently_renewed' + : 'renewed'; + } +} diff --git a/app/Support/MailPlaceholders.php b/app/Support/MailPlaceholders.php index 4699130..407eaba 100644 --- a/app/Support/MailPlaceholders.php +++ b/app/Support/MailPlaceholders.php @@ -8,6 +8,8 @@ final class MailPlaceholders { public const PATTERN = '/\{\{\s*\$?([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/'; + private const CONDITIONAL = '/\{\{\s*#if\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}(.*?)(?:\{\{\s*else\s*\}\}(.*?))?\{\{\s*\/if\s*\}\}/s'; + private const LAYOUT_SLOT = 'content'; public const RECIPIENT_FIELDS = [ @@ -29,7 +31,19 @@ final class MailPlaceholders { preg_match_all(self::PATTERN, implode("\n", array_map('strval', $contents)), $matches); - return array_values(array_diff(array_unique($matches[1]), [self::LAYOUT_SLOT])); + return array_values(array_diff(array_unique($matches[1]), [self::LAYOUT_SLOT, 'else'])); + } + + /** + * @param array $values + */ + public static function resolveConditionals(string $content, array $values): string + { + return preg_replace_callback( + self::CONDITIONAL, + fn (array $match) => trim((string) ($values[$match[1]] ?? '')) !== '' ? $match[2] : ($match[3] ?? ''), + $content, + ) ?? $content; } /** @@ -37,6 +51,8 @@ final class MailPlaceholders */ public static function fill(string $content, array $values, bool $escapeHtml = true): string { + $content = self::resolveConditionals($content, $values); + return preg_replace_callback(self::PATTERN, function (array $match) use ($values, $escapeHtml) { if ($match[1] === self::LAYOUT_SLOT) { return $match[0]; diff --git a/app/Support/PhoneNumber.php b/app/Support/PhoneNumber.php new file mode 100644 index 0000000..0630f21 --- /dev/null +++ b/app/Support/PhoneNumber.php @@ -0,0 +1,21 @@ +seedServiceLifecycleTemplates(); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_21_000002_split_trial_templates_by_account.php b/database/migrations/2026_10_21_000002_split_trial_templates_by_account.php new file mode 100644 index 0000000..06196b6 --- /dev/null +++ b/database/migrations/2026_10_21_000002_split_trial_templates_by_account.php @@ -0,0 +1,16 @@ +where('name', 'like', '% — Dùng thử dịch vụ')->delete(); + (new TemplateSeeder())->seedServiceLifecycleTemplates(); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_21_000003_prefix_service_lifecycle_subjects.php b/database/migrations/2026_10_21_000003_prefix_service_lifecycle_subjects.php new file mode 100644 index 0000000..456400f --- /dev/null +++ b/database/migrations/2026_10_21_000003_prefix_service_lifecycle_subjects.php @@ -0,0 +1,23 @@ +where('name', $item['name'])->first(); + if (!$template) { + continue; + } + $template->subject = $item['subject']; + $template->syncVariables($item['variables']); + $template->save(); + } + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_21_000004_widen_template_subject.php b/database/migrations/2026_10_21_000004_widen_template_subject.php new file mode 100644 index 0000000..e7707e4 --- /dev/null +++ b/database/migrations/2026_10_21_000004_widen_template_subject.php @@ -0,0 +1,18 @@ + $table->string('subject', 255)->nullable()->change()); + } + + public function down(): void + { + Schema::table('template', fn (Blueprint $table) => $table->string('subject', 100)->nullable()->change()); + } +}; diff --git a/database/migrations/2026_10_21_000005_prefix_all_template_subjects.php b/database/migrations/2026_10_21_000005_prefix_all_template_subjects.php new file mode 100644 index 0000000..310789d --- /dev/null +++ b/database/migrations/2026_10_21_000005_prefix_all_template_subjects.php @@ -0,0 +1,33 @@ + $subject) { + Template::query() + ->where(fn ($query) => $query->where('scenario', $keyOrName)->orWhere('key', $keyOrName)->orWhere('name', $keyOrName)) + ->get() + ->each(fn (Template $template) => $this->apply($template, $subject, [])); + } + + foreach (ServiceLifecycleTemplates::all() as $item) { + Template::query()->where('name', $item['name'])->get() + ->each(fn (Template $template) => $this->apply($template, $item['subject'], $item['variables'])); + } + } + + public function down(): void {} + + private function apply(Template $template, string $subject, array $variableOverrides): void + { + $template->subject = $subject; + $template->syncVariables($variableOverrides); + $template->save(); + } +}; diff --git a/database/migrations/2026_10_21_000006_rewrite_domain_new_service_template.php b/database/migrations/2026_10_21_000006_rewrite_domain_new_service_template.php new file mode 100644 index 0000000..1124b85 --- /dev/null +++ b/database/migrations/2026_10_21_000006_rewrite_domain_new_service_template.php @@ -0,0 +1,26 @@ +firstWhere('name', 'Tên miền — Cấp mới dịch vụ'); + $template = Template::query()->where('name', $item['name'])->first(); + if (!$template) { + return; + } + + $template->subject = $item['subject']; + $template->message = EmailTemplateShell::page($item['body']); + $template->metadata = [...($template->metadata ?? []), 'variables' => []]; + $template->syncVariables($item['variables']); + $template->save(); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_21_000007_seed_service_renewal_templates.php b/database/migrations/2026_10_21_000007_seed_service_renewal_templates.php new file mode 100644 index 0000000..7066ad7 --- /dev/null +++ b/database/migrations/2026_10_21_000007_seed_service_renewal_templates.php @@ -0,0 +1,14 @@ +seedServiceLifecycleTemplates(); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_21_000008_add_saler_to_customers_if_missing.php b/database/migrations/2026_10_21_000008_add_saler_to_customers_if_missing.php new file mode 100644 index 0000000..d9e4d0c --- /dev/null +++ b/database/migrations/2026_10_21_000008_add_saler_to_customers_if_missing.php @@ -0,0 +1,21 @@ +foreignId('saler')->nullable()->after('city')->constrained('user')->nullOnDelete(); + }); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_21_100001_add_lead_permissions.php b/database/migrations/2026_10_21_100001_add_lead_permissions.php new file mode 100644 index 0000000..ce835a9 --- /dev/null +++ b/database/migrations/2026_10_21_100001_add_lead_permissions.php @@ -0,0 +1,31 @@ +forgetCachedPermissions(); + + foreach (self::PERMISSIONS as $name) { + Permission::firstOrCreate(['name' => $name, 'guard_name' => 'admin']); + } + + foreach (['Full Administrator', 'Administrator'] as $role) { + Role::where('name', $role)->where('guard_name', 'admin')->first()?->givePermissionTo(self::PERMISSIONS); + } + + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + } + + public function down(): void + { + Permission::where('guard_name', 'admin')->whereIn('name', self::PERMISSIONS)->delete(); + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + } +}; diff --git a/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php b/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php new file mode 100644 index 0000000..c88be01 --- /dev/null +++ b/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php @@ -0,0 +1,319 @@ + ['Tên dịch vụ', 'Cloud VPS Business 2'], + 'domain_name' => ['Tên miền', 'example.vn'], + 'ssl_type' => ['Loại chứng chỉ', 'Positive SSL Wildcard'], + 'issued_date' => ['Ngày cấp', '21/10/2026'], + 'expires_date' => ['Ngày hết hạn', '21/10/2027'], + 'nameservers' => ['Nameserver', 'ns1.tgs.com.vn, ns2.tgs.com.vn'], + 'hosting_plan' => ['Gói hosting', 'Premium Hosting P2'], + 'service_host' => ['IP / Host', '203.0.113.10'], + 'service_user' => ['Tài khoản', 'administrator'], + 'service_pass' => ['Mật khẩu', 'Xk9#mP2vLq7'], + 'os_name' => ['Hệ điều hành', 'Ubuntu 22.04'], + 'auth_code' => ['Mã xác thực chuyển tên miền', 'A1b2C3d4E5'], + 'cancel_date' => ['Ngày huỷ / thu hồi', '21/10/2026 14:30'], + 'cancel_reason' => ['Lý do', 'Hết hạn sử dụng, chưa gia hạn'], + 'reset_date' => ['Thời điểm thực hiện', '21/10/2026 14:30'], + 'trial_days' => ['Số ngày dùng thử', '7'], + 'trial_expires' => ['Hạn dùng thử', '28/10/2026'], + 'service_login_url' => ['URL đăng nhập trang dịch vụ', 'https://portal.tgs.com.vn/dang-nhap'], + 'account_email' => ['Email đăng ký tài khoản', 'khach@example.com'], + 'account_pass' => ['Mật khẩu đăng ký tài khoản', 'Tr1al#Pass2026'], + 'name_server_1' => ['Nameserver 1 (NS1)', 'ns9.tgs.com.vn'], + 'name_server_2' => ['Nameserver 2 (NS2)', 'ns10.tgs.com.vn'], + 'kyc_url' => ['Link xác thực eKYC (để trống nếu không cần)', ''], + 'old_expires_date' => ['Hạn cũ (trước gia hạn)', '25/10/2026'], + 'billing_cycle' => ['Chu kỳ gia hạn', '1 năm'], + 'trial_plan' => ['Gói dùng thử', 'Cloud VPS Trial 2 vCPU / 4GB'], + ]; + + private const SERVICES = [ + 'ssl' => [ + 'label' => 'SSL', + 'noun' => 'chứng chỉ SSL', + 'target' => 'domain_name', + 'new' => ['Tên miền' => 'domain_name', 'Loại chứng chỉ' => 'ssl_type', 'Ngày cấp' => 'issued_date', 'Ngày hết hạn' => 'expires_date'], + 'reset' => ['Tên miền' => 'domain_name', 'Loại chứng chỉ' => 'ssl_type', 'Thời điểm cấp lại' => 'reset_date', 'Ngày hết hạn' => 'expires_date'], + 'trial' => ['Tên miền' => 'domain_name', 'Loại chứng chỉ' => 'ssl_type', 'Thời gian dùng thử' => 'trial_days', 'Hạn dùng thử' => 'trial_expires', 'URL đăng nhập' => 'service_login_url'], + ], + 'domain' => [ + 'label' => 'Tên miền', + 'noun' => 'tên miền', + 'target' => 'domain_name', + 'new' => ['Tên miền' => 'domain_name', 'Ngày đăng ký' => 'issued_date', 'Ngày hết hạn' => 'expires_date', 'Nameserver' => 'nameservers'], + 'reset' => ['Tên miền' => 'domain_name', 'Mã xác thực (Auth/EPP)' => 'auth_code', 'Thời điểm thực hiện' => 'reset_date'], + 'trial' => ['Tên miền' => 'domain_name', 'Nameserver' => 'nameservers', 'Hạn dùng thử' => 'trial_expires', 'URL đăng nhập' => 'service_login_url'], + ], + 'hosting' => [ + 'label' => 'Hosting', + 'noun' => 'Hosting', + 'target' => 'service_name', + 'new' => ['Gói dịch vụ' => 'hosting_plan', 'Tên miền' => 'domain_name', 'IP / Host' => 'service_host', 'Tài khoản' => 'service_user', 'Mật khẩu' => 'service_pass', 'Ngày hết hạn' => 'expires_date'], + 'reset' => ['Tên miền' => 'domain_name', 'Tài khoản' => 'service_user', 'Mật khẩu mới' => 'service_pass', 'Thời điểm thực hiện' => 'reset_date'], + 'trial' => ['Gói dùng thử' => 'trial_plan', 'Tên miền' => 'domain_name', 'IP / Host' => 'service_host', 'Tài khoản' => 'service_user', 'Mật khẩu' => 'service_pass', 'Hạn dùng thử' => 'trial_expires', 'URL đăng nhập' => 'service_login_url'], + ], + 'cloud_vps' => [ + 'label' => 'Cloud VPS', + 'noun' => 'Cloud VPS', + 'target' => 'service_name', + 'new' => ['Dịch vụ' => 'service_name', 'Hệ điều hành' => 'os_name', 'IP / Host' => 'service_host', 'Tài khoản' => 'service_user', 'Mật khẩu' => 'service_pass', 'Ngày hết hạn' => 'expires_date'], + 'reset' => ['Dịch vụ' => 'service_name', 'IP / Host' => 'service_host', 'Tài khoản' => 'service_user', 'Mật khẩu mới' => 'service_pass', 'Thời điểm thực hiện' => 'reset_date'], + 'trial' => ['Gói dùng thử' => 'trial_plan', 'Hệ điều hành' => 'os_name', 'IP / Host' => 'service_host', 'Tài khoản' => 'service_user', 'Mật khẩu' => 'service_pass', 'Hạn dùng thử' => 'trial_expires', 'URL đăng nhập' => 'service_login_url'], + ], + ]; + + private const RENEWALS = [ + 'vps' => ['label' => 'VPS', 'noun' => 'VPS', 'target' => 'service_name', 'column' => 'Dịch vụ'], + 'hosting' => ['label' => 'Hosting', 'noun' => 'Hosting', 'target' => 'service_name', 'column' => 'Dịch vụ'], + 'cloud_vps' => ['label' => 'Cloud VPS', 'noun' => 'Cloud VPS', 'target' => 'service_name', 'column' => 'Dịch vụ'], + 'dedicated' => ['label' => 'Dedicated', 'noun' => 'máy chủ Dedicated', 'target' => 'service_name', 'column' => 'Dịch vụ'], + 'colo' => ['label' => 'Colocation', 'noun' => 'dịch vụ Colocation', 'target' => 'service_name', 'column' => 'Dịch vụ'], + 'ssl' => ['label' => 'SSL', 'noun' => 'chứng chỉ SSL', 'target' => 'domain_name', 'column' => 'Tên miền'], + 'domain' => ['label' => 'Tên miền', 'noun' => 'tên miền', 'target' => 'domain_name', 'column' => 'Tên miền'], + ]; + + private const RESET_COPY = [ + 'ssl' => ['Cấp lại chứng chỉ SSL', 'Chứng chỉ SSL đã được cấp lại (reissue) theo yêu cầu. Quý khách vui lòng cài đặt lại chứng chỉ mới lên máy chủ; chứng chỉ cũ sẽ không còn hiệu lực.'], + 'domain' => ['Đặt lại mã xác thực tên miền', 'Mã xác thực (Auth/EPP code) của tên miền đã được đặt lại theo yêu cầu. Mã này dùng để chuyển tên miền sang nhà đăng ký khác, vui lòng không chia sẻ cho người không liên quan.'], + 'hosting' => ['Đặt lại mật khẩu Hosting', 'Mật khẩu Hosting của Quý khách đã được đặt lại theo yêu cầu. Thông tin đăng nhập mới như sau:'], + 'cloud_vps' => ['Đặt lại mật khẩu Cloud VPS', 'Mật khẩu Cloud VPS của Quý khách đã được đặt lại theo yêu cầu. Thông tin đăng nhập mới như sau:'], + ]; + + private const CANCEL_COPY = [ + 'ssl' => 'thu hồi (revoke)', + 'domain' => 'huỷ', + 'hosting' => 'huỷ', + 'cloud_vps' => 'huỷ', + ]; + + private const CANCEL_NOTE = [ + 'ssl' => 'Sau khi thu hồi, chứng chỉ không còn hiệu lực và website có thể hiển thị cảnh báo không an toàn. Vui lòng đăng ký chứng chỉ mới nếu còn nhu cầu.', + 'domain' => 'Sau khi huỷ, tên miền có thể được đăng ký bởi người khác. Vui lòng liên hệ ngay nếu Quý khách muốn giữ lại tên miền.', + 'hosting' => 'Toàn bộ dữ liệu website, cơ sở dữ liệu và email trên Hosting sẽ bị xoá và không thể khôi phục. Vui lòng sao lưu dữ liệu cần thiết.', + 'cloud_vps' => 'Toàn bộ dữ liệu trên máy chủ ảo sẽ bị xoá và không thể khôi phục. Vui lòng sao lưu dữ liệu cần thiết.', + ]; + + /** + * @return array}> + */ + public static function all(): array + { + $items = []; + foreach (self::SERVICES as $code => $service) { + $items[] = self::newService($service); + $items[] = self::cancelService($code, $service); + $items[] = self::resetService($code, $service); + foreach ([true, false] as $hasAccount) { + $items[] = self::trialService($service, $hasAccount); + } + } + + foreach (self::RENEWALS as $service) { + $items[] = self::renewService($service); + } + + return $items; + } + + private static function renewService(array $service): array + { + $head = T::HEAD_CELL_STYLE . ' text-align: center;'; + $cell = 'box-sizing: border-box; padding: 11px 14px; font-size: 13px; text-align: center;'; + $headers = ''; + foreach ([$service['column'], 'Chu kỳ', 'Hạn cũ', 'Hạn mới'] as $label) { + $headers .= '' . $label . ''; + } + $values = '{{' . $service['target'] . '}}' + . '{{billing_cycle}}' + . '{{old_expires_date}}' + . '{{expires_date}}'; + + $badge = '' + . '' + . '
✅ GIA HẠN THÀNH CÔNG
'; + + return self::template( + "{$service['label']} — Gia hạn dịch vụ", + "V/v: Thế Giới Số gia hạn thành công {$service['noun']} - {{{$service['target']}}} | {{client_name}}", + $badge + . T::greeting('{{client_name}}') + . T::paragraph("Thế Giới Số trân trọng cảm ơn Quý khách đã hoàn tất thanh toán. {$service['noun']} dưới đây đã được gia hạn thành công và tiếp tục hoạt động bình thường.") + . self::section('📋 THÔNG TIN GIA HẠN') + . '' + . $headers . '' . $values . '
' + . T::paragraph('Thời hạn mới được tính từ ngày kết thúc cũ, cộng thêm theo chu kỳ thanh toán Quý khách đã chọn.') + . self::section('📞 HỖ TRỢ KỸ THUẬT 24/7') + . T::paragraph('Hotline: (028) 7309 7379
Email: support@tgs.com.vn') + . 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.') + . T::paragraph('Trân trọng,'), + ); + } + + private static function newService(array $service): array + { + if ($service['target'] === 'domain_name' && $service['label'] === 'Tên miền') { + return self::template( + "{$service['label']} — Cấp mới dịch vụ", + self::subject("cấp mới {$service['noun']}", $service), + self::domainRegisteredBody(), + ); + } + + return self::template( + "{$service['label']} — Cấp mới dịch vụ", + self::subject("cấp mới {$service['noun']}", $service), + T::title("{$service['label']} đã sẵn sàng") + . T::greeting('{{client_name}}') + . T::paragraph("Cảm ơn Quý khách đã tin tưởng sử dụng dịch vụ của {{app_name}}. {$service['noun']} {{{$service['target']}}} đã được khởi tạo thành công.") + . T::heading('Thông tin dịch vụ') + . T::infoTable(self::rows($service['new'])) + . T::button('{{login_url}}', 'QUẢN LÝ DỊCH VỤ') + . T::note('Vui lòng lưu thông tin ở nơi an toàn và không chia sẻ cho người khác.') + . T::supportLine(), + ); + } + + private static function domainRegisteredBody(): string + { + $cell = 'box-sizing: border-box; padding: 10px; font-size: 13px; text-align: center;'; + $head = T::HEAD_CELL_STYLE . ' text-align: center;'; + $headers = ''; + foreach (['Tên miền', 'Ngày đăng ký', 'Ngày hết hạn', 'NS1', 'NS2'] as $label) { + $headers .= '' . $label . ''; + } + $values = ''; + foreach (['{{domain_name}}', '{{issued_date}}', '{{expires_date}}', '{{name_server_1}}', '{{name_server_2}}'] as $value) { + $values .= '' . $value . ''; + } + $domainTable = '' + . $headers . '' . $values . '
'; + + return 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.') + . self::section('🌐 I. THÔNG TIN TÊN MIỀN') + . $domainTable + . self::section('🔐 II. THÔNG TIN TÀI KHOẢN QUẢN LÝ') + . T::infoTable([ + 'Trang đăng nhập' => '{{service_login_url}}', + 'Tên đăng nhập' => '{{account_email}}', + 'Mật khẩu' => '{{account_pass}}', + ]) + . T::note('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.', 'Bảo mật:') + . '{{#if kyc_url}}' + . self::section('📋 III. LƯU Ý QUAN TRỌNG') + . T::paragraph('Căn cứ Khoản 2 Điều 9 và Khoản 6 Điều 21 Thông tư số 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ về Quản lý và sử dụng tài nguyên Internet, chủ thể đăng ký tên miền là cá nhân có trách nhiệm hoàn thiện hồ sơ đăng ký thông qua hình thức xác thực điện tử (eKYC) trong vòng 03 ngày kể từ thời điểm nhận được email này.') + . T::button('{{kyc_url}}', '✅ XÁC THỰC eKYC NGAY') + . T::note('Việc không hoàn thành xác thực đúng hạn có thể ảnh hưởng đến quyền sử dụng tên miền của Quý khách.', '⏰ Lưu ý:') + . '{{/if}}' + . self::section('📞 {{#if kyc_url}}IV{{else}}III{{/if}}. HỖ TRỢ KỸ THUẬT 24/7') + . T::paragraph('Hotline: (028) 7309 7379
Email: support@tgs.com.vn') + . 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. 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.') + . T::paragraph('Trân trọng,'); + } + + private static function section(string $title): string + { + return '' + . '' + . '
' . $title . '
'; + } + + private static function cancelService(string $code, array $service): array + { + $verb = self::CANCEL_COPY[$code]; + + return self::template( + "{$service['label']} — Thông báo huỷ / thu hồi", + self::subject("thông báo {$verb} {$service['noun']}", $service), + T::title("Thông báo {$verb} {$service['noun']}") + . T::greeting('{{client_name}}') + . T::paragraph("{{app_name}} xin thông báo {$service['noun']} {{{$service['target']}}} của Quý khách đã được {$verb}.") + . T::infoTable([ + $service['label'] => '{{' . $service['target'] . '}}', + 'Ngày ' . $verb => '{{cancel_date}}', + 'Lý do' => '{{cancel_reason}}', + ]) + . T::note(self::CANCEL_NOTE[$code], 'Quan trọng:') + . T::supportLine(), + ); + } + + private static function resetService(string $code, array $service): array + { + [$title, $intro] = self::RESET_COPY[$code]; + + return self::template( + "{$service['label']} — Đặt lại (Reset)", + self::subject(mb_strtolower(mb_substr($title, 0, 1)) . mb_substr($title, 1), $service), + T::title($title) + . T::greeting('{{client_name}}') + . T::paragraph($intro) + . T::infoTable(self::rows($service['reset'])) + . T::button('{{login_url}}', 'QUẢN LÝ DỊCH VỤ') + . T::note('Nếu Quý khách không yêu cầu thao tác này, vui lòng liên hệ ngay bộ phận hỗ trợ để được bảo vệ tài khoản.') + . T::supportLine(), + ); + } + + private static function trialService(array $service, bool $hasAccount): array + { + $suffix = $hasAccount ? 'đã có tài khoản' : 'chưa có tài khoản'; + $account = $hasAccount + ? T::paragraph('Quý khách vui lòng đăng nhập bằng tài khoản {{account_email}} đã đăng ký trước đó để quản lý dịch vụ dùng thử.') + : T::heading('Tài khoản đăng ký') + . T::paragraph('Hệ thống đã tự động tạo tài khoản để Quý khách quản lý dịch vụ dùng thử:') + . T::infoTable(['Email đăng nhập' => '{{account_email}}', 'Mật khẩu' => '{{account_pass}}']); + $note = $hasAccount + ? 'Dịch vụ dùng thử sẽ tự động ngừng khi hết hạn và dữ liệu có thể bị xoá. Quý khách vui lòng đăng ký chính thức trước thời hạn để tiếp tục sử dụng.' + : 'Vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên. Dịch vụ dùng thử sẽ tự động ngừng khi hết hạn và dữ liệu có thể bị xoá.'; + + return self::template( + "{$service['label']} — Dùng thử ({$suffix})", + self::subject("kích hoạt dùng thử {$service['noun']}", $service), + T::title("Dùng thử {$service['label']}") + . T::greeting('{{client_name}}') + . T::paragraph("Cảm ơn Quý khách đã đăng ký dùng thử {$service['noun']} tại {{app_name}}. Dịch vụ dùng thử đã được kích hoạt và có hiệu lực đến {{trial_expires}}.") + . T::heading('Thông tin dùng thử') + . T::infoTable(self::rows($service['trial'])) + . $account + . T::button('{{service_login_url}}', 'ĐĂNG NHẬP QUẢN LÝ DỊCH VỤ') + . T::note($note) + . T::supportLine(), + ); + } + + /** + * @param array $map label => variable key + * @return array + */ + private static function rows(array $map): array + { + return array_map(fn (string $key) => '{{' . $key . '}}', $map); + } + + private static function subject(string $topic, array $service): string + { + return "V/v: Thế Giới Số {$topic} - {{{$service['target']}}} | {{client_name}}"; + } + + private static function template(string $name, string $subject, string $body): array + { + $variables = []; + foreach (MailPlaceholders::extract($subject, $body) as $key) { + if (isset(self::VARIABLES[$key])) { + $variables[] = ['key' => $key, 'label' => self::VARIABLES[$key][0], 'example' => self::VARIABLES[$key][1]]; + } + } + + return compact('name', 'subject', 'body', 'variables'); + } +} diff --git a/database/seeders/EmailTemplates/SubjectFormat.php b/database/seeders/EmailTemplates/SubjectFormat.php new file mode 100644 index 0000000..17d5304 --- /dev/null +++ b/database/seeders/EmailTemplates/SubjectFormat.php @@ -0,0 +1,72 @@ + ['mã xác thực tài khoản', null, 'full_name'], + 'customer_password_reset_link' => ['yêu cầu đặt lại mật khẩu', null, 'full_name'], + 'customer_two_factor_code' => ['mã xác thực {{otp_purpose}}', null, 'full_name'], + 'password_reset_admin' => ['cập nhật mật khẩu tài khoản', null, 'full_name'], + 'customer_welcome' => ['chào mừng Quý khách', null, 'full_name'], + 'ticket_created' => ['tiếp nhận yêu cầu hỗ trợ #{{ticket_num}}', null, 'full_name'], + 'vm_credentials' => ['bàn giao Cloud VPS', 'product_name', 'full_name'], + 'hosting_information' => ['thông tin truy cập Hosting', 'product_name', 'client_name'], + 'hosting_account_created' => ['gửi thông tin truy cập Hosting', 'server_name', 'recipient_label'], + 'vm_suspended' => ['tạm ngưng Cloud VPS', 'vm_name', 'customer_name'], + 'service_expiry' => ['dịch vụ sắp hết hạn', 'product_name', null], + 'service_renewal_reminder' => ['nhắc gia hạn dịch vụ', 'product_name', 'client_name'], + 'service_renewed' => ['gia hạn dịch vụ thành công', 'product_name', 'client_name'], + 'auto_renew_failed' => ['chưa thể tự động gia hạn dịch vụ', 'product_name', 'client_name'], + 'vm_creation_failed' => ['không thể khởi tạo máy chủ ảo (đơn hàng #{{order_num}})', 'product_name', 'full_name'], + 'vm_rebuild_failed' => ['cài lại hệ điều hành thất bại', 'vm_name', 'full_name'], + 'vm_password_changed' => ['thay đổi mật khẩu VPS', 'vm_name', 'full_name'], + 'snapshot_action' => ['{{action_title}}', 'vm_name', 'full_name'], + 'invoice_notification' => ['{{title}} #{{invoice_number}}', null, 'customer_name'], + 'invoice_payment_reminder' => ['nhắc thanh toán hoá đơn {{invoice_number}}', null, 'client_name'], + 'customer_deposit_success' => ['nạp tiền thành công {{amount_formatted}} VNĐ', null, 'customer_name'], + 'manual_deposit_status' => ['trạng thái nạp tiền', null, 'customer_name'], + 'order_created' => ['xác nhận đơn hàng #{{order_num}}', null, 'client_name'], + 'domain_order_received' => ['ghi nhận đơn đăng ký tên miền', 'domain_name', 'customer_name'], + 'domain_order_approved' => ['bàn giao tên miền', 'domain_name', 'client_name'], + 'domain_order_rejected' => ['từ chối đăng ký tên miền', 'domain_name', 'client_name'], + 'ip_allocated' => ['cấp phát {{ip_count}} địa chỉ IP', null, 'client_name'], + 'ip_revoked' => ['thu hồi {{ip_count}} địa chỉ IP', null, 'client_name'], + 'admin_order_notification' => ['[ADMIN] đơn hàng mới #{{order_num}}', null, 'customer_name'], + 'admin_domain_order_new' => ['[ADMIN] đơn đăng ký tên miền mới', 'domain_name', 'customer_name'], + 'admin_hosting_account_created' => ['[ADMIN] hosting account mới', 'domain_name', 'customer_name'], + 'admin_trial_registration_request' => ['[ADMIN] yêu cầu dùng thử Cloud VPS', null, 'customer_name'], + 'admin_manual_deposit_pending' => ['[ADMIN] yêu cầu nạp tiền chờ duyệt {{transaction_code}}', null, 'customer_name'], + 'admin_ticket_notification' => ['[ADMIN] ticket mới #{{ticket_num}}', null, 'customer_name'], + 'admin_payment_received' => ['[ADMIN] khách hàng thanh toán {{payment_type}} #{{reference}}', null, 'customer_name'], + 'admin_service_renewed' => ['[ADMIN] {{admin_name}} gia hạn dịch vụ', 'product_name', 'customer_name'], + 'customer_signup_verify' => ['[ADMIN] đơn hàng mới #{{order_num}}', null, 'customer_name'], + 'customer_password_reset' => ['[ADMIN] yêu cầu nạp tiền chờ duyệt {{transaction_code}}', null, 'customer_name'], + 'vm_password_reset_success' => ['đặt lại mật khẩu VPS', 'vm_name', null], + 'Khuyến mãi tháng 10' => ['ưu đãi 30% Cloud VPS tháng 10', null, 'client_name'], + 'Thông báo bảo trì hệ thống' => ['thông báo bảo trì hệ thống ngày {{maintenance_date}}', null, 'client_name'], + 'Ra mắt Premium Hosting' => ['ra mắt Premium Hosting thế hệ mới', null, 'client_name'], + 'Thông báo bổ sung hồ sơ bản khai tên miền' => ['thông báo bổ sung hồ sơ bản khai tên miền', 'domain_name', 'customer_name'], + ]; + + /** + * @return array + */ + public static function all(): array + { + return array_map(fn (array $parts) => self::build(...$parts), self::MAP); + } + + public static function for(string $keyOrName): ?string + { + return isset(self::MAP[$keyOrName]) ? self::build(...self::MAP[$keyOrName]) : null; + } + + public static function build(string $topic, ?string $target, ?string $customer): string + { + return 'V/v: Thế Giới Số ' . $topic + . ($target ? " - {{{$target}}}" : '') + . ($customer ? " | {{{$customer}}}" : ''); + } +} diff --git a/database/seeders/RolesAndPermissions.php b/database/seeders/RolesAndPermissions.php index 2320a85..18c942d 100644 --- a/database/seeders/RolesAndPermissions.php +++ b/database/seeders/RolesAndPermissions.php @@ -26,6 +26,13 @@ class RolesAndPermissions extends Seeder 'mail-campaign.create', 'mail-campaign.manager', + // Khách tiềm năng + 'lead.view', + 'lead.create', + 'lead.edit', + 'lead.destroy', + 'lead.manager', + // Nhân sự 'administrator.view', 'administrator.create', diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index 750c1d1..fee3aa4 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -11,6 +11,8 @@ use Database\Seeders\EmailTemplates\BillingTemplates; use Database\Seeders\EmailTemplates\IpTemplates; use Database\Seeders\EmailTemplates\LegacyTemplates; use Database\Seeders\EmailTemplates\MarketingTemplates; +use Database\Seeders\EmailTemplates\ServiceLifecycleTemplates; +use Database\Seeders\EmailTemplates\SubjectFormat; use Database\Seeders\EmailTemplates\ServiceTemplates; use Database\Seeders\EmailTemplates\TemplateVariables; use Illuminate\Database\Seeder; @@ -27,9 +29,18 @@ class TemplateSeeder extends Seeder $this->createMarketingIfMissing($item); } + $this->seedServiceLifecycleTemplates(); + $this->createMarketingIfMissing(self::ipRevocationTemplate()); } + public function seedServiceLifecycleTemplates(): void + { + foreach (ServiceLifecycleTemplates::all() as $item) { + $this->createMarketingIfMissing($item); + } + } + public function seedScenarioIfMissing(string $scenario): void { if (Template::query()->where('key', $scenario)->orWhere('scenario', $scenario)->exists()) { @@ -62,7 +73,7 @@ class TemplateSeeder extends Seeder $template->fill([ 'type_id' => 0, 'format' => 'html', - 'subject' => $item['subject'], + 'subject' => SubjectFormat::for($item['name']) ?? $item['subject'], 'message' => $item['message'] ?? EmailTemplateShell::page($item['body']), 'status' => 1, ]); @@ -83,7 +94,7 @@ class TemplateSeeder extends Seeder ]; $scenarios = array_map( - fn (array $item) => [...$item, 'message' => EmailTemplateShell::page($item['body'])], + fn (array $item) => [...$item, 'subject' => SubjectFormat::for($item['scenario']) ?? $item['subject'], 'message' => EmailTemplateShell::page($item['body'])], [ ...AccountTemplates::all(), ...ServiceTemplates::all(), @@ -102,7 +113,7 @@ class TemplateSeeder extends Seeder public static function legacyTemplates(): array { return array_map( - fn (array $item) => [...$item, 'message' => EmailTemplateShell::page($item['body'])], + fn (array $item) => [...$item, 'subject' => SubjectFormat::for($item['scenario']) ?? $item['subject'], 'message' => EmailTemplateShell::page($item['body'])], LegacyTemplates::all(), ); } diff --git a/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php b/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php new file mode 100644 index 0000000..1b63d2c --- /dev/null +++ b/modules/Lead/App/Http/Controllers/Api/Admin/LeadController.php @@ -0,0 +1,97 @@ +with('customer:id,uuid,fullname,email') + ->search($request->input('filter')) + ->when($request->filled('status'), fn ($query) => $query->where('status', $request->input('status'))) + ->when($request->filled('source'), fn ($query) => $query->where('source', $request->input('source'))) + ->when($request->input('linked') === '1', fn ($query) => $query->whereNotNull('customer_id')) + ->when($request->input('linked') === '0', fn ($query) => $query->whereNull('customer_id')) + ->orderByDesc('id') + ->paginate(min(max((int) $request->input('per_page', 20), 1), 100)); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => LeadResource::collection($leads->items()), + 'total' => $leads->total(), + 'per_page' => $leads->perPage(), + 'current_page' => $leads->currentPage(), + 'last_page' => $leads->lastPage(), + ], + ]); + } + + public function show(string $uuid): JsonResponse + { + return response()->json(['error' => false, 'data' => new LeadResource($this->find($uuid)->load('customer:id,uuid,fullname,email'))]); + } + + public function store(LeadRequest $request): JsonResponse + { + $lead = Lead::create($request->validated()); + + return response()->json(['error' => false, 'message' => 'Đã thêm khách tiềm năng.', 'data' => new LeadResource($lead->load('customer:id,uuid,fullname,email'))], 201); + } + + public function update(string $uuid, LeadRequest $request): JsonResponse + { + $lead = $this->find($uuid); + $lead->update($request->validated()); + + return response()->json(['error' => false, 'message' => 'Đã cập nhật khách tiềm năng.', 'data' => new LeadResource($lead->load('customer:id,uuid,fullname,email'))]); + } + + public function destroy(string $uuid): JsonResponse + { + $this->find($uuid)->delete(); + + return response()->json(['error' => false, 'message' => 'Đã xoá khách tiềm năng.']); + } + + public function bulkDestroy(Request $request): JsonResponse + { + $uuids = $this->bulkUuids($request); + $deleted = Lead::query()->whereIn('uuid', $uuids)->delete(); + + return response()->json(['error' => false, 'message' => "Đã xoá {$deleted} khách tiềm năng.", 'data' => ['affected' => $deleted]]); + } + + public function bulkStatus(Request $request): JsonResponse + { + $status = $request->validate(['status' => ['required', Rule::in(Lead::STATUSES)]])['status']; + $updated = Lead::query()->whereIn('uuid', $this->bulkUuids($request))->update(['status' => $status, 'updated_at' => now()]); + + return response()->json(['error' => false, 'message' => "Đã cập nhật trạng thái cho {$updated} khách tiềm năng.", 'data' => ['affected' => $updated]]); + } + + /** + * @return array + */ + private function bulkUuids(Request $request): array + { + return $request->validate([ + 'uuids' => ['required', 'array', 'min:1', 'max:500'], + 'uuids.*' => ['required', 'string', 'max:36'], + ])['uuids']; + } + + private function find(string $uuid): Lead + { + return Lead::query()->where('uuid', $uuid)->firstOrFail(); + } +} diff --git a/modules/Lead/App/Http/Requests/LeadRequest.php b/modules/Lead/App/Http/Requests/LeadRequest.php new file mode 100644 index 0000000..f1290fb --- /dev/null +++ b/modules/Lead/App/Http/Requests/LeadRequest.php @@ -0,0 +1,49 @@ + ['nullable', 'string', 'max:255'], + 'company' => ['nullable', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('leads', 'email')->ignore($this->route('uuid'), 'uuid')], + 'phone' => ['nullable', 'string', 'max:30'], + 'address' => ['nullable', 'string', 'max:1000'], + 'status' => ['nullable', Rule::in(Lead::STATUSES)], + 'customer_id' => ['nullable', 'integer', 'exists:customers,id'], + 'receives_invoice_emails' => ['nullable', 'boolean'], + 'receives_domain_emails' => ['nullable', 'boolean'], + 'receives_support_emails' => ['nullable', 'boolean'], + 'receives_product_emails' => ['nullable', 'boolean'], + 'receives_general_emails' => ['nullable', 'boolean'], + 'note' => ['nullable', 'string', 'max:5000'], + ]; + } + + public function messages(): array + { + return [ + 'email.required' => 'Email là bắt buộc.', + 'email.unique' => 'Email này đã có trong danh sách khách tiềm năng.', + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('email')) { + $this->merge(['email' => strtolower(trim((string) $this->input('email')))]); + } + } +} diff --git a/modules/Lead/App/Http/Resources/LeadResource.php b/modules/Lead/App/Http/Resources/LeadResource.php new file mode 100644 index 0000000..af04132 --- /dev/null +++ b/modules/Lead/App/Http/Resources/LeadResource.php @@ -0,0 +1,38 @@ + $this->uuid, + 'fullname' => $this->fullname, + 'company' => $this->company, + 'email' => $this->email, + 'phone' => $this->phone, + 'address' => $this->address, + 'source' => $this->source, + 'status' => $this->status, + 'whmcs_client_id' => $this->whmcs_client_id, + 'receives_invoice_emails' => $this->receives_invoice_emails, + 'receives_domain_emails' => $this->receives_domain_emails, + 'receives_support_emails' => $this->receives_support_emails, + 'receives_product_emails' => $this->receives_product_emails, + 'receives_general_emails' => $this->receives_general_emails, + 'note' => $this->note, + 'customer' => $this->whenLoaded('customer', fn () => $this->customer ? [ + 'id' => $this->customer->id, + 'uuid' => $this->customer->uuid, + 'fullname' => $this->customer->fullname, + 'email' => $this->customer->email, + ] : null), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/modules/Lead/App/Models/Lead.php b/modules/Lead/App/Models/Lead.php new file mode 100644 index 0000000..24e3b8e --- /dev/null +++ b/modules/Lead/App/Models/Lead.php @@ -0,0 +1,68 @@ + 'boolean', + 'receives_domain_emails' => 'boolean', + 'receives_support_emails' => 'boolean', + 'receives_product_emails' => 'boolean', + 'receives_general_emails' => 'boolean', + ]; + + public function uniqueIds(): array + { + return ['uuid']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function scopeSearch(Builder $query, ?string $term): Builder + { + $term = trim((string) $term); + + return $term === '' ? $query : $query->where(function (Builder $query) use ($term) { + $like = '%' . addcslashes($term, '%_\\') . '%'; + $query->where('fullname', 'like', $like) + ->orWhere('company', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhere('phone', 'like', $like); + }); + } +} diff --git a/modules/Lead/App/Providers/LeadServiceProvider.php b/modules/Lead/App/Providers/LeadServiceProvider.php new file mode 100644 index 0000000..63b56cc --- /dev/null +++ b/modules/Lead/App/Providers/LeadServiceProvider.php @@ -0,0 +1,14 @@ +mergeConfigFrom(module_path('Lead', 'config/config.php'), 'lead'); + $this->loadMigrationsFrom(module_path('Lead', 'Database/migrations')); + } +} diff --git a/modules/Lead/Database/migrations/2026_10_21_100000_create_leads_table.php b/modules/Lead/Database/migrations/2026_10_21_100000_create_leads_table.php new file mode 100644 index 0000000..2a0cf2e --- /dev/null +++ b/modules/Lead/Database/migrations/2026_10_21_100000_create_leads_table.php @@ -0,0 +1,38 @@ +id(); + $table->uuid('uuid')->unique(); + $table->unsignedBigInteger('legacy_id')->nullable()->unique(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->unsignedBigInteger('whmcs_client_id')->nullable(); + $table->string('fullname')->nullable(); + $table->string('company')->nullable(); + $table->string('email')->unique(); + $table->string('phone', 30)->nullable(); + $table->text('address')->nullable(); + $table->string('source', 30)->default('manual'); + $table->string('status', 20)->default('new')->index(); + $table->boolean('receives_invoice_emails')->default(false); + $table->boolean('receives_domain_emails')->default(false); + $table->boolean('receives_support_emails')->default(false); + $table->boolean('receives_product_emails')->default(false); + $table->boolean('receives_general_emails')->default(false); + $table->text('note')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('leads'); + } +}; diff --git a/modules/Lead/composer.json b/modules/Lead/composer.json new file mode 100644 index 0000000..29e82a2 --- /dev/null +++ b/modules/Lead/composer.json @@ -0,0 +1,31 @@ +{ + "name": "nwidart/lead", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\Lead\\": "", + "Modules\\Lead\\App\\": "App/", + "Modules\\Lead\\Database\\Factories\\": "Database/Factories/", + "Modules\\Lead\\Database\\Seeders\\": "Database/Seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Lead\\Tests\\": "tests/" + } + } +} diff --git a/modules/Lead/config/config.php b/modules/Lead/config/config.php new file mode 100644 index 0000000..03fd4e4 --- /dev/null +++ b/modules/Lead/config/config.php @@ -0,0 +1,5 @@ + 'Lead', +]; diff --git a/modules/Lead/module.json b/modules/Lead/module.json new file mode 100644 index 0000000..f2cc1e3 --- /dev/null +++ b/modules/Lead/module.json @@ -0,0 +1,11 @@ +{ + "name": "Lead", + "alias": "lead", + "description": "Khach tiem nang va nguoi lien he chua phai khach hang", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Lead\\App\\Providers\\LeadServiceProvider" + ], + "files": [] +} diff --git a/modules_statuses.json b/modules_statuses.json index b6232d8..e745d18 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -1,4 +1,5 @@ { "DomainFluctuation": true, - "Menu": true + "Menu": true, + "Lead": true } diff --git a/routes/api.php b/routes/api.php index ecb087a..56b8f99 100644 --- a/routes/api.php +++ b/routes/api.php @@ -34,6 +34,7 @@ Route::prefix('v1') require __DIR__.'/api/admin/hosting.php'; require __DIR__.'/api/admin/system.php'; require __DIR__.'/api/admin/tags.php'; + require __DIR__.'/api/admin/leads.php'; }); }); diff --git a/routes/api/admin/leads.php b/routes/api/admin/leads.php new file mode 100644 index 0000000..15f5491 --- /dev/null +++ b/routes/api/admin/leads.php @@ -0,0 +1,16 @@ +controller(LeadController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:lead.view'); + Route::post('/', 'store')->middleware('perm:lead.create'); + Route::post('bulk-delete', 'bulkDestroy')->middleware('perm:lead.destroy'); + Route::post('bulk-status', 'bulkStatus')->middleware('perm:lead.edit'); + Route::get('{uuid}', 'show')->middleware('perm:lead.view'); + Route::put('{uuid}', 'update')->middleware('perm:lead.edit'); + Route::delete('{uuid}', 'destroy')->middleware('perm:lead.destroy'); + }); diff --git a/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php b/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php index 0171d7d..2ce3c24 100644 --- a/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php +++ b/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php @@ -65,7 +65,7 @@ class IpAllocationTest extends TestCase $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'); + ->assertJsonPath('data.subject', 'V/v: Thế Giới Số cấp phát 1 địa chỉ IP | Công ty A'); $this->assertStringContainsString('10.0.0.5', $response->json('data.html')); $this->assertStringContainsString('Colo <b>1U</b>', $response->json('data.html')); @@ -122,7 +122,7 @@ class IpAllocationTest extends TestCase ->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'); + Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'V/v: Thế Giới Số cấp phát 2 địa chỉ IP | Công ty A'); $this->assertSame('Dịch vụ IP', $first->fresh()->service_name); $this->assertSame('CLOUD VPS SILVER', $second->fresh()->service_name); } diff --git a/tests/Feature/Api/Admin/LeadApiTest.php b/tests/Feature/Api/Admin/LeadApiTest.php new file mode 100644 index 0000000..d9d1923 --- /dev/null +++ b/tests/Feature/Api/Admin/LeadApiTest.php @@ -0,0 +1,92 @@ + 'role-' . uniqid(), 'guard_name' => 'admin']); + foreach ($permissions as $name) { + $role->givePermissionTo(Permission::firstOrCreate(['name' => $name, 'guard_name' => 'admin'])); + } + $admin = User::factory()->create(); + $admin->assignRole($role); + + return $admin; + } + + public function test_crud_flow_and_email_is_normalized_and_unique(): void + { + $admin = $this->admin(['lead.view', 'lead.create', 'lead.edit', 'lead.destroy']); + + $uuid = $this->actingAs($admin, 'admin') + ->postJson(self::BASE, ['fullname' => 'Khách A', 'email' => ' Khach.A@Example.com ', 'status' => 'new']) + ->assertCreated() + ->assertJsonPath('data.email', 'khach.a@example.com') + ->json('data.uuid'); + + $this->actingAs($admin, 'admin') + ->postJson(self::BASE, ['email' => 'khach.a@example.com']) + ->assertStatus(422); + + $this->actingAs($admin, 'admin') + ->putJson(self::BASE . "/{$uuid}", ['email' => 'khach.a@example.com', 'status' => 'contacted', 'phone' => '0901234567']) + ->assertOk() + ->assertJsonPath('data.status', 'contacted'); + + $this->actingAs($admin, 'admin')->getJson(self::BASE . '?filter=0901')->assertOk()->assertJsonPath('data.total', 1); + $this->actingAs($admin, 'admin')->getJson(self::BASE . "/{$uuid}")->assertOk()->assertJsonPath('data.phone', '0901234567'); + + $this->actingAs($admin, 'admin')->deleteJson(self::BASE . "/{$uuid}")->assertOk(); + $this->assertSame(0, Lead::count()); + } + + public function test_permissions_are_enforced(): void + { + $viewer = $this->admin(['lead.view']); + + $this->actingAs($viewer, 'admin')->getJson(self::BASE)->assertOk(); + $this->actingAs($viewer, 'admin')->postJson(self::BASE, ['email' => 'x@example.com'])->assertForbidden(); + $this->actingAs($viewer, 'admin')->deleteJson(self::BASE . '/' . uniqid())->assertForbidden(); + } + + public function test_bulk_status_and_bulk_delete_only_touch_given_uuids(): void + { + $admin = $this->admin(['lead.view', 'lead.edit', 'lead.destroy']); + $leads = collect(['a', 'b', 'c'])->map(fn ($name) => Lead::create(['email' => "{$name}@example.com"])); + + $this->actingAs($admin, 'admin') + ->postJson(self::BASE . '/bulk-status', ['uuids' => [$leads[0]->uuid, $leads[1]->uuid], 'status' => 'contacted']) + ->assertOk() + ->assertJsonPath('data.affected', 2); + $this->assertSame(2, Lead::where('status', 'contacted')->count()); + + $this->actingAs($admin, 'admin') + ->postJson(self::BASE . '/bulk-status', ['uuids' => [$leads[2]->uuid], 'status' => 'bogus']) + ->assertStatus(422); + + $this->actingAs($admin, 'admin') + ->postJson(self::BASE . '/bulk-delete', ['uuids' => [$leads[0]->uuid, $leads[2]->uuid]]) + ->assertOk() + ->assertJsonPath('data.affected', 2); + $this->assertSame([$leads[1]->uuid], Lead::pluck('uuid')->all()); + + $this->actingAs($this->admin(['lead.view']), 'admin') + ->postJson(self::BASE . '/bulk-delete', ['uuids' => [$leads[1]->uuid]]) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php b/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php index 206250e..f4a0933 100644 --- a/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php +++ b/tests/Feature/Api/Admin/MarketingEmailOptOutTest.php @@ -86,4 +86,22 @@ class MarketingEmailOptOutTest extends TestCase Queue::assertNotPushed(SendMailCampaignRecipient::class); } + + public function test_campaign_with_template_uses_edited_message_when_given(): void + { + Queue::fake(); + $admin = $this->admin(); + $customer = Customer::factory()->create(['email_marketing' => 1]); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/mail-campaigns', [ + 'template_id' => $this->template(null)->id, + 'message' => '

Nội dung đã sửa

', + 'recipient_type' => 'selected', + 'uuids' => [$customer->uuid], + ]) + ->assertOk(); + + $this->assertSame('

Nội dung đã sửa

', \App\Models\MailCampaign::latest('id')->first()->message); + } } diff --git a/tests/Feature/Mail/HostingAccountCreatedInvoiceTest.php b/tests/Feature/Mail/HostingAccountCreatedInvoiceTest.php index 2753245..0975ca6 100644 --- a/tests/Feature/Mail/HostingAccountCreatedInvoiceTest.php +++ b/tests/Feature/Mail/HostingAccountCreatedInvoiceTest.php @@ -63,8 +63,8 @@ class HostingAccountCreatedInvoiceTest extends TestCase $this->seed(\Database\Seeders\TemplateSeeder::class); $subject = fn (array $data) => (new HostingAccountCreated($data + ['server_name' => 'HOST-01']))->envelope()->subject; - $this->assertSame('V/v: '.\App\Support\MailGlobals::values()['company_name'].' gửi thông tin truy cập - HOST-01 - Nguyen Van A', $subject(['customer_name' => 'Nguyen Van A'])); - $this->assertStringEndsWith(' - Cong ty ABC', $subject(['customer_name' => ' ', 'customer_company' => 'Cong ty ABC'])); - $this->assertStringEndsWith(' - Công ty', $subject([])); + $this->assertSame('V/v: Thế Giới Số gửi thông tin truy cập Hosting - HOST-01 | Nguyen Van A', $subject(['customer_name' => 'Nguyen Van A'])); + $this->assertStringEndsWith(' | Cong ty ABC', $subject(['customer_name' => ' ', 'customer_company' => 'Cong ty ABC'])); + $this->assertStringEndsWith(' | Công ty', $subject([])); } } diff --git a/tests/Feature/MailPlaceholdersConditionalTest.php b/tests/Feature/MailPlaceholdersConditionalTest.php new file mode 100644 index 0000000..97d2bb7 --- /dev/null +++ b/tests/Feature/MailPlaceholdersConditionalTest.php @@ -0,0 +1,23 @@ +KYC{{else}}none{{/if}}B'; + + $this->assertSame('AnoneB', MailPlaceholders::fill($content, ['kyc_url' => ' '])); + $this->assertSame('AnoneB', MailPlaceholders::fill($content, [])); + $this->assertSame('AKYCB', MailPlaceholders::fill($content, ['kyc_url' => 'https://x.vn/k?a=1&b=2'])); + } + + public function test_extract_ignores_conditional_keywords(): void + { + $this->assertSame(['kyc_url'], MailPlaceholders::extract('{{#if kyc_url}}{{kyc_url}}{{else}}x{{/if}}')); + } +} diff --git a/tests/Feature/TemplateSeederTest.php b/tests/Feature/TemplateSeederTest.php index 09aeec6..7d0a543 100644 --- a/tests/Feature/TemplateSeederTest.php +++ b/tests/Feature/TemplateSeederTest.php @@ -8,6 +8,7 @@ use App\Services\Email\EmailTemplateService; use App\Support\EmailScenarioRegistry; use App\Support\MailPlaceholders; use Database\Seeders\EmailTemplates\TemplateVariables; +use Database\Seeders\EmailTemplates\ServiceLifecycleTemplates; use Database\Seeders\TemplateSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Tests\TestCase; @@ -39,6 +40,22 @@ class TemplateSeederTest extends TestCase } } + public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void + { + $items = ServiceLifecycleTemplates::all(); + $this->assertCount(27, $items); + + $this->seed(TemplateSeeder::class); + $this->seed(TemplateSeeder::class); + + foreach ($items as $item) { + $this->assertSame(1, Template::where('name', $item['name'])->count(), $item['name']); + $template = Template::where('name', $item['name'])->first(); + $this->assertTrue($template->isMarketing(), $item['name']); + $this->assertStringContainsString('class="Footer"', $template->message, $item['name']); + } + } + public function test_every_placeholder_has_a_variable_definition(): void { foreach ([...TemplateSeeder::templates(), ...TemplateSeeder::legacyTemplates()] as $item) { diff --git a/tests/Unit/AdminProductResourceTest.php b/tests/Unit/AdminProductResourceTest.php index 8a12e77..62b5357 100644 --- a/tests/Unit/AdminProductResourceTest.php +++ b/tests/Unit/AdminProductResourceTest.php @@ -61,4 +61,23 @@ class AdminProductResourceTest extends TestCase $this->assertSame('onetime', $data['billing_type']); $this->assertSame('15000000.00', $data['price_onetime']); } + + public function test_custom_prices_serialize_as_json_object_even_when_empty(): void + { + $product = new Product(['name' => 'No custom cycles', 'is_active' => true, 'setup_fee' => 0]); + $product->id = 6; + $product->setRelation('pricings', new Collection([ + new ProductPricings(['billing_cycle' => 'annually', 'price' => 1000000, 'currency' => 'VND']), + new ProductPricings(['billing_cycle' => 'months_60', 'price' => 5000000, 'currency' => 'VND']), + ])); + + $withCustom = json_decode((new ProductResource($product))->toResponse(request())->getContent()); + $this->assertIsObject($withCustom->data->custom_prices ?? $withCustom->custom_prices); + + $product->setRelation('pricings', new Collection([ + new ProductPricings(['billing_cycle' => 'annually', 'price' => 1000000, 'currency' => 'VND']), + ])); + $empty = json_decode((new ProductResource($product))->toResponse(request())->getContent()); + $this->assertIsObject($empty->data->custom_prices ?? $empty->custom_prices); + } }