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:
baodev0
2026-10-30 18:36:41 +07:00
co-authored by Claude Sonnet 5.5
parent 047c23beff
commit 55a834a2f8
53 changed files with 1463 additions and 119 deletions
@@ -74,7 +74,7 @@ trait HandlesCampaignBulkActions
$failedLogs = Mail::where('campaign_id', $campaign->id)
->where('status', array_search('failed', Mail::STATUS_MAP, true))
->lockForUpdate()
->get(['id', 'to', 'customer_id']);
->get(['id', 'to', 'customer_id', 'service_id']);
if ($failedLogs->isEmpty()) {
return;
}
@@ -87,7 +87,7 @@ trait HandlesCampaignBulkActions
])->save();
foreach ($failedLogs as $log) {
SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to)->afterCommit();
SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to, $log->service_id)->afterCommit();
}
$retried += $failedLogs->count();
});
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\MailCampaign\Concerns;
use App\Models\Customer;
use App\Models\MailCampaign;
use App\Models\Service;
use App\Support\RenewalCampaign;
use App\Support\RenewalQuote;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
trait HandlesRenewalCampaigns
{
public function renewalServices(Request $request): JsonResponse
{
$data = $request->validate([
'uuids' => 'required|array|min:1|max:200',
'uuids.*' => 'string|exists:customers,uuid',
'days' => 'nullable|integer|min:1|max:365',
]);
$customerIds = Customer::whereIn('uuid', $data['uuids'])->pluck('id')->all();
$today = now()->startOfDay();
$rows = RenewalQuote::dueServices($customerIds, (int) ($data['days'] ?? 30))
->map(function (Service $service) use ($today) {
$quote = RenewalQuote::forService($service);
return $quote ? [
...$quote,
'customer_uuid' => $service->customer->uuid,
'customer_name' => $service->customer->fullname,
'customer_email' => $service->customer->email,
'days_left' => (int) $today->diffInDays($service->expires_at->copy()->startOfDay(), false),
] : null;
})
->filter()
->values();
return response()->json(['error' => false, 'data' => $rows]);
}
/**
* @param array<int, array{service_id: int, months: int}> $selected
* @return array<int, array{customer_id: int, email: string, service_id: int}>
*/
private function renewalJobs(MailCampaign $campaign, array $selected): array
{
if (! RenewalCampaign::isRenewalTemplate($campaign)) {
throw ValidationException::withMessages(['renewal_services' => 'Template đã chọn không phải mẫu nhắc gia hạn.']);
}
$services = Service::with('customer:id,email')
->whereIn('id', array_column($selected, 'service_id'))
->get();
return $services
->filter(fn (Service $service) => $service->customer && trim((string) $service->customer->email) !== '')
->map(fn (Service $service) => [
'customer_id' => $service->customer->id,
'email' => trim($service->customer->email),
'service_id' => $service->id,
])
->values()
->all();
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\MailCampaign;
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions;
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesRenewalCampaigns;
use App\Http\Controllers\Controller;
use App\Jobs\Mail\SendMailCampaignRecipient;
use App\Models\Customer;
@@ -16,6 +17,7 @@ use App\Support\AutoEmailGate;
use App\Support\MailAttachments;
use App\Support\MailPlaceholders;
use App\Support\MailSettings;
use App\Support\RenewalCampaign;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -28,6 +30,7 @@ use Illuminate\Validation\ValidationException;
final class MailCampaignController extends Controller
{
use HandlesCampaignBulkActions;
use HandlesRenewalCampaigns;
private const MAX_EMAILS = 1000;
@@ -253,6 +256,9 @@ final class MailCampaignController extends Controller
'emails' => 'nullable|array|max:' . self::MAX_EMAILS,
'emails.*' => self::EMAIL_RULE,
'name' => 'nullable|string|max:255',
'renewal_services' => 'nullable|array|max:500',
'renewal_services.*.service_id' => 'required|integer|exists:services,id',
'renewal_services.*.months' => 'required|integer|min:1|max:600',
'cc' => 'nullable|string|max:1000',
'bcc' => 'nullable|string|max:1000',
...MailAttachments::rules(),
@@ -263,12 +269,25 @@ final class MailCampaignController extends Controller
$campaign = $this->draftCampaign($data);
$marketingOnly = $campaign->isMarketing();
$recipients = $data['recipient_type'] === 'all'
? $this->allCustomerRecipients($marketingOnly)
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
$optedOut = $recipients['opted_out'];
$recipients = $recipients['recipients'];
$renewalJobs = [];
$optedOut = 0;
if (!empty($data['renewal_services'])) {
$renewalJobs = $this->renewalJobs($campaign, $data['renewal_services']);
$campaign->renewal_services = array_values(array_filter(
$data['renewal_services'],
fn (array $item) => in_array((int) $item['service_id'], array_column($renewalJobs, 'service_id'), true),
));
$campaign->variables = RenewalCampaign::withoutServiceValues($campaign->variables);
$data['recipient_type'] = 'selected';
$recipients = $renewalJobs;
} else {
$marketingOnly = $campaign->isMarketing();
$resolved = $data['recipient_type'] === 'all'
? $this->allCustomerRecipients($marketingOnly)
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
$optedOut = $resolved['opted_out'];
$recipients = $resolved['recipients'];
}
if ($recipients === []) {
return response()->json([
@@ -299,8 +318,14 @@ final class MailCampaignController extends Controller
]);
}
foreach ($recipients as $email => $customerId) {
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
if ($renewalJobs !== []) {
foreach ($renewalJobs as $job) {
SendMailCampaignRecipient::dispatch($campaign->id, $job['customer_id'], $job['email'], $job['service_id']);
}
} else {
foreach ($recipients as $email => $customerId) {
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
}
}
return response()->json([