Commit d760eb10 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(settlements): make the worklist teach the case it is showing

The screen listed what was wrong with an account and stopped there. The person
reading it is a receptionist mid-shift, not an accountant with time to work out
what "مستلزم محاسَب بأكثر من سعره" means or which of nine tools fixes it — and a
case nobody understands is a case nobody touches, which is money.

So the catalogue now carries the explanation with the label: what the case
means, why it happens, the numbered handling, and which tool does it. Selecting
a case in the rail shows all of that beside the accounts it filters to. Every
row opens its own evidence in place — the months nobody billed, the free-text
lines that are really product sales, where the player stands on his kit — so the
operator reads the account before deciding, without opening the wizard to find
out whether it is worth opening.

New case, highest severity: **لا يوجد له اشتراك مسجَّل**. An active paying player
with invoice history and no enrolment row at all. Renewal billing reads
`enrollments` and nothing else, so these players are not billed late — they are
not billed at all, and no other screen in the system lists them. That is what
made OC-Sport's 95 lost accounts invisible for a month after a programme delete
took their enrolments. The row reads the programme off their last invoice line,
because the enrolment that knew it is gone and that name is what an operator
needs to put them back.

Two things the rebuild fixes on the way past:

- The scan no longer passes `only` to the scanner. Filtering there dropped the
  other cases from the result, so the rail beside a selected case read zero
  everywhere — which an operator reads as "those are fixed", not "those are
  hidden".
- `expanded` is `#[Locked]`. It names a row whose detail is that participant's
  own money; settable from the browser it is an id to aim somewhere else.

Verified against the restored tenant: 496 tests, 387 pass / 109 skip on Postgres
and 313 / 183 on SQLite, and every one of the first 40 flagged accounts renders
its detail block. Rendered and inspected at 390px, 820px and 1360px.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent a6451e4e
......@@ -5,18 +5,24 @@
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Shared\Support\ArabicSearch;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Every account that needs a human decision, worst first.
* Every account that needs a human decision, worst first — and what to do
* about each one.
*
* The alternative — and what actually happened on the first academy to go live
* — is that nobody knows a file is wrong until a parent argues at the desk. By
* then the money is months old and the person who took it has gone home. This
* screen reads the same books the roster reads and says: these are the players
* whose file does not add up, and this is why.
* whose file does not add up, this is why, and this is how it is handled.
*
* The guidance is on the screen rather than in a manual because the operator
* here is a receptionist mid-shift, not an accountant with time to read. A case
* they do not understand is a case they skip, and a skipped case is money.
*
* Read-only. Every row links into the settlement wizard, where a person decides
* what actually happened before anything is written.
......@@ -31,6 +37,16 @@ class SettlementWorklist extends Component
#[Url(as: 'q')]
public string $search = '';
/**
* The row whose full detail is open.
*
* Locked: it only ever names a row this render already produced, and the
* detail it reveals is the participant's own money. Letting the browser set
* it would be handing it an id to point at.
*/
#[Locked]
public ?int $expanded = null;
public function mount(): void
{
$this->authorize('settlements.manage');
......@@ -40,6 +56,20 @@ public function clearFilter(): void
{
$this->caseFilter = '';
$this->search = '';
$this->expanded = null;
}
public function selectCase(string $case): void
{
// Selecting the open case again closes it. A filter you can only escape
// by finding the "all" button is a filter operators leave switched on.
$this->caseFilter = $this->caseFilter === $case ? '' : $case;
$this->expanded = null;
}
public function toggleRow(int $participantId): void
{
$this->expanded = $this->expanded === $participantId ? null : $participantId;
}
/**
......@@ -47,29 +77,48 @@ public function clearFilter(): void
* a query per participant — an academy of a few hundred players is one
* page-load, and the branch scope on Participant is what keeps it to this
* branch.
*
* Scanned unfiltered, then narrowed here. The scanner's own `only` argument
* would drop the other cases from the result entirely, and the case rail
* would then show a count of zero beside every case except the selected one
* — which reads as "these are fixed" rather than "these are hidden".
*
* @return array{all:array<int,array>, shown:array<int,array>, counts:array<string,int>}
*/
private function rows(): array
private function scan(): array
{
$rows = app(AccountAnomalyScanner::class)->scan(
branchId: null,
only: $this->caseFilter ?: null,
);
$all = app(AccountAnomalyScanner::class)->scan(branchId: null);
$counts = [];
foreach ($all as $row) {
foreach ($row['cases'] as $case) {
$counts[$case] = ($counts[$case] ?? 0) + 1;
}
}
$term = trim($this->search);
$shown = $all;
if ($term === '') {
return $rows;
if ($this->caseFilter !== '') {
$shown = array_values(array_filter(
$shown,
fn ($row) => in_array($this->caseFilter, $row['cases'], true)
));
}
return array_values(array_filter($rows, function ($row) use ($term) {
$person = $row['participant']->person;
if ($term !== '') {
$shown = array_values(array_filter($shown, function ($row) use ($term) {
$person = $row['participant']->person;
// Same folding the wizard's search uses, so a name found there
// is found here too.
return ArabicSearch::matches($person?->name_ar, $term)
|| ArabicSearch::matches($person?->name, $term)
|| str_contains((string) $person?->phone, ArabicSearch::normalise($term));
}));
}
// Same folding the wizard's search uses, so a name found there is
// found here too.
return ArabicSearch::matches($person?->name_ar, $term)
|| ArabicSearch::matches($person?->name, $term)
|| str_contains((string) $person?->phone, ArabicSearch::normalise($term));
}));
return ['all' => $all, 'shown' => $shown, 'counts' => $counts];
}
/** The worklist as a spreadsheet, for the people who work off paper. */
......@@ -77,7 +126,7 @@ public function export()
{
$this->authorize('settlements.manage');
$rows = $this->rows();
$rows = $this->scan()['shown'];
$cases = AccountAnomalyScanner::CASES;
return response()->streamDownload(function () use ($rows, $cases) {
......@@ -98,7 +147,11 @@ public function export()
$participant->id,
$participant->person?->name_ar,
$participant->person?->phone,
$participant->enrollments->first()?->program?->name_ar,
// A player whose enrolment was destroyed has no programme to
// read off the relation; his last invoice line is the only
// surviving record of which one it was.
$participant->enrollments->first()?->program?->name_ar
?? ($row['detail']['no_enrollment']['last_program'] ?? null),
implode(' / ', array_map(fn ($c) => $cases[$c]['label'], $row['cases'])),
$row['unpaid_invoices'],
number_format($row['owed'] / 100, 2, '.', ''),
......@@ -115,20 +168,25 @@ public function export()
public function render()
{
$rows = $this->rows();
$counts = [];
foreach ($rows as $row) {
foreach ($row['cases'] as $case) {
$counts[$case] = ($counts[$case] ?? 0) + 1;
}
$scan = $this->scan();
// Money the books are asking for twice, across every flagged account.
// It sits beside the debt in the header because it is the one headline
// figure that is owed BY the academy, and averaging it into "المستحق"
// is how an over-billing goes unnoticed for a season.
$overBilled = 0;
foreach ($scan['all'] as $row) {
$overBilled += array_sum(array_column($row['detail']['over_billed_bundle'] ?? [], 'over_billed'));
}
return view('livewire.admin.settlement-worklist', [
'rows' => $rows,
'counts' => $counts,
'rows' => $scan['shown'],
'total' => count($scan['all']),
'counts' => $scan['counts'],
'cases' => AccountAnomalyScanner::CASES,
'totalOwed' => array_sum(array_column($rows, 'owed')),
'guide' => AccountAnomalyScanner::CASES[$this->caseFilter] ?? null,
'totalOwed' => array_sum(array_column($scan['all'], 'owed')),
'overBilled' => $overBilled,
]);
}
}
......@@ -3,8 +3,12 @@
namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Participant\Models\Participant;
use App\Livewire\Admin\SettlementWorklist;
use App\Models\User;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Tests\TestCase;
/**
......@@ -55,6 +59,100 @@ public function test_the_worklist_can_be_filtered_to_one_case(): void
$response->assertSee('لم يُسجَّل له أي دفع', escape: false);
}
public function test_selecting_a_case_shows_how_it_is_handled(): void
{
// The guidance is the point of the console. An operator who cannot see
// what a case means skips it, and a skipped case is money — so the
// meaning, the cause and the numbered handling all have to be on the
// page, not one click further in.
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'never_paid']));
$response->assertOk();
$response->assertSee('كيف تُعالَج', escape: false);
$response->assertSee('لماذا تحدث', escape: false);
$response->assertSee('راجع دفتر الكاش', escape: false);
}
public function test_the_case_rail_keeps_every_count_while_one_case_is_selected(): void
{
// The scanner's own `only` filter would have dropped the other cases
// from the result entirely, so the rail beside a selected case would
// read zero everywhere — which an operator reads as "those are fixed",
// not "those are hidden".
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'never_paid']));
$response->assertOk();
$response->assertSee('متأخرات متراكمة', escape: false);
$response->assertSee('لا يوجد له اشتراك مسجَّل', escape: false);
}
public function test_the_worklist_surfaces_players_with_no_enrolment(): void
{
// The case nobody could find by looking: renewal billing reads
// `enrollments` and nothing else, so a player with no row there is not
// billed late — he is not billed at all, and no other screen lists him.
$orphans = collect(app(AccountAnomalyScanner::class)->scan())
->filter(fn ($row) => in_array('no_enrollment', $row['cases'], true));
if ($orphans->isEmpty()) {
$this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
}
// The programme is read off the last invoice line, because the enrolment
// that knew it is gone — and it is the one fact the operator needs to
// put the player back.
$this->assertNotNull(
$orphans->first()['detail']['no_enrollment']['last_program'] ?? null,
'An orphan whose invoices name a programme must carry it into the row.'
);
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'no_enrollment']));
$response->assertOk();
$response->assertSee('سجل الاشتراك هو الشيء الوحيد', escape: false);
// The programme column falls back to the last invoice's wording and says
// so, rather than printing an em-dash and leaving the operator to guess.
$response->assertSee('من فاتورة سابقة — لا اشتراك', escape: false);
$response->assertDontSee('لا توجد حالات تحتاج تسوية', escape: false);
}
public function test_a_row_opens_its_evidence_without_leaving_the_list(): void
{
// The detail block reads eight different shapes out of the scanner's
// `detail` array. A renamed key there is a 500 the moment an operator
// clicks a row, and nothing but rendering it catches that.
$rows = app(AccountAnomalyScanner::class)->scan();
if ($rows === []) {
$this->markTestSkipped('This tenant snapshot has no flagged accounts.');
}
$component = Livewire::actingAs($this->anAdmin())
->test(SettlementWorklist::class);
// Every flagged account, not just the first: each case contributes its
// own detail block, and one broken branch is one operator's dead end.
foreach (array_slice($rows, 0, 40) as $row) {
$component->call('toggleRow', $row['participant']->id)
->assertOk()
->assertSee('رقم المشترك', escape: false);
}
}
public function test_the_open_row_cannot_be_pointed_at_another_account(): void
{
// `expanded` reveals a participant's own money, so it is #[Locked]:
// settable from the browser it would be an id to aim somewhere else.
$this->expectException(CannotUpdateLockedPropertyException::class);
Livewire::actingAs($this->anAdmin())
->test(SettlementWorklist::class)
->set('expanded', 1);
}
public function test_the_wizard_opens_on_a_participant_and_shows_the_account(): void
{
// A live participant: an invoice whose player was later deleted must
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment