Commit e139a8a2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(sports-activity): resolve broken queries, fee/threshold drift, and billing gaps

- ActivitySubscriptions: fix generate/calculateRate querying nonexistent
  `enrollments` table; use `academy_enrollments` with correct columns
- SportsDashboard: fix queries against nonexistent `disciplines` table;
  use `sport_disciplines`
- Sports: unify conversion-fee percentage to a single source
  (MembershipRulesService::getAthleticMemberConversionRules), preventing
  the eligibility preview from drifting from what's actually charged
- SportsActivity: align absence-threshold fallback defaults between
  AttendanceRuleService and TrainingAttendanceService via a shared constant
- SportsActivity: auto-bill first month on Registration Wizard completion,
  matching the direct-enrollment path so wizard-registered players aren't
  left uncharged until the monthly batch runs
- ActivitySubscriptions: guard paySubscription() to only transition
  pending/overdue -> paid, making it idempotent against the (currently
  unreachable) payment.completed listener path
- ActivitySubscriptions: dispatch academy.enrollment_created from the
  enroll wizard so PlayerAffairs' auto-billing listener actually fires

Also adds/updates Architecture Maps for Sports, SportsActivity,
SportsDashboard, ActivitySubscriptions and the cross-module Dependency
Graph, per this repo's mandatory architecture-map workflow.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent f46e7a77
......@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Disciplines\Models\SportDiscipline;
class EnrollWizardController extends Controller
......@@ -198,6 +199,19 @@ class EnrollWizardController extends Controller
'is_full' => ((int) $group['current_count'] + 1) >= (int) $group['max_capacity'] ? 1 : 0,
], 'id = ?', [(int) $group['id']]);
// Trigger auto-billing for the first month. The academy.enrollment_created
// listener in PlayerAffairs/EventListeners.php owns first-month subscription
// generation (including half-month proration); without this dispatch a
// wizard-enrolled player gets no activity_subscriptions row until the monthly
// generate batch is run manually. The listener requires enrollment_id,
// player_id and academy_id to all be non-zero and re-derives everything else
// (level, discipline, pricing) from the database.
EventBus::dispatch('academy.enrollment_created', [
'enrollment_id' => (int) $enrollment->id,
'player_id' => $playerId,
'academy_id' => $academyId,
]);
return $this->redirect('/players/' . $playerId)
->withSuccess('تم تسجيل اللاعب بنجاح في ' . ($group['name_ar'] ?? 'المجموعة'));
}
......
......@@ -24,17 +24,21 @@ class ActivitySubscriptionService
$db = App::getInstance()->db();
$count = 0;
// Get all active enrollments
// Get all active enrollments.
// Source table is `academy_enrollments` (there is no `enrollments` table).
// It carries `academy_id` directly, so the academy — and therefore the
// discipline — is resolved without going through `academy_levels`.
// Lifecycle columns are `enrolled_at` (start) and `dropped_at` (end);
// there are no `start_date` / `end_date` columns.
$enrollments = $db->select(
"SELECT e.*, p.player_type, a.discipline_id
FROM `enrollments` e
FROM `academy_enrollments` e
JOIN `players` p ON p.id = e.player_id
JOIN `academy_levels` al ON al.id = e.level_id
JOIN `academies` a ON a.id = al.academy_id
JOIN `academies` a ON a.id = e.academy_id
WHERE e.`status` = 'active'
AND (e.`end_date` IS NULL OR e.`end_date` >= ?)
AND e.`start_date` <= ?",
[$month . '-01', $month . '-28']
AND (e.`dropped_at` IS NULL OR DATE(e.`dropped_at`) >= ?)
AND DATE(e.`enrolled_at`) <= LAST_DAY(?)",
[$month . '-01', $month . '-01']
);
foreach ($enrollments as $enrollment) {
......@@ -52,9 +56,17 @@ class ActivitySubscriptionService
continue;
}
// Check if this is the first month and enrollment started after the 15th
$enrollmentDay = (int) date('d', strtotime($enrollment['start_date'] ?? ''));
$enrollmentMonth = date('Y-m', strtotime($enrollment['start_date'] ?? ''));
// Check if this is the first month and enrollment started after the 15th.
// `enrollment_day` is the authoritative day-of-month for half-month logic
// (see the column comment on `academy_enrollments`); fall back to the day
// component of `enrolled_at` when it is not populated.
$enrolledAt = (string) ($enrollment['enrolled_at'] ?? '');
$enrolledAtTs = $enrolledAt !== '' ? strtotime($enrolledAt) : false;
$enrollmentMonth = $enrolledAtTs !== false ? date('Y-m', $enrolledAtTs) : '';
$enrollmentDay = (int) ($enrollment['enrollment_day'] ?? 0);
if ($enrollmentDay <= 0 && $enrolledAtTs !== false) {
$enrollmentDay = (int) date('j', $enrolledAtTs);
}
$isHalfMonth = ($enrollmentMonth === $month && $enrollmentDay > 15);
// Calculate rate
......@@ -103,12 +115,13 @@ class ActivitySubscriptionService
{
$db = App::getInstance()->db();
// `academy_enrollments.academy_id` already identifies the academy, so the
// discipline is resolved via a single join to `academies`.
$enrollment = $db->selectOne(
"SELECT e.*, p.player_type, a.id as academy_id, a.discipline_id
FROM `enrollments` e
"SELECT e.*, p.player_type, a.discipline_id
FROM `academy_enrollments` e
JOIN `players` p ON p.id = e.player_id
JOIN `academy_levels` al ON al.id = e.level_id
JOIN `academies` a ON a.id = al.academy_id
JOIN `academies` a ON a.id = e.academy_id
WHERE e.`id` = ?",
[$enrollmentId]
);
......@@ -136,6 +149,16 @@ class ActivitySubscriptionService
/**
* Mark a subscription as paid.
*
* This is the authoritative pending/overdue -> paid transition for
* `activity_subscriptions`. The guard below makes it idempotent: only a
* pending or overdue subscription can transition to paid, so a second
* invocation (or the `payment.completed` listener in
* PlayerAffairs/EventListeners.php, which encodes the same predicate and
* writes the same columns) cannot re-stamp paid_at/payment_id or
* re-dispatch `activity_sub.paid` — which would double-fire card
* activation and audit logging. It also prevents an exempted or revoked
* subscription from being silently flipped to paid.
*/
public static function paySubscription(int $subscriptionId, int $paymentId): bool
{
......@@ -144,6 +167,10 @@ class ActivitySubscriptionService
return false;
}
if (!in_array((string) $sub->status, ['pending', 'overdue'], true)) {
return false;
}
$sub->update([
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
......
......@@ -6,6 +6,7 @@ namespace App\Modules\Sports\Services;
use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Pricing\Services\PricingEngine;
use App\Modules\Members\Services\MembershipRulesService;
final class SportsConversionCalculator
{
......@@ -44,8 +45,12 @@ final class SportsConversionCalculator
$priceInfo = PricingEngine::getMembershipPrice((int) $member['branch_id'], $qualCode);
$newValue = $priceInfo['price'] ?? '0.00';
$feeData = RuleEngine::get('SPORTS_CONVERSION_FEE');
$pct = $feeData['percentage'] ?? '50.00';
// Single source of truth for the athletic-member conversion fee percentage:
// MembershipRulesService::getAthleticMemberConversionRules(). SportsController::store()
// charges the registration fee off this same rule, so the preview shown here can no
// longer drift from what is actually collected.
$conversionRules = MembershipRulesService::getAthleticMemberConversionRules();
$pct = (string) ($conversionRules['conversion_percentage'] ?? '50');
$fee = bcmul($newValue, bcdiv($pct, '100', 4), 2);
return [
......
......@@ -109,4 +109,10 @@ final class SaConstants
// Cancelled booking statuses (for exclusion)
const EXCLUDED_STATUSES = ['cancelled', 'no_show'];
// Fallback used when system_config key `sa.absence_threshold` is unset.
// Shared by AttendanceRuleService (sa_attendance) and
// TrainingAttendanceService (sa_training_attendance) so both attendance
// sources apply the same threshold when the config is absent.
const DEFAULT_ABSENCE_THRESHOLD = 3;
}
......@@ -6,10 +6,11 @@ namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\SportsActivity\SaConstants;
final class AttendanceRuleService
{
private const DEFAULT_ABSENCE_THRESHOLD = 3;
private const DEFAULT_ABSENCE_THRESHOLD = SaConstants::DEFAULT_ABSENCE_THRESHOLD;
public static function checkAbsenceThreshold(int $playerId, int $groupId): ?array
{
......
......@@ -531,6 +531,47 @@ final class RegistrationWizardService
'is_full' => $newCount >= (int) $group['max_capacity'] ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
// Bill the first month immediately, mirroring the direct-enrollment path
// (SaEventListenerService::handleEnrollmentPaid). The wizard activates the
// enrollment itself and never passes through that handler, so without this
// the player's first month stays uncharged until the monthly batch runs.
//
// period_start is normalised to the 1st of the registration month so this row
// matches SubscriptionGeneratorService::generateForMonth()'s idempotency key
// (player_id + group_id + period_start = 'YYYY-MM-01') and the batch skips it
// instead of billing the same month a second time.
$subscriptionAmount = (float) ($registration['subscription_amount'] ?? 0);
if ($subscriptionAmount > 0) {
$periodStart = date('Y-m-01');
$periodEnd = date('Y-m-t');
$existingSub = $db->selectOne(
"SELECT id FROM sa_subscriptions WHERE player_id = ? AND group_id = ? AND period_start = ?",
[(int) $registration['player_id'], $groupId, $periodStart]
);
if (!$existingSub) {
$amount = number_format($subscriptionAmount, 2, '.', '');
$db->insert('sa_subscriptions', [
'subscription_number' => NumberGeneratorService::subscriptionNumber(),
'player_id' => (int) $registration['player_id'],
'group_id' => $groupId,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $amount,
'discount_amount' => '0.00',
'final_amount' => $amount,
'payment_status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $amount,
'payment_id' => $paymentId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
}
}
}
}
......
......@@ -167,7 +167,7 @@ final class TrainingAttendanceService
"SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'",
[]
);
$threshold = (int) ($thresholdRow['config_value'] ?? 5);
$threshold = (int) ($thresholdRow['config_value'] ?? SaConstants::DEFAULT_ABSENCE_THRESHOLD);
$monthStart = date('Y-m-01', strtotime($date));
$monthEnd = date('Y-m-t', strtotime($date));
......
......@@ -61,7 +61,7 @@ class SportsDashboardController extends Controller
$this->authorize('sports_dashboard.view');
$db = App::getInstance()->db();
$discipline = $db->selectOne("SELECT * FROM disciplines WHERE id = ? AND is_archived = 0", [(int) $id]);
$discipline = $db->selectOne("SELECT * FROM sport_disciplines WHERE id = ? AND is_archived = 0", [(int) $id]);
if (!$discipline) {
return $this->redirect('/sports-dashboard')->withError('الرياضة غير موجودة');
}
......@@ -182,7 +182,7 @@ class SportsDashboardController extends Controller
$db = App::getInstance()->db();
if ($level === 'sport' && $referenceId > 0) {
$discipline = $db->selectOne("SELECT * FROM disciplines WHERE id = ?", [$referenceId]);
$discipline = $db->selectOne("SELECT * FROM sport_disciplines WHERE id = ?", [$referenceId]);
$revenueComparison = DashboardMetricsService::getPreviousPeriodComparison($start, $end, 'revenue', null, $referenceId);
$demographics = DashboardMetricsService::getPlayerDemographics($referenceId);
$coaches = DashboardMetricsService::getCoachesForDiscipline($referenceId, $start, $end);
......
......@@ -72,7 +72,7 @@ final class DashboardMetricsService
SELECT d.id, d.name_ar, d.name_en,
COALESCE(SUM(p.amount), 0) AS revenue,
COUNT(DISTINCT asub.player_id) AS player_count
FROM disciplines d
FROM sport_disciplines d
LEFT JOIN activity_subscriptions asub ON asub.discipline_id = d.id AND asub.status = 'active'
LEFT JOIN payments p ON p.related_entity_type = 'discipline' AND p.related_entity_id = d.id
AND p.payment_date BETWEEN ? AND ? AND p.is_voided = 0
......
This diff is collapsed.
This diff is collapsed.
# Sports Module — Architecture Map
## Module Purpose
Manages the **"athletic/sports member"** membership sub-type — members whose `membership_type = 'sports'`
(distinct from the club's regular `working` membership tier). Tracks each such member's federation
registration, sport/discipline, and years of competitive service, and determines/tracks their eligibility
to **convert** into a full `working` membership after a minimum service period.
**Not related to activity bookings, coaching, or facility scheduling** — that is entirely owned by
`SportsActivity` (see `docs/architecture-maps/SportsActivity.md`). Despite the similar name, `Sports` is a
small satellite of the **Members** module (a special membership category), not part of the sports-activity
booking/coaching subsystem.
## System Responsibilities
1. Register a sports-member profile for a member (`sports_members` row) with federation/discipline info.
2. Collect the sports-membership fee at registration (percentage of the `working`-tier membership price).
3. Track `years_of_service` and evaluate conversion eligibility against a configurable minimum (`RuleEngine`).
4. Preview the conversion fee (JSON endpoint only — see Risk Areas, no write-side conversion exists).
## File Structure
```
app/Modules/Sports/
├── bootstrap.php — permissions only (no menu, no event listeners)
├── Routes.php
├── Controllers/
│ └── SportsController.php — index, create, store, checkConversion
├── Models/
│ └── SportsMember.php — table: sports_members
├── Services/
│ └── SportsConversionCalculator.php — eligibility + fee preview
└── Views/
├── index.php — list of active sports members
└── create.php — registration form
```
## Routes
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/sports` | `SportsController@index` | `sports.view` |
| GET | `/members/{memberId}/sports/create` | `SportsController@create` | `sports.add` |
| POST | `/members/{memberId}/sports` | `SportsController@store` | `sports.add` |
| POST | `/members/{memberId}/sports/check-conversion` | `SportsController@checkConversion` | `sports.convert` |
## Permissions
`sports.view`, `sports.add`, `sports.convert` (registered in `bootstrap.php`). No sidebar menu entry
registered, no `EventBus::listen()` calls.
## Database Schema (migration-derived — NOT live-DB verified; see Database Truth Rule)
### `sports_members` (`Phase_10_003_create_sports_members_table.php`)
| Column | Notes |
|---|---|
| id | BIGINT UNSIGNED PK |
| member_id | FK → `members.id` |
| sport_name | VARCHAR(200) |
| federation_name, federation_registration | nullable |
| registration_date | DATE nullable |
| years_of_service | INT UNSIGNED, default 0 — the eligibility driver |
| highest_competitive_level | محلي/إقليمي/قومي/دولي/أولمبي |
| is_conversion_eligible, conversion_requested | TINYINT flags |
| conversion_date, conversion_fee, conversion_receipt | set only if a conversion write-path existed (it doesn't yet) |
| status | default `active` |
| standard archive/audit columns | is_archived, archived_at/by, timestamps, created_by/updated_by |
Indexes: `member_id`, `status`.
### `sport_disciplines` (`Phase_17_001_create_sport_disciplines_table.php`)
Used only to populate the `create.php` discipline dropdown (`id, name_ar`). Not the same table as
`SportsActivity`'s `sa_disciplines`.
## Business Rules
### Registration (`SportsController::store()`)
Computes `sports_membership_fee` at registration time as a percentage of the member's `membership_value`
(falling back to `pricing_configs` for `membership_type='working'` if the stored value is 0), using
`Members\Services\MembershipRulesService::getAthleticMemberConversionRules()['conversion_percentage']`
(default 50%). Creates a payment request via `Cashier\Services\PaymentRequestService`. Dispatches
`sports.registered` (see Risk Areas — no listener consumes it).
### Conversion Eligibility (`SportsConversionCalculator::checkEligibility()`)
"Conversion" = upgrading the athlete's `sports`-type membership into the standard, more expensive
`working` tier once `years_of_service` clears a minimum.
1. Load `sports_members` row; no row → ineligible.
2. Compare `years_of_service` to `RuleEngine::get('SPORTS_MIN_YEARS')` (default 8); short → returns
`remaining` years needed.
3. If eligible on years: resolve the member's qualification code (`qualifications.code`, default `high`)
and call `Pricing\Services\PricingEngine::getMembershipPrice($branchId, $qualCode)` for the target
`working`-tier value.
4. `conversion_fee = new_membership_value * (MembershipRulesService::getAthleticMemberConversionRules()['conversion_percentage'] ?? 50) / 100`
(via `bcmul`/`bcdiv`). **Changed** — previously read `RuleEngine::get('SPORTS_CONVERSION_FEE')['percentage']`,
a second independently-configurable source that could drift from what registration actually charges.
5. Returns eligibility flag, years, min_years, target membership value, fee %, and fee — JSON preview only.
**This is preview-only.** `checkConversion()` (the `sports.convert`-gated endpoint) never writes anything —
no route/controller performs the actual conversion (member type change, fee payment request, or updating
`sports_members.conversion_requested/conversion_date/conversion_fee`). The feature is incomplete.
## Cross-Module Integration
- `SportsMember` model class is used only inside `app/Modules/Sports/` — no other module imports it.
- The **`sports_members` table** (raw SQL) is read by:
- `Archive\Services\ArchiveService` — loads sports records when archiving a member.
- `Members\Views\show.php` — shows a "complete sports profile" prompt; maps
`membership_type='sports'``payment_type='sports_membership_fee'`.
- `Audit\Services\AuditService` — Arabic label mapping for audit-log display.
- The **`sports_membership_fee`** payment-type string (not the table) is a recognized "membership
activation" payment type across `Cashier`, `Treasury`, `Dashboard` widgets, and
`Members\Services\BillingService` / `MembershipPaymentGuard`.
- Depends on: `Members\Services\MembershipRulesService` (now used by **both** `SportsController` and
`SportsConversionCalculator`), `Cashier\Services\PaymentRequestService`, `Rules\Services\RuleEngine`
(now only for `SPORTS_MIN_YEARS`), `Pricing\Services\PricingEngine`.
## Risk Areas / Technical Debt
1. **Dead event**: `sports.registered` dispatched in `SportsController::store()` has no listener anywhere.
2. **Incomplete feature**: no write-side conversion action exists; `sports.convert` only gates a JSON
eligibility/fee preview.
3. ~~**Two divergent fee-percentage sources**~~ — **RESOLVED**. Both the registration charge
(`SportsController::store()`) and the conversion-fee preview (`SportsConversionCalculator::checkEligibility()`)
now read the single source of truth
`Members\Services\MembershipRulesService::getAthleticMemberConversionRules()['conversion_percentage']`
(which itself resolves `RuleEngine::get('membership.athletic_conversion')`, default `'50'`).
The `SPORTS_CONVERSION_FEE` business-rule row (seeded by
`database/seeds/Phase_05_001_seed_all_business_rules.php`, value
`{"percentage":"50.00","min_years":8}`) is now **unreferenced by any code**. It was deliberately left in
place — deleting seeded business-rule data is a separate, riskier change. If someone edits that rule
expecting it to affect fees, nothing will happen; consider retiring the row.
4. **Remaining divergence — minimum years** (not changed, flag for follow-up): eligibility still uses
`RuleEngine::get('SPORTS_MIN_YEARS')['years']` (default 8), while
`getAthleticMemberConversionRules()` independently exposes `min_playing_years` (also default 8) from
`membership.athletic_conversion`. Same two-sources-of-truth pattern as the fee percentage was, one level
down. Unifying it was out of scope for the fix pass that resolved item 3.
This diff is collapsed.
# SportsDashboard Module — Architecture Map
## Module Purpose
Read-only analytics/BI dashboard (club-level, per-sport, per-facility) showing revenue, player
demographics, discipline/facility breakdowns, coach session counts, and popular booking time slots, with
PDF export. It is a **leaf/reporting module** — nothing depends on it, and it writes nothing.
**Critical caveat**: it queries **legacy, non-`sa_`-prefixed tables**
(`sport_disciplines`, `facilities`, `players`, `coaches`, `reservations`, `activity_subscriptions`,
`facility_zone_schedules`, `facility_time_slots`) rather than the current `sa_*`-prefixed tables used by
the actively-developed `SportsActivity` module. It may be stale/superseded — see Risk Areas.
## File Structure
```
app/Modules/SportsDashboard/
├── bootstrap.php — permissions only, no menu, no listeners
├── Routes.php
├── Controllers/
│ └── SportsDashboardController.php — clubLevel, sportLevel, facilityLevel, export
├── Services/
│ └── DashboardMetricsService.php
└── Views/
├── club_dashboard.php
├── sport_dashboard.php
└── facility_dashboard.php
```
## Routes
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/sports-dashboard` | `@clubLevel` | `sports_dashboard.view` |
| GET | `/sports-dashboard/sport/{id}` | `@sportLevel` | `sports_dashboard.view` |
| GET | `/sports-dashboard/facility/{id}` | `@facilityLevel` | `sports_dashboard.view` |
| GET | `/sports-dashboard/export` | `@export` | `sports_dashboard.export` |
## Metrics (`DashboardMetricsService`)
- `getDateRange($period, $from, $to)` — presets: day/week/month/year/3year/5year/custom.
- `getRevenue($start,$end,$facilityId,$disciplineId)` — sums `payments.amount` where
`payment_type IN ('activity_booking','pool_booking','sports_subscription')` and `is_voided=0`.
- `getPlayerDemographics($disciplineId=null)` — counts `players` by `player_type` (member/non_member).
- `getDisciplineBreakdown($start,$end)` — per-discipline revenue + `activity_subscriptions`
player count, from **`sport_disciplines`** (was a nonexistent `disciplines` table; see Risk Areas).
- `getFacilityUtilization($start,$end,$facilityId=null)` — booked `reservations` slots vs.
`facility_time_slots` × days-in-range, as a utilization %.
- `getPreviousPeriodComparison(...)` — current vs. prior-period revenue % change.
- `getCoachesForDiscipline($disciplineId,$start,$end)``coaches` joined to `facility_zone_schedules`.
- `getPopularTimeSlots($facilityId,$start,$end)` — top 10 booking hours from `reservations`.
## Views
- **club_dashboard.php** — KPI cards (revenue + prior-period %, active players, member/non-member
ratio, avg facility utilization); discipline table with drill-down; facility utilization table.
- **sport_dashboard.php** — KPI cards (revenue, registered players, coach count, session count); coach
performance table; linked academies table.
- **facility_dashboard.php** — KPI cards (revenue, bookings, utilization); popular time-slots table.
## Cross-Module Integration
- Uses `Shared\Services\PdfExportService::renderToPdf()` for export.
- Includes shared `Shared.Views._partials.time_filter` partial.
- No module calls into this controller/service — it is a pure downstream reporting consumer.
## Risk Areas / Technical Debt
1. ~~**Likely-broken query**~~ — **RESOLVED**: all three references to a table literally named
`disciplines` (`DashboardMetricsService::getDisciplineBreakdown()`,
`SportsDashboardController::sportLevel()` and `::export()`) now target **`sport_disciplines`**, which is
the FK target of `activity_subscriptions.discipline_id` and `academies.discipline_id`. Columns used
(`id`, `name_ar`, `name_en`, `is_archived`) were verified against `Phase_17_001` and all exist.
2. **Still-suspect predicate in `getDisciplineBreakdown()`** (NOT changed — outside the scope of the fix
pass, flag for follow-up): it filters `activity_subscriptions asub ON ... AND asub.status = 'active'`,
but that column's value set is `pending`/`paid`/`overdue`/`exempted`/`revoked``'active'` is not among
them. `player_count` will therefore read 0 for every discipline even now that the table name is correct.
Decide what "active player" should mean here (most likely `status IN ('pending','paid')`) before
trusting this metric.
3. **Schema generation mismatch**: `facilities`, `players`, `coaches`, `reservations`,
`activity_subscriptions`, `facility_zone_schedules`, `facility_time_slots` are real tables (created by
`Phase_17_002`, `Phase_19_001`, `Phase_43_001`, `Phase_20_001`, `Phase_22_001`, `Phase_54_003`,
`Phase_17_003`) — but they belong to the **older**, non-`sa_`-prefixed generation of the sports/activity
schema. `SportsActivity` (the actively developed module, `Phase_70+`) never appears in this dashboard's
queries at all.
4. **Overlapping/parallel dashboards**: `FacilityDashboards` module (`FacilityDashboardController`) is a
separate, similar per-facility dashboard also querying plain (non-`sa_`) `facilities`. It's unclear
which dashboard staff actually use — needs a product decision on which is authoritative, and whether
this module should be migrated to `sa_*` tables or retired.
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