Teren-app/app/Http/Controllers/Admin/PackageController.php
2025-11-06 22:30:17 +01:00

534 lines
21 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 ?? ''),
];
// Include contract.meta as flattened key-value pairs
if (is_array($c->meta) && ! empty($c->meta)) {
$vars['contract']['meta'] = $this->flattenMeta($c->meta);
}
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 ?? ''),
];
// Include contract.meta as flattened key-value pairs
if (is_array($c->meta) && ! empty($c->meta)) {
$vars['contract']['meta'] = $this->flattenMeta($c->meta);
}
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');
}
/**
* Flatten nested meta structure into dot-notation key-value pairs.
* Extracts 'value' from objects with {title, value, type} structure.
* Also creates direct access aliases for nested fields (skipping numeric keys).
*/
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
foreach ($meta as $key => $value) {
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
if (is_array($value)) {
// Check if it's a structured meta entry with 'value' field
if (isset($value['value'])) {
$result[$newKey] = $value['value'];
// If parent key is numeric, also create direct alias without the number
if ($prefix !== '' && is_numeric($key)) {
$result[$key] = $value['value'];
}
} else {
// Recursively flatten nested arrays
$nested = $this->flattenMeta($value, $newKey);
$result = array_merge($result, $nested);
// If current key is numeric, also flatten without it for easier access
if (is_numeric($key)) {
$directNested = $this->flattenMeta($value, $prefix);
foreach ($directNested as $dk => $dv) {
// Only add if not already set (prefer first occurrence)
if (! isset($result[$dk])) {
$result[$dk] = $dv;
}
}
}
}
} else {
$result[$newKey] = $value;
}
}
return $result;
}
}