Commit d3f2e87e authored by DevPilot's avatar DevPilot

fix(facility-grids, members): cross-grid delete auth gap, blank names, wrong...

fix(facility-grids, members): cross-grid delete auth gap, blank names, wrong permissions, dead crons, FY string mismatch

FacilityGrids:
- ZoneTraineeController::remove()/clearZone()/move() ما كانوش بيتأكدوا إن
  المتدرب/المنطقة فعلاً تابعين لنفس الشبكة في الـ URL — أي حد عنده
  facility_grid.manage على أي شبكة كان يقدر يمسح متدرب أو يفرغ منطقة تابعة
  لشبكة تانية تمامًا لو خمّن الـ ID. دلوقتي بيتأكدوا من الملكية الأول.
- FacilityAttendanceService::sendPaymentReminders() والـ view بتاع شاشة
  الحضور كانوا لسه بيقروا name_ar بعد ما الكويري اتصلح لـ full_name_ar —
  فكل تذكير دفع كان بيتبعت باسم فاضي، وشاشة تسجيل الحضور كانت بتطلع كل
  أسماء المدربين/المتدربين فاضية وبتحفظ entity_name_cache فاضي في القاعدة.
- MonthlyPlanController::index() كان بيتحقق من صلاحية مختلفة عن اللي مسجلة
  على الـ route بتاعه (manage بدل view) — يعني حتى لو عندك صلاحية العرض
  بس، تاخد 403 فور ما تدخل شاشة الخطط الشهرية.
- زرار "إنشاء شبكة" و"خطة جديدة" كانوا بيتحكموا بصلاحية facility.manage
  (بتاعة موديول Facilities المختلف) بدل facility_grid.manage بتاع
  الموديول نفسه.
- زرار "تصدير PDF" في لوحة المالية كان بيسمي نفسه غلط — الملف اللي بينزل
  فعليًا HTML مش PDF.

Members:
- AutoFreezeService::freezeTemporaryAtAgeLimit() كانت شرطه بيدور على أكواد
  فئات (sisters_under_25 إلخ) مش موجودة في القاعدة أصلًا — الفئات الحقيقية
  المخزنة هي sister/stepchild/orphan، فالكرون كان ميت فعليًا ومفيش شقيقة
  ولا ابن زوج ولا يتيم بيتقفل تلقائيًا بعد سن 25.
- حالة permanently_dropped (اللي بيكتبها كرون إسقاط الأعضاء) ما كانتش في
  قائمة تسميات الحالات — فكانت بتظهر بالإنجليزي الخام في أي شاشة تعرض حالة
  العضو. اتضافت هي وterminated اللي كانت ناقصة في نفس القائمة.
- MemberController::show() كان بيبني السنة المالية الحالية بصيغة فيها شرطة
  (2025-2026) بينما كل مكان تاني في الكود بيكتبها بشرطة مايلة (2025/2026)
  زي ما فعليًا متخزن في subscriptions — فكانت شاشة بروفايل أي عضو دايمًا
  بتقول إن مفيش اشتراك للسنة الحالية حتى لو مدفوع فعلًا.

Plus extreme-detail reference docs for both modules.
parent 00d84740
...@@ -14,7 +14,7 @@ class MonthlyPlanController extends Controller ...@@ -14,7 +14,7 @@ class MonthlyPlanController extends Controller
{ {
public function index(Request $request, string $gridId): Response public function index(Request $request, string $gridId): Response
{ {
$this->authorize('facility_grid.manage'); $this->authorize('facility_grid.view');
$grid = FacilityGrid::find((int) $gridId); $grid = FacilityGrid::find((int) $gridId);
if (!$grid) return $this->redirect('/facility-grids')->withError('الشبكة غير موجودة'); if (!$grid) return $this->redirect('/facility-grids')->withError('الشبكة غير موجودة');
......
...@@ -67,9 +67,20 @@ class ZoneTraineeController extends Controller ...@@ -67,9 +67,20 @@ class ZoneTraineeController extends Controller
public function remove(Request $request, string $gridId, string $traineeId): Response public function remove(Request $request, string $gridId, string $traineeId): Response
{ {
$this->authorize('facility_grid.manage'); $this->authorize('facility_grid.manage');
$gridId = (int) $gridId;
$traineeId = (int) $traineeId; $traineeId = (int) $traineeId;
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$trainee = $db->selectOne(
"SELECT fzt.id FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
WHERE fzt.id = ? AND fgz.grid_id = ?",
[$traineeId, $gridId]
);
if (!$trainee) {
return $this->json(['success' => false, 'message' => 'المتدرب غير موجود في هذه الشبكة']);
}
$db->delete('facility_zone_trainees', 'id = ?', [$traineeId]); $db->delete('facility_zone_trainees', 'id = ?', [$traineeId]);
return $this->json(['success' => true, 'message' => 'تم إزالة المتدرب']); return $this->json(['success' => true, 'message' => 'تم إزالة المتدرب']);
} }
...@@ -86,6 +97,16 @@ class ZoneTraineeController extends Controller ...@@ -86,6 +97,16 @@ class ZoneTraineeController extends Controller
return $this->json(['success' => false, 'message' => 'لم يتم تحديد المنطقة']); return $this->json(['success' => false, 'message' => 'لم يتم تحديد المنطقة']);
} }
$currentTrainee = $db->selectOne(
"SELECT fzt.id FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
WHERE fzt.id = ? AND fgz.grid_id = ?",
[$traineeId, $gridId]
);
if (!$currentTrainee) {
return $this->json(['success' => false, 'message' => 'المتدرب غير موجود في هذه الشبكة']);
}
$targetZone = $db->selectOne( $targetZone = $db->selectOne(
"SELECT * FROM facility_grid_zones WHERE id = ? AND grid_id = ?", "SELECT * FROM facility_grid_zones WHERE id = ? AND grid_id = ?",
[$targetZoneId, $gridId] [$targetZoneId, $gridId]
...@@ -109,9 +130,15 @@ class ZoneTraineeController extends Controller ...@@ -109,9 +130,15 @@ class ZoneTraineeController extends Controller
public function clearZone(Request $request, string $gridId, string $zoneId): Response public function clearZone(Request $request, string $gridId, string $zoneId): Response
{ {
$this->authorize('facility_grid.manage'); $this->authorize('facility_grid.manage');
$gridId = (int) $gridId;
$zoneId = (int) $zoneId; $zoneId = (int) $zoneId;
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$zone = $db->selectOne("SELECT id FROM facility_grid_zones WHERE id = ? AND grid_id = ?", [$zoneId, $gridId]);
if (!$zone) {
return $this->json(['success' => false, 'message' => 'المنطقة غير موجودة']);
}
$db->delete('facility_zone_trainees', 'zone_id = ?', [$zoneId]); $db->delete('facility_zone_trainees', 'zone_id = ?', [$zoneId]);
return $this->json(['success' => true, 'message' => 'تم إفراغ المنطقة']); return $this->json(['success' => true, 'message' => 'تم إفراغ المنطقة']);
} }
......
...@@ -111,7 +111,7 @@ final class FacilityAttendanceService ...@@ -111,7 +111,7 @@ final class FacilityAttendanceService
foreach ($trainees as $trainee) { foreach ($trainees as $trainee) {
EventBus::dispatch('facility.payment_reminder', [ EventBus::dispatch('facility.payment_reminder', [
'player_id' => (int) $trainee['player_id'], 'player_id' => (int) $trainee['player_id'],
'player_name' => $trainee['name_ar'], 'player_name' => $trainee['full_name_ar'],
'phone' => $trainee['phone'], 'phone' => $trainee['phone'],
'grid_id' => $gridId, 'grid_id' => $gridId,
]); ]);
......
...@@ -45,11 +45,11 @@ ...@@ -45,11 +45,11 @@
<tbody> <tbody>
<?php foreach ($coaches as $i => $coach): ?> <?php foreach ($coaches as $i => $coach): ?>
<tr> <tr>
<td><?= e($coach['name_ar']) ?></td> <td><?= e($coach['full_name_ar']) ?></td>
<td> <td>
<input type="hidden" name="attendance[<?= $i ?>][entity_type]" value="coach"> <input type="hidden" name="attendance[<?= $i ?>][entity_type]" value="coach">
<input type="hidden" name="attendance[<?= $i ?>][entity_id]" value="<?= $coach['coach_id'] ?>"> <input type="hidden" name="attendance[<?= $i ?>][entity_id]" value="<?= $coach['coach_id'] ?>">
<input type="hidden" name="attendance[<?= $i ?>][entity_name]" value="<?= e($coach['name_ar']) ?>"> <input type="hidden" name="attendance[<?= $i ?>][entity_name]" value="<?= e($coach['full_name_ar']) ?>">
<select name="attendance[<?= $i ?>][status]" class="form-select form-select-sm"> <select name="attendance[<?= $i ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option> <option value="present">حاضر</option>
<option value="absent">غائب</option> <option value="absent">غائب</option>
...@@ -75,11 +75,11 @@ ...@@ -75,11 +75,11 @@
<tbody> <tbody>
<?php $offset = count($coaches); foreach ($trainees as $j => $trainee): ?> <?php $offset = count($coaches); foreach ($trainees as $j => $trainee): ?>
<tr> <tr>
<td><?= e($trainee['name_ar']) ?></td> <td><?= e($trainee['full_name_ar']) ?></td>
<td> <td>
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_type]" value="trainee"> <input type="hidden" name="attendance[<?= $offset + $j ?>][entity_type]" value="trainee">
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_id]" value="<?= $trainee['player_id'] ?>"> <input type="hidden" name="attendance[<?= $offset + $j ?>][entity_id]" value="<?= $trainee['player_id'] ?>">
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_name]" value="<?= e($trainee['name_ar']) ?>"> <input type="hidden" name="attendance[<?= $offset + $j ?>][entity_name]" value="<?= e($trainee['full_name_ar']) ?>">
<select name="attendance[<?= $offset + $j ?>][status]" class="form-select form-select-sm"> <select name="attendance[<?= $offset + $j ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option> <option value="present">حاضر</option>
<option value="absent">غائب</option> <option value="absent">غائب</option>
......
...@@ -4,7 +4,7 @@ $__template->layout('Layout.main'); ...@@ -4,7 +4,7 @@ $__template->layout('Layout.main');
<?php $__template->section('title'); ?>شبكات المرافق<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>شبكات المرافق<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?> <?php $__template->section('page_actions'); ?>
<?php if (can('facility.manage')): ?> <?php if (can('facility_grid.manage')): ?>
<a href="/facility-grids/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء شبكة</a> <a href="/facility-grids/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء شبكة</a>
<?php endif; ?> <?php endif; ?>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -15,7 +15,7 @@ $__template->layout('Layout.main'); ...@@ -15,7 +15,7 @@ $__template->layout('Layout.main');
<div class="card" style="padding:60px;text-align:center;"> <div class="card" style="padding:60px;text-align:center;">
<i data-lucide="grid-3x3" style="width:48px;height:48px;color:#9CA3AF;margin-bottom:16px;"></i> <i data-lucide="grid-3x3" style="width:48px;height:48px;color:#9CA3AF;margin-bottom:16px;"></i>
<p style="font-size:16px;color:#6B7280;margin-bottom:16px;">لا توجد شبكات مرافق بعد</p> <p style="font-size:16px;color:#6B7280;margin-bottom:16px;">لا توجد شبكات مرافق بعد</p>
<?php if (can('facility.manage')): ?> <?php if (can('facility_grid.manage')): ?>
<a href="/facility-grids/create" class="btn btn-primary">إنشاء أول شبكة</a> <a href="/facility-grids/create" class="btn btn-primary">إنشاء أول شبكة</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
......
<?php $__template->layout('Layout.main'); ?> <?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الخطط الشهرية — <?= e($grid->name_ar) ?><?php $__template->endSection(); ?> <?php $__template->section('title'); ?>الخطط الشهرية — <?= e($grid->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?> <?php $__template->section('page_actions'); ?>
<?php if (can('facility.manage')): ?> <?php if (can('facility_grid.manage')): ?>
<a href="/facility-grids/<?= (int) $grid->id ?>/plans/create" class="btn btn-primary">+ خطة جديدة</a> <a href="/facility-grids/<?= (int) $grid->id ?>/plans/create" class="btn btn-primary">+ خطة جديدة</a>
<?php endif; ?> <?php endif; ?>
<a href="/facility-grids/<?= (int) $grid->id ?>" class="btn btn-outline">العودة للشبكة</a> <a href="/facility-grids/<?= (int) $grid->id ?>" class="btn btn-outline">العودة للشبكة</a>
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4>لوحة التحكم المالية - حمام السباحة</h4> <h4>لوحة التحكم المالية - حمام السباحة</h4>
<a href="/facility-grids/<?= $grid_id ?>/financial-dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>" <a href="/facility-grids/<?= $grid_id ?>/financial-dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>"
class="btn btn-outline-success" target="_blank">تصدير PDF</a> class="btn btn-outline-success" target="_blank">تصدير التقرير</a>
</div> </div>
<!-- Filter --> <!-- Filter -->
......
...@@ -317,7 +317,7 @@ class MemberController extends Controller ...@@ -317,7 +317,7 @@ class MemberController extends Controller
// Subscription status for current FY // Subscription status for current FY
$currentMonth = (int) date('n'); $currentMonth = (int) date('n');
$currentYear = (int) date('Y'); $currentYear = (int) date('Y');
$currentFY = $currentMonth >= 7 ? "$currentYear-" . ($currentYear + 1) : ($currentYear - 1) . "-$currentYear"; $currentFY = $currentMonth >= 7 ? "$currentYear/" . ($currentYear + 1) : ($currentYear - 1) . "/$currentYear";
$subscriptionStatus = $db->selectOne( $subscriptionStatus = $db->selectOne(
"SELECT id, financial_year, status, total_amount, paid_amount, fine_amount "SELECT id, financial_year, status, total_amount, paid_amount, fine_amount
FROM subscriptions WHERE member_id = ? AND financial_year = ? LIMIT 1", FROM subscriptions WHERE member_id = ? AND financial_year = ? LIMIT 1",
......
...@@ -65,6 +65,10 @@ class Member extends Model ...@@ -65,6 +65,10 @@ class Member extends Model
'dropped' => 'مسقط', 'dropped' => 'مسقط',
'expired' => 'منتهي', 'expired' => 'منتهي',
'terminated' => 'منتهي بقرار', 'terminated' => 'منتهي بقرار',
'deceased' => 'متوفى',
'transferred' => 'منقولة',
'waived' => 'متنازل عنها',
'permanently_dropped'=> 'مسقط نهائياً',
]; ];
return $labels[$this->status] ?? $this->status; return $labels[$this->status] ?? $this->status;
} }
...@@ -121,9 +125,11 @@ class Member extends Model ...@@ -121,9 +125,11 @@ class Member extends Model
public static function getAllStatusLabels(): array public static function getAllStatusLabels(): array
{ {
return self::getStatusOptions() + [ return self::getStatusOptions() + [
'deceased' => 'متوفى', 'deceased' => 'متوفى',
'transferred' => 'منقولة', 'transferred' => 'منقولة',
'waived' => 'متنازل عنها', 'waived' => 'متنازل عنها',
'terminated' => 'منتهي بقرار',
'permanently_dropped' => 'مسقط نهائياً',
]; ];
} }
......
...@@ -166,7 +166,7 @@ final class AutoFreezeService ...@@ -166,7 +166,7 @@ final class AutoFreezeService
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$frozenCount = 0; $frozenCount = 0;
$ageLimitCategories = ['sisters_under_25', 'stepchildren_under_25', 'orphan_sponsored']; $ageLimitCategories = ['sister', 'stepchild', 'orphan'];
$temps = $db->select( $temps = $db->select(
"SELECT id, date_of_birth, category FROM temporary_members "SELECT id, date_of_birth, category FROM temporary_members
......
# FacilityGrids Module
40 routes, 2 permissions (`facility_grid.view`, `facility_grid.manage`). This module owns a legacy row×column physical grid ("**المرايا** / Mirror") for assigning coaches/academies and individual trainees onto lanes/courts by hand — it is a **separate, older system from SportsActivity's Mirror and Pool Grid**, not a shared one.
## Read this first: this is not SportsActivity's Mirror
SportsActivity has its own, newer drag-and-drop Mirror (`/sa/mirror/*`, scheduling `sa_groups` onto `sa_facilities`) and its own Pool Grid template system (`/sa/pool-grid/*`, `sa_pool_zone_templates`). Both are entirely separate modules with entirely separate tables (`facilities`/`facility_grids`/`facility_grid_zones`/`facility_zone_schedules`/`facility_zone_trainees` here vs. `sa_facilities`/`sa_groups`/`sa_pool_zone_bookings` there — unrelated ID spaces, created in different migration phases). **This module has zero references anywhere to any SportsActivity table.** Practical consequence: a club running both systems against the same physical pool has **no cross-system double-booking protection whatsoever** — this module's conflict checker only ever looks at its own `facility_zone_schedules`, and SportsActivity's never looks at this module's tables. This is an architectural gap worth knowing about, not a line-level bug.
## The Mirror grid (`/facility-grids/{id}`)
Assigning a coach/academy session is **click-to-select, not drag-and-drop**: pick a selection mode (cells/row/column/all), click cells to build a selection, then "تعيين المحدد" opens a modal for a free-text `schedule_name` (doubles as the coach/academy name), activity type, age group, gender, day of week, color. Start/end time are forced to exactly the one-hour block of whichever hour-tab is open — **the Mirror cannot create sessions shorter or longer than exactly one hour.**
The modal has **no `coach_id`/`academy_id` field at all** — only a name string. Every schedule created from the live screen therefore has `coach_id = NULL`/`academy_id = NULL`, which means the coach/academy existence-and-active-status validation written into `ZoneScheduleController::store()` is dead code from this screen's perspective — it only ever runs against nulls. A separate, fully-built alternative Mirror view (`Views/grid_interactive.php`, with real coach/academy pickers, mouse-drag rectangular selection, and a richer modal) exists in the codebase but is wired to **zero routes** — an orphaned, superseded screen.
Moving an already-assigned trainee between zones **is** real HTML5 drag-and-drop.
Conflict checking (`GridStateService::checkConflicts()`) resolves each schedule's `zone_selection_json` into concrete grid cells and does a manual overlap check in PHP — it's invoked on both create and update, and also exposed as a standalone pre-check endpoint that the shipped UI never actually calls (dead endpoint, not wired to any JS).
### Fixed this session — cross-grid authorization gap on trainee actions
`ZoneTraineeController::remove()` and `::clearZone()` never checked that the trainee/zone being deleted actually belonged to the grid ID in the URL — `remove()` ran a bare `DELETE ... WHERE id = ?`, and `clearZone()` a bare `DELETE ... WHERE zone_id = ?`, with no ownership check at all. Anyone holding `facility_grid.manage` on *any* grid could delete a trainee record or wipe an entire zone belonging to a *completely different* grid just by guessing/enumerating IDs. `move()` had a matching gap in the other direction: it validated the *target* zone belonged to the grid in the URL, but never checked that the trainee being moved currently belonged to that grid at all — a trainee from Grid A could be pulled into Grid B from Grid B's own page. All three now verify the trainee/zone actually belongs to the grid in the URL before acting.
Since `facility_zone_trainees` has no soft-delete column, these were (and remain) true SQL `DELETE`s with no audit trail of who removed a trainee or when — worth knowing if a trainee disappears with no record of why.
## Facility Attendance
Checklist-style attendance for coaches/trainees scheduled on a grid, `POST`ed as a raw `$_POST` array with no server-side validation of `entity_type`/`status` beyond what the `<select>` in the view offers — a hand-crafted POST could write an arbitrary status string. No unique DB constraint backs the upsert-by-`(grid_id, date, entity_type, entity_id, hour_slot)` logic, so a double-submit (two tabs, a slow double-click) can create duplicate rows that would double-count in the report's aggregates.
`remind()` fires a payment-reminder event for **every** trainee ever assigned to any zone on the grid — despite the name, it does not check whether they actually owe payment.
### Fixed this session — reminder/checklist showed blank names everywhere
An earlier column-name fix (`name_ar``full_name_ar`) was applied to the controller's SQL but missed two other places reading the same result rows: `FacilityAttendanceService::sendPaymentReminders()` still read `$trainee['name_ar']` (always null against a row that only has `full_name_ar`), so **every `facility.payment_reminder` event shipped with `player_name = null`**, and `Views/facility_attendance.php` (4 occurrences) still read `$coach['name_ar']`/`$trainee['name_ar']`, so the attendance checklist rendered **blank names for every coach and trainee row**, and every attendance record saved through that screen persisted `entity_name_cache = ''` (shown as "—" in the report). Both are now aligned to `full_name_ar`.
## Monthly Plans
A per-grid month container (draft → active → archived, day/night working-hour config) that can be cloned from a prior month — cloning copies the source's active schedules into the new month, forcing their `effective_from`/`effective_to` to bound exactly the new calendar month.
**Confirmed structural gap, not fixed**`facility_zone_schedules.plan_id` is not in `FacilityZoneSchedule::$fillable`, and the only reachable schedule-creation path (the Mirror modal, via `ZoneScheduleController::store()`) never sets it at all. Only `clonePlan()`'s raw SQL insert ever populates `plan_id`. Consequence: a Monthly Plan created from scratch (not cloned) will show **zero schedules forever** on its own "show"/"diff" page, even after staff fill the grid with real sessions through the Mirror — because those sessions are never tagged with that plan's ID. This needs a deliberate design decision (what should tag a schedule with the currently-active plan?) rather than a blind fix, since normally-created schedules also default to open-ended (`effective_to = NULL`), meaning the Monthly Plan layer is largely decorative metadata sitting on top of a grid whose actual displayed content is driven purely by date/day-of-week, independent of which plan is "active." Activating a plan does not touch `facility_zone_schedules` at all.
The "diff" between a cloned plan and its source is a net count delta (added/removed totals), not a real per-schedule comparison, despite the name.
### Fixed this session — two permission mismatches
`MonthlyPlanController::index()` called `$this->authorize('facility_grid.manage')` while its own route was registered under `facility_grid.view` — a staff member holding only view access would pass the route middleware and then get a hard 403 the instant the controller ran, making the "الخطط الشهرية" list unreachable for view-only users despite the route table promising otherwise. Now it authorizes against `facility_grid.view`, matching the route.
Separately, the "Create Grid" button (`Views/index.php`, 2 places) and the "New Plan" button (`Views/monthly_plans.php`) were gated on `can('facility.manage')` — a different, real permission belonging to the unrelated `Facilities` module — instead of this module's own `facility_grid.manage`. A role granted `facility_grid.manage` never saw its own create buttons; a role granted the unrelated `facility.manage` saw the buttons and then got 403'd on submit. All three now check `facility_grid.manage`.
## Pool Hour Plan
A per-hour snapshot-freeze feature (`pool_hour_plans.grid_snapshot_json`) meant to let staff freeze/print "what the grid looked like" for one hour of one date. **Confirmed dead feature, not fixed** — the day-view screen's "تعديل الشبكة" button links straight to `PoolHourPlanController::showHour()`, which is a JSON-only API endpoint with no HTML page or client-side script anywhere in the codebase to consume it. The backend (save/copy an hour's snapshot) works; there is currently no way to reach it through the shipped UI at all.
## Pool Financial Dashboard
Revenue is read from the **legacy** `reservations`/`free_time_entries` tables, filtered by `facility_id`**not** `grid_id`, and not aware of SportsActivity's `sa_pool_reservations` at all. Two consequences worth knowing: if two `facility_grids` rows happen to share one underlying `facility_id`, both grids' dashboards show identical, double-visible revenue with no way to attribute it to one or the other; and if a club's actual pool commerce runs through SportsActivity's booking wizard rather than this legacy `reservations` flow, this dashboard will under-report or show zero revenue despite real bookings existing.
The "تصدير PDF" button — **fixed this session** — actually downloaded a plain `.html` file (no PDF library is invoked anywhere in the export method); relabeled to "تصدير التقرير" so it no longer promises a format it doesn't produce.
## Legacy URL redirects
Four old routes (`/mirror`, `/mirror/{id}`, `/pool/{id}/grid`, `/pool/{id}/schedules`) 302 to their `facility-grids` equivalents, kept only so old bookmarks/links still work after this module unified what used to be separate Mirror/Pool screens. Unrelated to SportsActivity's current `/sa/mirror/*` routes.
# Members Module
The core member lifecycle: registration, activation, dependents (spouses/children/temporary members), status transitions, transfers (waiver/death/divorce/child-separation), unified people search, reports, board offers, and carnet eligibility. The Retroactive Entry wizard is documented separately (`wizard-member-retroactive.md`). An architecture map already exists at `docs/architecture-maps/Members.md` — this chapter verifies it against the live code and extends it; divergences are called out explicitly below.
## Registration
Creation (`MemberController@store`) requires name, Egyptian-mobile-format phone, branch, and (only for `sports` membership type) a qualification — **not** for `working`, despite the membership-type config flagging `working` as requiring a qualification too; that flag is simply never read at `store()` time, dead config. National ID, if given, is parsed for DOB/gender/governorate/century — **the 14th check digit is deliberately not validated**, so a NID with a wrong check digit is silently accepted as long as the other 13 digits parse. Without a NID, DOB/gender are taken from manual fields and the member is filed as `id_type='passport'`.
A member starts as `status='potential'` and cannot progress past the "fill form" step until a `form_fee` payment exists (~505 EGP: 500 form + 5 martyrs' stamp). Editing core member data and the fill-form itself lock permanently once a `membership_number` is assigned — only a super admin can still touch name/national ID after that point.
Membership pricing by branch+qualification is actually resolved from **`pricing_configs`** at fill-form time; two other calculators (`MemberNumberGenerator::getMembershipValue()`'s hardcoded 150K/225K/300K fallback, and `MembershipRulesService::getMembershipPrice()`'s 2024-07-01 cutoff logic) exist in the code but have no live caller — `pricing_configs` is the actual source of truth in practice.
Photos are required on creation: ≤5MB, JPEG/PNG/WebP only, re-encoded to JPEG q82 and resized to fit 800×800 plus a 200×200 thumbnail.
## Activation — `MembershipPaymentGuard`
This service is the authority for **payment-driven** activation/deactivation (cash and installment membership fees, addition fees) — but it is not literally the *only* code that flips a member to `active`, despite what its own docblock claims:
- **`ChequeService::activateMemberAfterCheques()`** activates directly once all installment cheques are submitted for a `pending_cheques` member — the Guard's own `reconcile()` method explicitly documents this as a legitimate exception, not a bug.
- **`HonoraryController::store()`** activates honorary memberships directly, since there's no payment to gate on — consistent with `reconcile()` explicitly skipping honorary members forever (they're activation-guard-exempt for life).
- The Retroactive Entry wizard also writes `status='active'` directly in several places, by design (documented in its own chapter).
`reconcile()` — a self-healing re-check — only ever runs when someone opens a member's profile page (`MemberController::show()`), not on any cron. A member whose payment status has silently drifted stays drifted until someone happens to view their profile.
**Confirmed gap, not fixed**`PaymentLifecycleService::onPaymentVoided()` only triggers deactivation for `payment_type IN ('membership_fee', 'down_payment')`. Voiding a `foreign_membership_fee`, `sports_membership_fee`, or `seasonal_fee` payment leaves the member showing `active` with no covering payment until someone happens to open their profile and trigger `reconcile()`. The Guard's own `deactivateMember()` handles all five payment types correctly — the gap is purely in what the caller decides is worth calling it for. Also: seasonal-membership activation goes through Cashier calling `activateMember()` directly rather than through the normal `PaymentLifecycleService` path, so `activateIncludedDependents()` is never invoked for seasonal memberships — their dependents don't get auto-activated alongside them the way cash/installment members' do.
## Dependents (Spouses, Children, Temporary Members)
Three separate modules (`Spouses`, `Children`, `Temporary`), each following the same pattern: blocked post-activation except for super admins, photo required, no hard delete (archive only, with a required reason).
**Spouses**: gender must be the opposite of the member's own gender; max count is 1 for a female member, 4 for a male member (configurable rules). Fee: free on the initial form up to the free-spouse count, otherwise a tiered percentage (2nd/3rd/4th+) of live membership value, plus an annual late-fee accrual for post-initial-form additions based on years since marriage/activation.
**Children**: relationship is `son`/`daughter`/`stepchild` in the data model, but **the standard "add child" screen only offers `son`/`daughter`**`stepchild` is fully priced and validated in the backend (its own fee percentage, its own max-age rule) but is only reachable via the transfers child-separation form or direct DB writes, never through the normal add-child screen. Fee scales by age band (free under 18 up to the 3rd child, then a rising percentage through age 20, 15% from 21 up to the max age), and anyone at/past the max age is rejected outright as `not_accepted`.
**Age-25 auto-separation, a real inconsistency between what's reported and what's enforced**: the business rule (`canChildSeparate()`) and the aging-out *report* both treat this as a male-only concern (females separate on marriage, not by age). But the actual daily cron that executes the freeze (`AutoFreezeService::processAutoFreeze()`) applies a blanket age-25 rule to **every** child regardless of gender — it doesn't filter by gender at all. This is a real behavioral gap between the documented business rule and what the cron actually does; flagging it rather than changing the cron's gender behavior blind, since it's unclear whether the intended fix is to add the missing gender filter to the cron or to correct the business-rule/report side — that's a product call, not a mechanical one.
**Temporary members** — the live category list has **8** values (`parent`, `special_needs`, `unmarried_daughter`, `sister`, `stepchild`, `orphan`, `disabled_sibling`, `nanny`), each with its own age/gender/documentation validation and fee treatment (fully exempt for championship-winning members, regardless of category). `temp.edit` is a registered permission with no corresponding route or controller method anywhere — dead permission, there is no way to edit a temporary member's record once created.
### Fixed this session — temporary-member age-out cron matched nothing
`AutoFreezeService::freezeTemporaryAtAgeLimit()` filtered on `category IN ('sisters_under_25', 'stepchildren_under_25', 'orphan_sponsored')` — category codes from a completely different, unused category list (`MembershipRulesService::getTemporaryMemberCategories()`) that is never what actually gets written to `temporary_members.category` (which always uses `TemporaryMember::getCategories()`'s codes: `sister`, `stepchild`, `orphan`). The filter could never match a single real row, so sisters/stepchildren/orphans over 25 were never being auto-expired at all. The category list now matches what's actually stored, so the cron can find and expire them going forward.
## Status transitions
Beyond the documented vocabulary (`potential``under_review``interview_scheduled`/`accepted`/`rejected``payment_pending`/`pending_cheques``active``frozen`/`suspended`/`dropped`/`terminated`/`expired`, plus the transfer-side `transferred`/`waived`/`deceased`), a `dropped` member who isn't reinstated within the reinstatement window (default 12 months) is moved to **`permanently_dropped`** by `OverdueFineApplicator::expireReinstatements()`.
**Fixed this session**`permanently_dropped` was written by that cron but was absent from both `Member::getStatusOptions()` and `getAllStatusLabels()`, so anywhere the UI displayed a member's status, it fell through to the raw, untranslated English string instead of Arabic. It's now included (along with `terminated`, which had the same gap in `getAllStatusLabels()` specifically) in the label list used for display/filtering.
## Transfers — waiver, death, divorce, child separation
All four compute a fee off the *live* membership value/plan price, not a stored historical figure, and each treats the "old vs. new record" differently — this is the detail most worth remembering if a transfer's aftermath ever looks wrong:
- **Waiver**: source member is archived (`status='waived'`, number released); target inherits the number. Dependents are **archived, not moved** — explicitly, by design, "dependents belong to the source." Requires zero debts on both sides before it can even be requested. The module's own `Members\Services\WaiverService` (percentage/condition helpers) is dead code with zero callers — the real flow lives entirely in the separate `Waiver` module (`WaiverProcessor`), which computes richer per-individual excess-dependent fees the map doesn't fully capture.
- **Death** (primary member): requires certificate + inheritance-notice uploads, board approval, then a new member is created **inheriting the same membership number**; children move per the board's explicit assignment (default: all); secondary spouses can each get their own brand-new independent membership, with the death fee multiplied accordingly. Spouse/child deaths auto-complete immediately with no fee and no board step.
- **Divorce** — the outlier: the original member is **never touched or archived**; the divorced spouse gets a completely new, independently-numbered membership. Eligibility requires the divorce to be within a year of the decree and the membership to be at least 5 years old, unless waived because children are involved.
- **Child separation**: the parent's membership is untouched; the separating child gets a fresh membership number. Fee is tiered by years elapsed since the parent's membership began (30% in year 1 down to 2.5% from year 6+), using **floored** years — worth knowing that this differs from the generic full-transfer fee calculator in the same file, which rounds partial years **up**; that asymmetry is intentional-looking but easy to trip over if you're comparing the two calculators' outputs directly.
- **Full transfer**: source archived like death/waiver, new owner inherits the number, and — unlike waiver — **all** dependents (active and archived) are bulk re-pointed to the new member via a direct `member_id` update rather than being re-created or fee-charged.
## Member search
The unified people-search (`MemberSearchService`) UNIONs members/spouses/children/temporary members into one shape with Arabic-orthography and digit folding, token-AND name matching, and a 0/1/2 exact/prefix/substring match rank. Four different consumers (main index, employee search screen, and two API versions) each independently choose to search a different subset of columns (5/5/3/4 fields respectively) — not a bug, just worth knowing that "search" means a narrower set of fields depending on which screen or API version you're hitting. Result labels for `stepchild` (children) and several temporary-member categories (`sister`, `special_needs`, `orphan`, `disabled_sibling`) aren't in the search service's label map, so those rows show their raw English code instead of an Arabic label in search results.
## Reports
Age report, transfers, waivers, subscription-status, and unpaid-debts reports are all straightforward date/status-filtered aggregates. Nothing else notable beyond the financial-year bug documented below, which affects the member profile page rather than these report screens directly.
### Fixed this session — member profile's "current subscription" check never matched
`MemberController::show()` built its own financial-year string as `"2025-2026"` (hyphen) to look up the member's current-year subscription — but every other place in the codebase that writes or reads a financial year (`ReportController`, `SubscriptionController`, `OverdueFineApplicator`, `MembershipRulesService`, `CarnetPrintService`, `AutoFreezeService`) consistently uses the slash format `"2025/2026"`, which is what actually gets stored in `subscriptions.financial_year`. The hyphen-format query could never match a real row, so the "current-year subscription paid?" block on every single member's profile page always rendered as if no current-year subscription existed, regardless of the real data. Now built with the same slash format as everywhere else.
## Board Offers
Cash-discount / installment-override terms scoped by branch and effective date range, applied at three points: payment-time selection (frozen into a snapshot so later edits to the offer don't retroactively change an already-created payment request), installment-plan creation (restores that frozen snapshot to override the calculator's normal terms), and as a persistent per-member discount.
**Confirmed gap, not fixed** — despite the name, there is no actual board-approval gate anywhere in the code. `board_decision_number`/`board_decision_date` are free-text fields captured for paper-trail purposes only and are never required to be non-empty before an offer can be created or used — in contrast, the unrelated `special_discounts` feature *does* hard-require a non-empty decision number before it's selectable. An employee with plain CRUD permission on this screen can create and immediately use a cash-discount "board offer" with no decision reference at all. Left as a flagged finding since closing it is a product decision (should it require the same hard-stop as special discounts?), not a mechanical fix.
## Carnet eligibility
**Confirmed dead code, not fixed** — three separate `canPrintCarnet()` implementations exist (`MembershipRulesService`, `AutoFreezeService`, `Payments\BalanceCalculator`), and none of them has a single caller anywhere in the app. The actual, live eligibility rule lives entirely in the **Carnets** module (`CarnetPrintService::checkEligibility()`), which additionally requires a photo to be on file — a requirement none of the three dead implementations even check for. Worth knowing so you check the right file if carnet-eligibility behavior ever needs to change; the three files sitting inside `Members/Services/` that look like they'd be the answer are not what's actually running.
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