Package system sms
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PersonPhoneType: string
|
||||
{
|
||||
case Mobile = 'mobile';
|
||||
case Landline = 'landline';
|
||||
case Voip = 'voip';
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StorePackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageRequest;
|
||||
use App\Jobs\PackageItemSmsJob;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$packages = Package::query()
|
||||
->latest('id')
|
||||
->paginate(20);
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
$senders = \App\Models\SmsSender::query()
|
||||
->where('active', true)
|
||||
->orderBy('sname')
|
||||
->get(['id', 'profile_id', 'sname', 'phone_number']);
|
||||
$templates = \App\Models\SmsTemplate::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
// Provide a lightweight list of recent clients with person names for filtering
|
||||
$clients = \App\Models\Client::query()
|
||||
->with(['person' => function ($q) {
|
||||
$q->select('id', 'uuid', 'full_name');
|
||||
}])
|
||||
->latest('id')
|
||||
->get(['id', 'uuid', 'person_id'])
|
||||
->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
'segments' => $segments,
|
||||
'clients' => $clients,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Package $package, SmsService $sms): Response
|
||||
{
|
||||
$items = $package->items()->latest('id')->paginate(25);
|
||||
|
||||
// Preload contracts/accounts for current page items to compute per-item previews
|
||||
$contractIds = collect($items->items())
|
||||
->map(fn ($it) => (array) ($it->target_json ?? []))
|
||||
->map(fn ($t) => $t['contract_id'] ?? null)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
$contracts = $contractIds->isNotEmpty()
|
||||
? Contract::query()->with('account.type')->whereIn('id', $contractIds)->get()->keyBy('id')
|
||||
: collect();
|
||||
|
||||
// Attach rendered_preview to each item
|
||||
$collection = collect($items->items());
|
||||
$collection = $collection->transform(function ($it) use ($sms, $contracts) {
|
||||
$payload = (array) ($it->payload_json ?? []);
|
||||
$tgt = (array) ($it->target_json ?? []);
|
||||
$vars = (array) ($payload['variables'] ?? []);
|
||||
if (! empty($tgt['contract_id']) && $contracts->has($tgt['contract_id'])) {
|
||||
$c = $contracts->get($tgt['contract_id']);
|
||||
$vars['contract'] = [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'reference' => $c->reference,
|
||||
'start_date' => (string) ($c->start_date ?? ''),
|
||||
'end_date' => (string) ($c->end_date ?? ''),
|
||||
];
|
||||
if ($c->account) {
|
||||
$initialRaw = (string) $c->account->initial_amount;
|
||||
$balanceRaw = (string) $c->account->balance_amount;
|
||||
$vars['account'] = [
|
||||
'id' => $c->account->id,
|
||||
'reference' => $c->account->reference,
|
||||
// Use EU formatted values for SMS previews
|
||||
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
||||
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
||||
// Also expose raw values
|
||||
'initial_amount_raw' => $initialRaw,
|
||||
'balance_amount_raw' => $balanceRaw,
|
||||
'type' => $c->account->type?->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer recorded message from result_json if available (sent items)
|
||||
$result = (array) ($it->result_json ?? []);
|
||||
$rendered = $result['message'] ?? null;
|
||||
if (! $rendered) {
|
||||
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
|
||||
if ($body !== '') {
|
||||
$rendered = $body;
|
||||
} elseif (! empty($payload['template_id'])) {
|
||||
$tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']);
|
||||
if ($tpl) {
|
||||
$rendered = $sms->renderContent($tpl->content, $vars);
|
||||
}
|
||||
}
|
||||
}
|
||||
$it->rendered_preview = $rendered;
|
||||
|
||||
return $it;
|
||||
});
|
||||
// Replace paginator collection
|
||||
if (method_exists($items, 'setCollection')) {
|
||||
$items->setCollection($collection);
|
||||
}
|
||||
|
||||
// Build a preview of message content from the first item (shared payload across package)
|
||||
$preview = null;
|
||||
$firstItem = $package->items()->oldest('id')->first();
|
||||
if ($firstItem) {
|
||||
$payload = (array) ($firstItem->payload_json ?? []);
|
||||
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
|
||||
// Enrich variables with contract/account for preview if available
|
||||
$vars = (array) ($payload['variables'] ?? []);
|
||||
$tgt = (array) ($firstItem->target_json ?? []);
|
||||
if (! empty($tgt['contract_id'])) {
|
||||
$c = Contract::query()->with('account.type')->find($tgt['contract_id']);
|
||||
if ($c) {
|
||||
$vars['contract'] = [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'reference' => $c->reference,
|
||||
'start_date' => (string) ($c->start_date ?? ''),
|
||||
'end_date' => (string) ($c->end_date ?? ''),
|
||||
];
|
||||
if ($c->account) {
|
||||
$initialRaw = (string) $c->account->initial_amount;
|
||||
$balanceRaw = (string) $c->account->balance_amount;
|
||||
$vars['account'] = [
|
||||
'id' => $c->account->id,
|
||||
'reference' => $c->account->reference,
|
||||
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
||||
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
||||
'initial_amount_raw' => $initialRaw,
|
||||
'balance_amount_raw' => $balanceRaw,
|
||||
'type' => $c->account->type?->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($body !== '') {
|
||||
$preview = [
|
||||
'source' => 'body',
|
||||
'content' => $body,
|
||||
];
|
||||
} elseif (! empty($payload['template_id'])) {
|
||||
/** @var SmsTemplate|null $tpl */
|
||||
$tpl = SmsTemplate::find((int) $payload['template_id']);
|
||||
if ($tpl) {
|
||||
$content = $sms->renderContent($tpl->content, $vars);
|
||||
$preview = [
|
||||
'source' => 'template',
|
||||
'template' => [
|
||||
'id' => $tpl->id,
|
||||
'name' => $tpl->name,
|
||||
],
|
||||
'content' => $content,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Packages/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
'preview' => $preview,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePackageRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$package = Package::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'type' => $data['type'],
|
||||
'status' => Package::STATUS_DRAFT,
|
||||
'name' => $data['name'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'meta' => $data['meta'] ?? [],
|
||||
'created_by' => optional($request->user())->id,
|
||||
]);
|
||||
|
||||
$items = collect($data['items'])
|
||||
->map(function (array $row) {
|
||||
return new PackageItem([
|
||||
'status' => 'queued',
|
||||
'target_json' => [
|
||||
'number' => (string) $row['number'],
|
||||
'phone_id' => $row['phone_id'] ?? null,
|
||||
],
|
||||
'payload_json' => $row['payload'] ?? [],
|
||||
]);
|
||||
});
|
||||
|
||||
$package->items()->saveMany($items);
|
||||
$package->total_items = $items->count();
|
||||
$package->save();
|
||||
|
||||
return back()->with('success', 'Package created');
|
||||
}
|
||||
|
||||
public function dispatch(Package $package): RedirectResponse
|
||||
{
|
||||
if (! in_array($package->status, [Package::STATUS_DRAFT, Package::STATUS_FAILED], true)) {
|
||||
return back()->with('error', 'Package not in a dispatchable state.');
|
||||
}
|
||||
|
||||
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
|
||||
return new PackageItemSmsJob($item->id);
|
||||
})->all();
|
||||
|
||||
if (empty($jobs)) {
|
||||
return back()->with('error', 'No items to dispatch.');
|
||||
}
|
||||
|
||||
$package->status = Package::STATUS_QUEUED;
|
||||
$package->save();
|
||||
|
||||
Bus::batch($jobs)
|
||||
->name('pkg:'.$package->id.' ('.$package->type.')')
|
||||
->then(function () use ($package) {
|
||||
// If finished counters not set by items (e.g., empty), finalize
|
||||
$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();
|
||||
} else {
|
||||
$package->status = Package::STATUS_RUNNING;
|
||||
$package->save();
|
||||
}
|
||||
})
|
||||
->onQueue('sms')
|
||||
->dispatch();
|
||||
|
||||
return back()->with('success', 'Package dispatched');
|
||||
}
|
||||
|
||||
public function cancel(Package $package): RedirectResponse
|
||||
{
|
||||
$package->status = Package::STATUS_CANCELED;
|
||||
$package->save();
|
||||
|
||||
return back()->with('success', 'Package canceled');
|
||||
}
|
||||
|
||||
/**
|
||||
* List contracts for a given segment and include selected phone per person.
|
||||
*/
|
||||
public function contracts(Request $request, PhoneSelector $selector): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$segmentId = (int) $request->input('segment_id');
|
||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
||||
|
||||
$query = Contract::query()
|
||||
->join('contract_segment', function ($j) use ($segmentId) {
|
||||
$j->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->with([
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client.person',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
$query->where(function ($w) use ($q) {
|
||||
$w->where('contracts.reference', 'ILIKE', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($clientId = $request->integer('client_id')) {
|
||||
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||
->where('client_cases.client_id', $clientId);
|
||||
}
|
||||
|
||||
// Optional phone filters
|
||||
if ($request->boolean('only_mobile') || $request->boolean('only_validated')) {
|
||||
$query->whereHas('clientCase.person.phones', function ($q) use ($request) {
|
||||
if ($request->boolean('only_mobile')) {
|
||||
$q->where('person_phones.phone_type', 'mobile');
|
||||
}
|
||||
if ($request->boolean('only_validated')) {
|
||||
$q->where('person_phones.validated', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->paginate($perPage);
|
||||
|
||||
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'case' => [
|
||||
'id' => $contract->clientCase?->id,
|
||||
'uuid' => $contract->clientCase?->uuid,
|
||||
],
|
||||
// Primer: the case person
|
||||
'person' => [
|
||||
'id' => $person?->id,
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
// Stranka: the client person
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
'uuid' => $contract->clientCase?->client?->uuid,
|
||||
'name' => $clientPerson->full_name,
|
||||
] : null,
|
||||
'selected_phone' => $phone ? [
|
||||
'id' => $phone->id,
|
||||
'number' => $phone->nu,
|
||||
'validated' => $phone->validated,
|
||||
'type' => $phone->phone_type?->value,
|
||||
] : null,
|
||||
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $contracts->currentPage(),
|
||||
'last_page' => $contracts->lastPage(),
|
||||
'per_page' => $contracts->perPage(),
|
||||
'total' => $contracts->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SMS package from a list of contracts by selecting recipient phones.
|
||||
*/
|
||||
public function storeFromContracts(StorePackageFromContractsRequest $request, PhoneSelector $selector): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
// Load contracts with people, phones and account (for template placeholders)
|
||||
$contracts = Contract::query()
|
||||
->with(['clientCase.person.phones', 'account.type'])
|
||||
->whereIn('id', $data['contract_ids'])
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
$seen = collect(); // de-dup by phone_id or number
|
||||
$skipped = 0;
|
||||
foreach ($contracts as $contract) {
|
||||
$person = $contract->clientCase?->person;
|
||||
if (! $person) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$selected = $selector->selectForPerson($person);
|
||||
/** @var ?\App\Models\Person\PersonPhone $phone */
|
||||
$phone = $selected['phone'];
|
||||
if (! $phone) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
|
||||
if ($seen->contains($key)) {
|
||||
// skip duplicates across multiple contracts/persons
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$seen->push($key);
|
||||
$items[] = [
|
||||
'number' => (string) $phone->nu,
|
||||
'phone_id' => $phone->id,
|
||||
'payload' => $data['payload'] ?? [],
|
||||
// Keep context for variable rendering during send
|
||||
'contract_id' => $contract->id,
|
||||
'account_id' => $contract->account?->id,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
return back()->with('error', 'No recipients found for selected contracts.');
|
||||
}
|
||||
|
||||
$package = Package::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'type' => $data['type'],
|
||||
'status' => Package::STATUS_DRAFT,
|
||||
'name' => $data['name'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'meta' => array_merge($data['meta'] ?? [], [
|
||||
'source' => 'contracts',
|
||||
'skipped' => $skipped,
|
||||
]),
|
||||
'created_by' => optional($request->user())->id,
|
||||
]);
|
||||
|
||||
$packageItems = collect($items)->map(function (array $row) {
|
||||
return new PackageItem([
|
||||
'status' => 'queued',
|
||||
'target_json' => [
|
||||
'number' => $row['number'],
|
||||
'phone_id' => $row['phone_id'],
|
||||
'contract_id' => $row['contract_id'] ?? null,
|
||||
'account_id' => $row['account_id'] ?? null,
|
||||
],
|
||||
'payload_json' => $row['payload'] ?? [],
|
||||
]);
|
||||
});
|
||||
|
||||
$package->items()->saveMany($packageItems);
|
||||
$package->total_items = $packageItems->count();
|
||||
$package->save();
|
||||
|
||||
return back()->with('success', 'Package created from contracts');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Exception;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -1693,6 +1694,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_templates,id'],
|
||||
'profile_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_profiles,id'],
|
||||
'sender_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_senders,id'],
|
||||
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
// Ensure the phone belongs to the person of this case
|
||||
@@ -1801,4 +1803,93 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return contracts for the given client case (for SMS dialog dropdown).
|
||||
*/
|
||||
public function listContracts(ClientCase $clientCase)
|
||||
{
|
||||
$contracts = $clientCase->contracts()
|
||||
->with('account.type')
|
||||
->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date')
|
||||
->latest('id')
|
||||
->get()
|
||||
->map(function ($c) {
|
||||
/** @var SmsService $sms */
|
||||
$sms = app(SmsService::class);
|
||||
$acc = $c->account;
|
||||
$initialRaw = $acc?->initial_amount !== null ? (string) $acc->initial_amount : null;
|
||||
$balanceRaw = $acc?->balance_amount !== null ? (string) $acc->balance_amount : null;
|
||||
|
||||
return [
|
||||
'uuid' => $c->uuid,
|
||||
'reference' => $c->reference,
|
||||
'active' => (bool) $c->active,
|
||||
'start_date' => (string) ($c->start_date ?? ''),
|
||||
'end_date' => (string) ($c->end_date ?? ''),
|
||||
'account' => $acc ? [
|
||||
'reference' => $acc->reference,
|
||||
'type' => $acc->type?->name,
|
||||
'initial_amount' => $initialRaw !== null ? $sms->formatAmountEu($initialRaw) : null,
|
||||
'balance_amount' => $balanceRaw !== null ? $sms->formatAmountEu($balanceRaw) : null,
|
||||
'initial_amount_raw' => $initialRaw,
|
||||
'balance_amount_raw' => $balanceRaw,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $contracts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an SMS template preview with optional contract/account placeholders filled.
|
||||
*/
|
||||
public function previewSms(ClientCase $clientCase, Request $request, SmsService $sms)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'template_id' => ['required', 'integer', 'exists:sms_templates,id'],
|
||||
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\SmsTemplate $template */
|
||||
$template = \App\Models\SmsTemplate::findOrFail((int) $validated['template_id']);
|
||||
|
||||
$vars = [];
|
||||
$contractUuid = $validated['contract_uuid'] ?? null;
|
||||
if ($contractUuid) {
|
||||
// Ensure the contract belongs to this client case
|
||||
$contract = $clientCase->contracts()->where('uuid', $contractUuid)
|
||||
->with('account.type')
|
||||
->first();
|
||||
if ($contract) {
|
||||
$vars['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) {
|
||||
$initialRaw = (string) $contract->account->initial_amount;
|
||||
$balanceRaw = (string) $contract->account->balance_amount;
|
||||
$vars['account'] = [
|
||||
'id' => $contract->account->id,
|
||||
'reference' => $contract->account->reference,
|
||||
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
||||
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
||||
'initial_amount_raw' => $initialRaw,
|
||||
'balance_amount_raw' => $balanceRaw,
|
||||
'type' => $contract->account->type?->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$content = $sms->renderContent($template->content, $vars);
|
||||
|
||||
return response()->json([
|
||||
'content' => $content,
|
||||
'variables' => $vars,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ public function createPhone(Person $person, Request $request)
|
||||
'country_code' => 'nullable|integer',
|
||||
'type_id' => 'required|integer|exists:phone_types,id',
|
||||
'description' => 'nullable|string|max:125',
|
||||
'validated' => 'sometimes|boolean',
|
||||
'phone_type' => 'nullable|in:mobile,landline,voip',
|
||||
]);
|
||||
|
||||
// Dedup: avoid duplicate phone per person by (nu, country_code)
|
||||
@@ -120,13 +122,7 @@ public function createPhone(Person $person, Request $request)
|
||||
'country_code' => $attributes['country_code'] ?? null,
|
||||
], $attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Phone added successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
|
||||
]);
|
||||
return back()->with('success', 'Phone added successfully');
|
||||
}
|
||||
|
||||
public function updatePhone(Person $person, int $phone_id, Request $request)
|
||||
@@ -136,19 +132,15 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
||||
'country_code' => 'nullable|integer',
|
||||
'type_id' => 'required|integer|exists:phone_types,id',
|
||||
'description' => 'nullable|string|max:125',
|
||||
'validated' => 'sometimes|boolean',
|
||||
'phone_type' => 'nullable|in:mobile,landline,voip',
|
||||
]);
|
||||
|
||||
$phone = $person->phones()->with(['type'])->findOrFail($phone_id);
|
||||
|
||||
$phone->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Phone updated successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'phone' => $phone,
|
||||
]);
|
||||
return back()->with('success', 'Phone updated successfully');
|
||||
}
|
||||
|
||||
public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||
@@ -156,11 +148,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||
$phone = $person->phones()->findOrFail($phone_id);
|
||||
$phone->delete(); // soft delete
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Phone deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
return back()->with('success', 'Phone deleted');
|
||||
}
|
||||
|
||||
public function createEmail(Person $person, Request $request)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePackageFromContractsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('manage-settings') ?? false;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'in:sms'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
|
||||
// Common payload for all items
|
||||
'payload' => ['required', 'array'],
|
||||
'payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||
'payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||
'payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
|
||||
'payload.delivery_report' => ['nullable', 'boolean'],
|
||||
'payload.variables' => ['nullable', 'array'],
|
||||
'payload.body' => ['nullable', 'string'],
|
||||
|
||||
// Source contracts to derive items from
|
||||
'contract_ids' => ['required', 'array', 'min:1'],
|
||||
'contract_ids.*' => ['integer', 'exists:contracts,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePackageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('manage-settings') ?? false;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'in:sms'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
|
||||
// items
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.number' => ['required', 'string'],
|
||||
'items.*.phone_id' => ['nullable', 'integer'],
|
||||
'items.*.payload' => ['nullable', 'array'],
|
||||
'items.*.payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||
'items.*.payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||
'items.*.payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
|
||||
'items.*.payload.delivery_report' => ['nullable', 'boolean'],
|
||||
'items.*.payload.variables' => ['nullable', 'array'],
|
||||
'items.*.payload.body' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-10
@@ -12,6 +12,7 @@
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class SendSmsJob implements ShouldQueue
|
||||
{
|
||||
@@ -49,16 +50,42 @@ public function handle(SmsService $sms): void
|
||||
/** @var SmsSender|null $sender */
|
||||
$sender = $this->senderId ? SmsSender::find($this->senderId) : null;
|
||||
|
||||
// Send and get log (handles queued->sent/failed transitions internally)
|
||||
$log = $sms->sendRaw(
|
||||
profile: $profile,
|
||||
to: $this->to,
|
||||
content: $this->content,
|
||||
sender: $sender,
|
||||
countryCode: $this->countryCode,
|
||||
deliveryReport: $this->deliveryReport,
|
||||
clientReference: $this->clientReference,
|
||||
);
|
||||
// Apply Redis throttle from config to avoid provider rate limits
|
||||
$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}";
|
||||
|
||||
$log = null;
|
||||
try {
|
||||
Redis::throttle($key)->allow($allow)->every($every)->then(function () use (&$log, $sms, $profile, $sender) {
|
||||
// Send and get log (handles queued->sent/failed transitions internally)
|
||||
$log = $sms->sendRaw(
|
||||
profile: $profile,
|
||||
to: $this->to,
|
||||
content: $this->content,
|
||||
sender: $sender,
|
||||
countryCode: $this->countryCode,
|
||||
deliveryReport: $this->deliveryReport,
|
||||
clientReference: $this->clientReference,
|
||||
);
|
||||
}, function () use ($jitter) {
|
||||
return $this->release(max(1, rand(1, $jitter)));
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if Redis is unavailable in test or local env
|
||||
$log = $sms->sendRaw(
|
||||
profile: $profile,
|
||||
to: $this->to,
|
||||
content: $this->content,
|
||||
sender: $sender,
|
||||
countryCode: $this->countryCode,
|
||||
deliveryReport: $this->deliveryReport,
|
||||
clientReference: $this->clientReference,
|
||||
);
|
||||
}
|
||||
// If invoked from the case UI with a selected template, create an Activity
|
||||
if ($this->templateId && $this->clientCaseId && $log) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Package extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'type', 'status', 'name', 'description', 'meta',
|
||||
'total_items', 'processing_count', 'sent_count', 'failed_count',
|
||||
'created_by', 'finished_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'meta' => 'array',
|
||||
'finished_at' => 'datetime',
|
||||
'total_items' => 'integer',
|
||||
'processing_count' => 'integer',
|
||||
'sent_count' => 'integer',
|
||||
'failed_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(PackageItem::class);
|
||||
}
|
||||
|
||||
public const TYPE_SMS = 'sms';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PackageItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'package_id', 'status', 'target_json', 'payload_json', 'attempts', 'last_error', 'result_json', 'provider_message_id', 'cost', 'currency', 'idempotency_key',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'target_json' => 'array',
|
||||
'payload_json' => 'array',
|
||||
'result_json' => 'array',
|
||||
'attempts' => 'integer',
|
||||
'cost' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function package()
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Models\Person;
|
||||
|
||||
use Blade;
|
||||
use App\Enums\PersonPhoneType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,6 +13,7 @@ class PersonPhone extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\Person/PersonPhoneFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use Searchable;
|
||||
use SoftDeletes;
|
||||
|
||||
@@ -22,25 +23,28 @@ class PersonPhone extends Model
|
||||
'type_id',
|
||||
'description',
|
||||
'person_id',
|
||||
'user_id'
|
||||
'user_id',
|
||||
'validated',
|
||||
'phone_type',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'person_id',
|
||||
'deleted'
|
||||
'deleted',
|
||||
];
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'nu' => $this->nu
|
||||
'nu' => $this->nu,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(){
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function (PersonPhone $personPhone) {
|
||||
if(!isset($personPhone->user_id)){
|
||||
if (! isset($personPhone->user_id)) {
|
||||
$personPhone->user_id = auth()->id();
|
||||
}
|
||||
});
|
||||
@@ -55,4 +59,12 @@ public function type(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Person\PhoneType::class, 'type_id');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'validated' => 'boolean',
|
||||
'phone_type' => PersonPhoneType::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Contact;
|
||||
|
||||
use App\Enums\PersonPhoneType;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonPhone;
|
||||
|
||||
class PhoneSelector
|
||||
{
|
||||
/**
|
||||
* Select the best phone for a person following priority rules.
|
||||
* Priority:
|
||||
* 1) validated mobile
|
||||
* 2) validated (any type)
|
||||
* 3) mobile (any validation)
|
||||
* 4) first active phone
|
||||
*
|
||||
* Returns an array shape: ['phone' => ?PersonPhone, 'reason' => ?string]
|
||||
*/
|
||||
public function selectForPerson(Person $person): array
|
||||
{
|
||||
// Load active phones only (Person relation already filters active=1)
|
||||
$phones = $person->phones;
|
||||
|
||||
if ($phones->isEmpty()) {
|
||||
return ['phone' => null, 'reason' => 'no_active_phones'];
|
||||
}
|
||||
|
||||
// 1) validated mobile
|
||||
$phone = $phones->first(function (PersonPhone $p) {
|
||||
return ($p->validated === true) && ($p->phone_type === PersonPhoneType::Mobile);
|
||||
});
|
||||
if ($phone) {
|
||||
return ['phone' => $phone, 'reason' => null];
|
||||
}
|
||||
|
||||
// 2) validated (any type)
|
||||
$phone = $phones->first(fn (PersonPhone $p) => $p->validated === true);
|
||||
if ($phone) {
|
||||
return ['phone' => $phone, 'reason' => null];
|
||||
}
|
||||
|
||||
// 3) mobile (any validation)
|
||||
$phone = $phones->first(fn (PersonPhone $p) => $p->phone_type === PersonPhoneType::Mobile);
|
||||
if ($phone) {
|
||||
return ['phone' => $phone, 'reason' => null];
|
||||
}
|
||||
|
||||
// 4) first active
|
||||
return ['phone' => $phones->first(), 'reason' => null];
|
||||
}
|
||||
}
|
||||
@@ -79,16 +79,52 @@ public function sendFromTemplate(SmsTemplate $template, string $to, array $varia
|
||||
return $log;
|
||||
}
|
||||
|
||||
protected function renderContent(string $content, array $vars): string
|
||||
public function renderContent(string $content, array $vars): string
|
||||
{
|
||||
// Simple token replacement: {token}
|
||||
return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars) {
|
||||
$key = $m[1];
|
||||
// Support {token} and {nested.keys} using dot-notation lookup
|
||||
$resolver = function (array $arr, string $path) {
|
||||
if (array_key_exists($path, $arr)) {
|
||||
return $arr[$path];
|
||||
}
|
||||
$segments = explode('.', $path);
|
||||
$cur = $arr;
|
||||
foreach ($segments as $seg) {
|
||||
if (is_array($cur) && array_key_exists($seg, $cur)) {
|
||||
$cur = $cur[$seg];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return array_key_exists($key, $vars) ? (string) $vars[$key] : $m[0];
|
||||
return $cur;
|
||||
};
|
||||
|
||||
return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars, $resolver) {
|
||||
$key = $m[1];
|
||||
$val = $resolver($vars, $key);
|
||||
|
||||
return $val !== null ? (string) $val : $m[0];
|
||||
}, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number to EU style: thousands separated by '.', decimals by ','.
|
||||
*/
|
||||
public function formatAmountEu(mixed $value, int $decimals = 2): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return number_format(0, $decimals, ',', '.');
|
||||
}
|
||||
$str = (string) $value;
|
||||
// Normalize possible EU-style input like "1.234,56" to standard for float casting
|
||||
if (str_contains($str, ',')) {
|
||||
$str = str_replace(['.', ','], ['', '.'], $str);
|
||||
}
|
||||
$num = (float) $str;
|
||||
|
||||
return number_format($num, $decimals, ',', '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current credit balance from provider.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user