Commit 61dab765 authored by DevPilot's avatar DevPilot

feat(accounting): surface the player/member links that break receivables

The accrual failure had a data cause: nine sa_players rows hold a membership
NUMBER in member_id (897000, 1015, 101) where a row id belongs. The code no
longer breaks on them, but the underlying problem is still real and invisible —
the obligation posts to the ledger while the debt never reaches the member's
account, so nobody chases it.

That is not something an accountant can decide alone; only whoever knows the
player can say which member they are. So it goes on the gaps screen, which is
where the consequence shows up, with the claim count and amount riding on each
one and a link straight to the player's edit form. Where the number matches an
actual membership_number the likely member is offered — two of the nine resolve
that way.

The comparison is numeric on purpose. membership_number is a varchar in
utf8mb4_unicode_ci and the cast of an integer carries the connection collation,
so string-comparing them raises an illegal-mix-of-collations error. It did, and
the catch swallowed it and returned an empty list — a broken query and a clean
bill of health looked identical on screen. That catch now logs.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 87c22aca
......@@ -33,6 +33,7 @@ class GapController extends Controller
'unbookable' => AccrualRunner::unbookable(),
'academyPending' => AcademyContractImportService::pending(),
'academyImported' => AcademyContractImportService::alreadyImported(),
'brokenLinks' => $ready ? GapToolService::brokenMemberLinks() : [],
]);
}
......
......@@ -330,6 +330,57 @@ final class GapToolService
}
}
/**
* Players whose `member_id` points at a member that does not exist.
*
* Some of these hold a membership NUMBER where a row id belongs — 897000 is
* nobody's row. The obligation is still accrued and still shows in the
* ledger, but it cannot be attached to a member: the receivable's foreign
* key refuses it, so the debt never appears on that member's account and
* nobody chases it.
*
* This is the one thing on this screen the accountant cannot decide alone —
* only whoever knows the player can say which member they are. It is listed
* here because this is where the consequence shows up.
*
* @return array<int, array<string, mixed>>
*/
public static function brokenMemberLinks(): array
{
try {
// The guess compares NUMBERS, not strings. `membership_number` is a
// varchar and the cast of an integer carries the connection's own
// collation, so string-comparing the two raises an illegal-mix-of-
// collations error rather than returning nothing.
return App::getInstance()->db()->select(
"SELECT p.id AS player_id,
p.full_name_ar,
p.member_id AS bad_member_id,
COUNT(a.id) AS claims,
ROUND(COALESCE(SUM(a.accrued_amount), 0), 2) AS amount,
(SELECT m.id FROM members m
WHERE m.membership_number REGEXP '^[0-9]+$'
AND CAST(m.membership_number AS UNSIGNED) = p.member_id
LIMIT 1) AS likely_member_id
FROM sa_players p
LEFT JOIN posting_accruals a
ON a.member_id = p.member_id
AND a.status <> 'reversed'
WHERE p.member_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM members m WHERE m.id = p.member_id)
GROUP BY p.id, p.full_name_ar, p.member_id
ORDER BY amount DESC, p.id"
);
} catch (\Throwable $e) {
// Never silently: an empty panel and a broken query look identical
// on screen, and this one already hid a collation error once.
\App\Core\Logger::error('Could not list broken player/member links', [
'error' => $e->getMessage(),
]);
return [];
}
}
private static function hasAccrualRule(string $streamCode): bool
{
$row = App::getInstance()->db()->selectOne(
......
......@@ -157,6 +157,78 @@
</div>
<?php endforeach; ?>
<!-- ══ Players linked to a member that does not exist ══ -->
<?php if (!empty($brokenLinks)): ?>
<?php
$brokenClaims = 0; $brokenAmount = '0.00';
foreach ($brokenLinks as $bl) {
$brokenClaims += (int) $bl['claims'];
$brokenAmount = bcadd($brokenAmount, (string) $bl['amount'], 2);
}
?>
<div class="card" style="margin-bottom:14px;border-right:3px solid #DC2626;">
<div style="padding:14px 18px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:15px;font-weight:700;">لاعبين مربوطين بعضو مش موجود</span>
<span class="badge badge-danger"><?= number_format(\count($brokenLinks)) ?> لاعب</span>
<?php if ($brokenClaims > 0): ?>
<span class="badge badge-warning"><?= number_format($brokenClaims) ?> مطالبة — <?= money($brokenAmount) ?></span>
<?php endif; ?>
</div>
<p style="margin:8px 0 0;color:#991B1B;font-size:12.5px;line-height:1.9;">
<strong>المشكلة:</strong> خانة العضو في ملف اللاعب فيها <strong>رقم عضوية</strong>
مش رقم صف العضو — زي <code>897000</code>، وده مش رقم حد. الاستحقاق بيتقيّد
في الدفاتر عادي، بس الدين <strong>مش بيظهر على حساب العضو</strong> لأن الربط
مش لاقي حد، وبالتالي محدش بيتابعه.
</p>
<p style="margin:6px 0 0;color:#6B7280;font-size:12px;line-height:1.9;">
ده مش قرار محاسبي — محدش غير اللي يعرف اللاعب يقدر يقول هو مين. صلّح الربط من
ملف اللاعب، وأول جولة للماسح بعد كده هتظبّط حساب العضو لوحدها.
</p>
<div class="table-responsive" style="margin-top:12px;">
<table class="table" style="font-size:12.5px;">
<thead>
<tr>
<th>اللاعب</th>
<th>الرقم المكتوب</th>
<th>مطالبات</th>
<th>المبلغ</th>
<th>الأغلب إنه</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($brokenLinks as $bl): ?>
<tr>
<td><?= e((string) ($bl['full_name_ar'] ?? '—')) ?></td>
<td><code><?= e((string) $bl['bad_member_id']) ?></code></td>
<td><?= number_format((int) $bl['claims']) ?></td>
<td><?= money((string) $bl['amount']) ?></td>
<td>
<?php if (!empty($bl['likely_member_id'])): ?>
<a href="/members/<?= (int) $bl['likely_member_id'] ?>">
العضو صاحب رقم العضوية ده
</a>
<?php else: ?>
<span style="color:#9CA3AF;">مفيش عضو برقم العضوية ده</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-sm btn-secondary" href="/sa/players/<?= (int) $bl['player_id'] ?>/edit">
صلّح الربط
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<!-- ══ Academy contracts: the duplicate-table gap ══ -->
<div class="card" style="margin-bottom:14px;border-right:3px solid <?= $academyImported > 0 ? '#059669' : '#D97706' ?>;">
<div style="padding:14px 18px;">
......
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