483 lines
19 KiB
PHP
483 lines
19 KiB
PHP
<?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', 'content']);
|
|
$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 = $sms->renderContent($body, $vars);
|
|
} 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' => $sms->renderContent($body, $vars),
|
|
];
|
|
} 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'],
|
|
'start_date_from' => ['nullable', 'date'],
|
|
'start_date_to' => ['nullable', 'date'],
|
|
]);
|
|
|
|
$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);
|
|
}
|
|
|
|
// Date range filters for start_date
|
|
if ($startDateFrom = $request->input('start_date_from')) {
|
|
$query->where('contracts.start_date', '>=', $startDateFrom);
|
|
}
|
|
|
|
if ($startDateTo = $request->input('start_date_to')) {
|
|
$query->where('contracts.start_date', '<=', $startDateTo);
|
|
}
|
|
|
|
// 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,
|
|
'start_date' => $contract->start_date,
|
|
'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');
|
|
}
|
|
}
|