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:
baodev0
2026-10-21 16:55:19 +07:00
co-authored by Claude Sonnet 5.5
parent 3c7710f1de
commit a443ec3bf6
66 changed files with 2032 additions and 124 deletions
@@ -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),
+62 -20
View File
@@ -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)) : '') . '.');
}
}