218 lines
9.1 KiB
PHP
218 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Contract;
|
|
use App\Models\Package;
|
|
use App\Models\PackageItem;
|
|
use App\Models\SmsProfile;
|
|
use App\Models\SmsSender;
|
|
use App\Models\SmsTemplate;
|
|
use App\Services\Sms\SmsService;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
class PackageItemSmsJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public function __construct(public int $packageItemId)
|
|
{
|
|
$this->onQueue('sms');
|
|
}
|
|
|
|
public function handle(SmsService $sms): void
|
|
{
|
|
/** @var PackageItem|null $item */
|
|
$item = PackageItem::query()->find($this->packageItemId);
|
|
if (! $item) {
|
|
return;
|
|
}
|
|
|
|
/** @var Package $package */
|
|
$package = $item->package;
|
|
if (! $package || $package->status === Package::STATUS_CANCELED) {
|
|
return; // canceled or missing
|
|
}
|
|
|
|
// Skip if already finalized to avoid double counting on retries
|
|
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
|
|
return;
|
|
}
|
|
// Mark processing on first entry
|
|
if ($item->status === 'queued') {
|
|
$item->status = 'processing';
|
|
$item->save();
|
|
$package->increment('processing_count');
|
|
}
|
|
|
|
$payload = (array) $item->payload_json;
|
|
$target = (array) $item->target_json;
|
|
|
|
$profileId = $payload['profile_id'] ?? null;
|
|
$senderId = $payload['sender_id'] ?? null;
|
|
$templateId = $payload['template_id'] ?? null;
|
|
$deliveryReport = (bool) ($payload['delivery_report'] ?? false);
|
|
$variables = (array) ($payload['variables'] ?? []);
|
|
// Enrich variables with contract/account context when available (contracts-based packages)
|
|
if (! empty($target['contract_id'])) {
|
|
$contract = Contract::query()->with('account.type')->find($target['contract_id']);
|
|
if ($contract) {
|
|
$variables['contract'] = [
|
|
'id' => $contract->id,
|
|
'uuid' => $contract->uuid,
|
|
'reference' => $contract->reference,
|
|
'start_date' => (string) ($contract->start_date ?? ''),
|
|
'end_date' => (string) ($contract->end_date ?? ''),
|
|
];
|
|
if ($contract->account) {
|
|
// Preserve raw values and provide EU-formatted versions for SMS rendering
|
|
$initialRaw = (string) $contract->account->initial_amount;
|
|
$balanceRaw = (string) $contract->account->balance_amount;
|
|
$variables['account'] = [
|
|
'id' => $contract->account->id,
|
|
'reference' => $contract->account->reference,
|
|
// Override placeholders with EU formatted values for SMS
|
|
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
|
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
|
// Expose raw values too in case templates need them explicitly
|
|
'initial_amount_raw' => $initialRaw,
|
|
'balance_amount_raw' => $balanceRaw,
|
|
'type' => $contract->account->type?->name,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
$bodyOverride = isset($payload['body']) ? trim((string) $payload['body']) : null;
|
|
if ($bodyOverride === '') {
|
|
$bodyOverride = null;
|
|
}
|
|
|
|
/** @var SmsProfile|null $profile */
|
|
$profile = $profileId ? SmsProfile::find($profileId) : null;
|
|
/** @var SmsSender|null $sender */
|
|
$sender = $senderId ? SmsSender::find($senderId) : null;
|
|
/** @var SmsTemplate|null $template */
|
|
$template = $templateId ? SmsTemplate::find($templateId) : null;
|
|
|
|
$to = $target['number'] ?? null;
|
|
if (! is_string($to) || $to === '') {
|
|
$item->status = 'failed';
|
|
$item->last_error = 'Missing recipient number.';
|
|
$item->save();
|
|
|
|
return;
|
|
}
|
|
|
|
// Compute throttle key
|
|
$scope = config('services.sms.throttle.scope', 'global');
|
|
$provider = config('services.sms.throttle.provider_key', 'smsapi_si');
|
|
$allow = (int) config('services.sms.throttle.allow', 30);
|
|
$every = (int) config('services.sms.throttle.every', 60);
|
|
$jitter = (int) config('services.sms.throttle.jitter_seconds', 2);
|
|
$key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}";
|
|
|
|
// Throttle
|
|
$sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride) {
|
|
// Idempotency key (optional external use)
|
|
if (empty($item->idempotency_key)) {
|
|
$hash = sha1(implode('|', [
|
|
'sms', (string) ($profile?->id ?? ''), (string) ($sender?->id ?? ''), (string) ($template?->id ?? ''), $to, (string) ($bodyOverride ?? ''), json_encode($variables),
|
|
]));
|
|
$item->idempotency_key = "pkgitem:{$item->id}:{$hash}";
|
|
$item->save();
|
|
}
|
|
|
|
// Decide whether to use template or raw content
|
|
$useTemplate = false;
|
|
if ($template) {
|
|
if ($bodyOverride) {
|
|
// If custom body is provided but template does not allow it, force template
|
|
$useTemplate = ! (bool) ($template->allow_custom_body ?? false);
|
|
} else {
|
|
// No custom body provided -> use template
|
|
$useTemplate = true;
|
|
}
|
|
}
|
|
|
|
if ($useTemplate) {
|
|
$log = $sms->sendFromTemplate(
|
|
template: $template,
|
|
to: $to,
|
|
variables: $variables,
|
|
profile: $profile,
|
|
sender: $sender,
|
|
countryCode: null,
|
|
deliveryReport: $deliveryReport,
|
|
clientReference: "pkg:{$item->package_id}:item:{$item->id}",
|
|
);
|
|
} else {
|
|
// Either explicit body override or no template
|
|
$effectiveBody = (string) ($bodyOverride ?? '');
|
|
if ($effectiveBody === '') {
|
|
// Avoid provider error for empty body
|
|
throw new \RuntimeException('Empty SMS body and no template provided.');
|
|
}
|
|
if (! $profile && $template) {
|
|
$profile = $template->defaultProfile;
|
|
}
|
|
if (! $profile) {
|
|
throw new \RuntimeException('Missing SMS profile for raw send.');
|
|
}
|
|
$log = $sms->sendRaw(
|
|
profile: $profile,
|
|
to: $to,
|
|
content: $effectiveBody,
|
|
sender: $sender,
|
|
countryCode: null,
|
|
deliveryReport: $deliveryReport,
|
|
clientReference: "pkg:{$item->package_id}:item:{$item->id}",
|
|
);
|
|
}
|
|
|
|
$newStatus = $log->status === 'sent' ? 'sent' : 'failed';
|
|
$item->status = $newStatus;
|
|
$item->provider_message_id = $log->provider_message_id;
|
|
$item->cost = $log->cost;
|
|
$item->currency = $log->currency;
|
|
// Persist useful result info including final rendered message for auditing
|
|
$result = $log->meta ?? [];
|
|
$result['message'] = $log->message ?? (($useTemplate && isset($template)) ? $sms->renderContent($template->content, $variables) : ($bodyOverride ?? null));
|
|
$result['template_id'] = $template?->id;
|
|
$result['render_source'] = $useTemplate ? 'template' : 'body';
|
|
$item->result_json = $result;
|
|
$item->last_error = $log->status === 'sent' ? null : ($log->meta['error_message'] ?? 'Failed');
|
|
$item->save();
|
|
|
|
// Update package counters atomically
|
|
if ($newStatus === 'sent') {
|
|
$package->increment('sent_count');
|
|
} else {
|
|
$package->increment('failed_count');
|
|
}
|
|
|
|
// If all items processed, finalize package
|
|
$package->refresh();
|
|
if (($package->sent_count + $package->failed_count) >= $package->total_items) {
|
|
$finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
|
|
$package->status = $finalStatus;
|
|
$package->finished_at = now();
|
|
$package->save();
|
|
}
|
|
};
|
|
|
|
try {
|
|
Redis::throttle($key)->allow($allow)->every($every)->then($sendClosure, function () use ($jitter) {
|
|
return $this->release(max(1, rand(1, $jitter)));
|
|
});
|
|
} catch (\Throwable $e) {
|
|
// Fallback to direct send when Redis unavailable (e.g., test environment)
|
|
$sendClosure();
|
|
}
|
|
}
|
|
}
|