Teren-app/app/Jobs/PackageItemSmsJob.php
2025-11-06 21:54:07 +01:00

238 lines
10 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\Bus\Batchable;
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, Batchable;
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::with(['action', 'decision'])->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, $target) {
// 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();
// Create activity if template has action_id and decision_id configured and SMS was sent successfully
if ($newStatus === 'sent' && $template && ($template->action_id || $template->decision_id)) {
if (! empty($target['contract_id'])) {
$contract = Contract::query()->with('clientCase')->find($target['contract_id']);
if ($contract && $contract->client_case_id) {
\App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => "SMS poslan na {$to}: {$result['message']}",
'created_at' => now(),
'updated_at' => now(),
]));
}
}
}
// 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();
}
}
}