feat(portal): lead module, WHMCS sync report/contacts/mapping, service lifecycle email templates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3c7710f1de
commit
a443ec3bf6
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Whmcs;
|
||||
|
||||
use App\Integrations\Whmcs\Support\CustomerIdResolver;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
class ExportMappingCommand extends Command
|
||||
{
|
||||
protected $signature = 'whmcs:export-mapping {--path= : Output .xlsx path (default storage/app/whmcs-mapping-<time>.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<int, array{0: string, 1: int, 2: ?string, 3: ?string, 4: string}>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Whmcs;
|
||||
|
||||
use App\Integrations\Whmcs\Support\CustomerIdResolver;
|
||||
use App\Integrations\Whmcs\Support\SyncReport;
|
||||
use App\Integrations\Whmcs\Writers\LeadWriter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SyncContactsCommand extends Command
|
||||
{
|
||||
protected $signature = 'whmcs:sync-contacts';
|
||||
|
||||
protected $description = 'Import WHMCS contacts (tblcontacts) into the Lead module, merging duplicate emails';
|
||||
|
||||
public function handle(CustomerIdResolver $customers, LeadWriter $writer): int
|
||||
{
|
||||
$contacts = DB::connection('whmcs')->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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, int>|null whmcs client id => local customer id */
|
||||
private static ?array $map = null;
|
||||
|
||||
/** @var array<int, true>|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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Container\Attributes\Singleton;
|
||||
|
||||
#[Singleton]
|
||||
class SyncReport
|
||||
{
|
||||
/** @var array<int, array{entity: string, ref: string, whmcs_id: string, issue: string, detail: string}> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SyncedLegacyIds
|
||||
{
|
||||
/**
|
||||
* @return array<int|string, int> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, int>|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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') . '.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\Support\SyncReport;
|
||||
use App\Support\PhoneNumber;
|
||||
use Modules\Lead\App\Models\Lead;
|
||||
|
||||
class LeadWriter
|
||||
{
|
||||
private const FLAGS = [
|
||||
'receives_invoice_emails' => '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)) : '') . '.');
|
||||
}
|
||||
}
|
||||
@@ -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<Service> $services
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DomainServiceSummary
|
||||
{
|
||||
public const DUE_WITHIN_DAYS = 30;
|
||||
public const RECENT_RENEWAL_DAYS = 30;
|
||||
private const YEAR_CYCLES = [12, 24, 36];
|
||||
|
||||
/**
|
||||
* @param Collection<int, \App\Models\DnsDomain> $domains
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<string, string|null> $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];
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class PhoneNumber
|
||||
{
|
||||
public static function normalize(?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user