feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ
- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services) - Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết - Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ - Đánh dấu mẫu email dịch vụ là non-marketing Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
047c23beff
commit
55a834a2f8
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class CustomerIdentity
|
||||
{
|
||||
public const SIDES = ['front' => 'front_identity_number', 'back' => 'back_identity_number'];
|
||||
|
||||
public static function payload(Customer $customer): array
|
||||
{
|
||||
return [
|
||||
'identity_number' => $customer->identity_number,
|
||||
'front_url' => self::signedUrl($customer, 'front'),
|
||||
'back_url' => self::signedUrl($customer, 'back'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function response(Customer $customer, string $side)
|
||||
{
|
||||
$disk = self::diskFor($customer->{self::SIDES[$side]});
|
||||
abort_unless($disk, 404);
|
||||
|
||||
return Storage::disk($disk)->response($customer->{self::SIDES[$side]}, null, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
private static function signedUrl(Customer $customer, string $side): ?string
|
||||
{
|
||||
if (! self::diskFor($customer->{self::SIDES[$side]})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'admin.customers.identity-image',
|
||||
now()->addHour(),
|
||||
['customer' => $customer->uuid, 'side' => $side],
|
||||
);
|
||||
}
|
||||
|
||||
private static function diskFor(?string $path): ?string
|
||||
{
|
||||
if (! is_string($path) || $path === '' || str_contains($path, '..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['local', 'public'] as $disk) {
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
return $disk;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Customer;
|
||||
use App\Models\MailCampaign;
|
||||
use App\Models\Service;
|
||||
|
||||
class RenewalCampaign
|
||||
{
|
||||
private const SERVICE_KEYS = ['service_name', 'billing_cycle', 'period_start', 'period_end', 'expires_date', 'total_amount', 'vat_rate', 'vat_amount', 'payment_content', 'bank_qr_url'];
|
||||
|
||||
public static function withoutServiceValues(?array $variables): ?array
|
||||
{
|
||||
$kept = array_diff_key($variables ?? [], array_flip(self::SERVICE_KEYS));
|
||||
|
||||
return $kept ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function sampleValues(MailCampaign $campaign): array
|
||||
{
|
||||
$first = $campaign->renewal_services[0]['service_id'] ?? null;
|
||||
$service = $first ? Service::with('customer')->find($first) : null;
|
||||
|
||||
return $service?->customer ? (self::valuesFor($campaign, $service->customer, $service->id) ?? []) : [];
|
||||
}
|
||||
|
||||
public static function isRenewalTemplate(MailCampaign $campaign): bool
|
||||
{
|
||||
$keys = MailPlaceholders::extract($campaign->subject, $campaign->message);
|
||||
|
||||
return in_array('period_end', $keys, true) && in_array('bank_qr_url', $keys, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null null khi dịch vụ không còn thuộc khách hoặc không thể gia hạn
|
||||
*/
|
||||
public static function valuesFor(MailCampaign $campaign, Customer $customer, int $serviceId): ?array
|
||||
{
|
||||
$months = collect($campaign->renewal_services)->firstWhere('service_id', $serviceId)['months'] ?? null;
|
||||
$service = Service::with(['orderItem', 'product', 'customProduct'])
|
||||
->whereKey($serviceId)
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
$quote = $service ? RenewalQuote::forService($service, $months) : null;
|
||||
if (! $quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($quote['service_id'], $quote['cycles'], $quote['months']);
|
||||
|
||||
$bank = Bank::where('account_no', $campaign->variables['bank_account_no'] ?? '')->first();
|
||||
|
||||
return $bank
|
||||
? [...$quote, 'bank_qr_url' => VietQr::url($bank, $quote['total_amount'], $quote['payment_content'])]
|
||||
: $quote;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RenewalQuote
|
||||
{
|
||||
private const TYPE_LABELS = [
|
||||
'domain' => 'tên miền',
|
||||
'hosting' => 'hosting',
|
||||
'vps' => 'Cloud VPS',
|
||||
'ssl' => 'SSL',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, int> $customerIds
|
||||
* @return Collection<int, Service>
|
||||
*/
|
||||
public static function dueServices(array $customerIds, int $days): Collection
|
||||
{
|
||||
return Service::query()
|
||||
->with(['customer:id,uuid,fullname,email', 'orderItem', 'product', 'customProduct'])
|
||||
->whereIn('customer_id', $customerIds)
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now()->addDays($days)->endOfDay())
|
||||
->orderBy('expires_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
public static function forService(Service $service, ?int $months = null): ?array
|
||||
{
|
||||
$orderItem = $service->orderItem;
|
||||
$product = $service->customProduct ?? $service->product;
|
||||
if (! $orderItem || ! $product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseMonths = OrderHelper::cycleToMonths($orderItem->billing_cycle);
|
||||
$options = RenewalPricing::options($product);
|
||||
$base = RenewalPricing::option($product, $baseMonths);
|
||||
if (! $base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = VatSettings::rateFor($service->product?->product_type);
|
||||
$start = ($service->expires_at ?? now())->copy();
|
||||
$target = $service->server_name ?: $product->name;
|
||||
$label = self::TYPE_LABELS[$service->type] ?? null;
|
||||
$terms = fn (array $option) => self::terms($service, $option, $baseMonths, $start, $rate);
|
||||
|
||||
return [
|
||||
'service_id' => $service->id,
|
||||
'service_name' => 'Gia hạn ' . ($label ? $label . ' - ' : '') . $target,
|
||||
'period_start' => $start->format('d/m/Y'),
|
||||
'expires_date' => $start->format('d/m/Y'),
|
||||
'vat_rate' => rtrim(rtrim(number_format($rate, 2, '.', ''), '0'), '.'),
|
||||
'payment_content' => Str::limit('GIA HAN ' . Str::upper(Str::ascii($target)), 50, ''),
|
||||
...$terms(RenewalPricing::option($product, (int) $months) ?? $base),
|
||||
'cycles' => array_map($terms, $options),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{months: int, price: int} $option
|
||||
* @return array{months: int, billing_cycle: string, period_end: string, total_amount: string, vat_amount: string}
|
||||
*/
|
||||
private static function terms(Service $service, array $option, int $baseMonths, Carbon $start, float $rate): array
|
||||
{
|
||||
$months = $option['months'];
|
||||
$amount = $option['price'] + RenewalPricing::addonTotal($service, $months / $baseMonths);
|
||||
|
||||
return [
|
||||
'months' => $months,
|
||||
'billing_cycle' => sprintf('%02d %s', $months % 12 === 0 ? $months / 12 : $months, $months % 12 === 0 ? 'năm' : 'tháng'),
|
||||
'period_end' => $start->copy()->addMonthsNoOverflow($months)->subDay()->format('d/m/Y'),
|
||||
'total_amount' => number_format($amount, 0, ',', '.'),
|
||||
'vat_amount' => number_format((int) round($amount * $rate / (100 + $rate)), 0, ',', '.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Services\ActivityLogger;
|
||||
|
||||
class ServiceProvisionLog
|
||||
{
|
||||
public const EVENTS = [
|
||||
'provision_requested',
|
||||
'provision_started',
|
||||
'provision_step',
|
||||
'provision_completed',
|
||||
'provision_failed',
|
||||
];
|
||||
|
||||
public static function record(?Service $service, string $event, string $description, array $values = []): void
|
||||
{
|
||||
if (! $service) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(ActivityLogger::class)->log($event, 'service', $description, $service, [], $values);
|
||||
}
|
||||
|
||||
public static function forOrderItem(int $orderItemId, string $event, string $description, array $values = []): void
|
||||
{
|
||||
self::record(Service::where('order_item_id', $orderItemId)->first(), $event, $description, $values);
|
||||
}
|
||||
|
||||
public static function forVm(int $moduleVmwareId, string $event, string $description, array $values = []): void
|
||||
{
|
||||
self::record(Service::where('vm_id', $moduleVmwareId)->first(), $event, $description, $values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Bank;
|
||||
|
||||
class VietQr
|
||||
{
|
||||
public static function url(Bank $bank, string $amount, string $content): string
|
||||
{
|
||||
$params = array_filter([
|
||||
'amount' => preg_replace('/\D/', '', $amount),
|
||||
'addInfo' => trim($content),
|
||||
'accountName' => $bank->account_name,
|
||||
], fn (?string $value) => $value !== null && $value !== '');
|
||||
|
||||
return "https://img.vietqr.io/image/{$bank->bank_code}-{$bank->account_no}-compact2.png?" . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user