Commit a77f34ec authored by DevPilot's avatar DevPilot

docs: extreme-detail reference for SA core structure

Disciplines, academies/contracts, programs, groups (enroll vs force-enroll,
the three disagreeing pause implementations, medical grace-period sweep),
coaches, facilities/units, pricing resolution algorithm, attendance,
player cards, and both SA reports. Documents the coach-cost fix's real
impact (9 of 25 active coaches previously cost 0 in the finance report)
and flags the card-renewal duration bug as a deliberate fix, not a
quick patch, since it touches both fee calculation and payment payload.
parent 9c54a882
# SportsActivity — Core Structure (Disciplines, Academies, Programs, Groups, Coaches, Facilities, Pricing, Attendance, Cards, Reports)
The foundational hierarchy every SportsActivity screen sits on: **Discipline → Academy (optional) → Program → Group → Player enrollment**, plus Facilities/Units, Coaches, Pricing, Attendance, and Cards. Wizards and gate/gap-tool screens are documented separately.
---
## Disciplines
CRUD-only: code (unique, uppercased), name, category (individual/team/racket/aquatic/combat/leisure — a hardcoded list, not DB-driven), sport_type (anything other than exactly `recreational` silently becomes `training` — no validation error shown), icon, sort order. Only reachable through Program — a discipline has no direct link to a group. Toggling active/inactive is the only lifecycle action; there's no hard delete.
## Academies & Academy Contracts
Academy itself is thin — code, name, discipline, contact info. All commercial terms live on the **Contract**: type (revenue_share/fixed_rent/hybrid), commission/share percentages (each 0–100 independently — **they are never cross-validated against each other**, so a contract can be saved with both sides claiming 80%, over-allocating 160% with no error), rent, deposit, and a required PDF upload (extension-checked only, not real MIME validation).
**Confirmed gap — the contract lifecycle is mostly aspirational.** Of the five defined statuses (draft → pending_approval → active → suspended/expired/terminated), only the `pending_approval → active` approval step has any code behind it. Nothing anywhere sets a contract to `suspended`, `expired`, or `terminated` — a contract that lapses just keeps showing `active` forever unless someone manually fixes it in the database. The `deposit_status` field is likewise never touched after creation.
**Confirmed gap — no settlement is ever computed against these contracts.** The commission/share percentages and rent are captured but never turned into an actual invoice or journal entry from this screen. This is independently corroborated by the Accounting module's own گaps screen, which explicitly documents it as a known accounting hole (see `accounting-extended.md`).
## Programs
This is where **actual pricing and capacity live** — not on the group. Monthly fee (member/non-member, flat — no per-branch or per-age variation), min/max capacity, age range (no check that min ≤ max), skill level and program type (both free-text against a suggested list, neither actually validated against it).
**Non-obvious side effect worth knowing — creating a program can silently create a group.** If any active coach teaches that discipline (or, failing that, literally *any* active coach in the whole system with no discipline filter at all), the controller auto-creates a default group under the new program — but this bypasses the normal group-creation path entirely, so the group's coach pivot table is never populated the way a normally-created group's is.
**Editing a program's capacity retroactively resizes every active group under it** — every active group's `max_capacity` and full/not-full flag are recomputed on save. Paused/completed/cancelled groups are left with their stale numbers.
## Groups — the enrollment engine
Capacity is always inherited from the Program at creation; groups have no capacity fields of their own.
### Enroll vs. Force-Enroll — the real divergence
Normal `enroll()`: checks medical status and starts a 14-day grace-period countdown if the player isn't cleared (this **never blocks** enrollment, only starts a clock); checks age range if the group has one set; checks capacity, and if full, returns a flag meant to route staff to the waitlist (see below for why that doesn't currently work); creates a pending-payment enrollment and a Cashier payment request. The group's headcount is **not** incremented until payment actually completes.
`forceEnroll()` diverges in two ways worth knowing:
1. **Skips the medical check entirely** — an unfit player who gets force-enrolled gets no grace-period countdown at all, unlike the same player going through normal enrollment.
2. **Fixed this session**: it previously always marked the group `is_full=1` regardless of actual occupancy — a single force-enrolled overflow into a 20-person group used to lock out the other 19 legitimate open seats from normal enrollment afterward. It now recomputes fullness the same way normal enrollment does.
### Three independent pause/resume implementations that disagree — pick the right one to trust
- `PlayerLifecycleService::pause()/resume()` — the "correct" one: enforces a configurable max-pause-months cap, sorts the pause history, and zeroes out any already-generated unpaid subscription for the paused month.
- Its controller wrapper — fully wired with a permission, **but has no button anywhere in the UI**. Reachable only by calling the endpoint directly.
- `Api\SubscriptionPreviewApiController::pause()/unpause()` — a **separate, hand-rolled reimplementation**, and this is the one actually wired to the pause button staff use on the subscription screen. It has **no cap check, no sort, no subscription exemption, and dispatches no events** — it silently bypasses every safeguard the "canonical" service enforces. Worth knowing if pause-related behavior ever looks inconsistent with what the code you'd expect (`PlayerLifecycleService`) should be doing.
Similarly, **group transfer has a fully-built, permission-gated endpoint with no UI button anywhere**`transferPlayer()` exists and works, but nothing in any group or player screen links to it.
**Fixed this session**: the "withdraw player" button on the group screen was gated by a different permission (`sa.group.enroll`) than the route itself required (`sa.group.manage`) — a role holding one but not the other would either see a button that always 403s, or never see the button despite being authorized. Now both check the same permission.
### Medical grace-period enforcement — the automatic sweep may not actually run
The countdown is set at enroll time and can be manually extended (again, via a route with no UI button). The automatic sweep that force-withdraws players whose grace period lapsed without clearance (`checkMedicalGraceExpired()`) exists as a method, but **no cron/scheduled call to it was found anywhere in the codebase** — worth confirming whether something external actually invokes it, since if not, an overdue player just keeps training with an expired grace deadline showing on dashboards and attendance screens as a visual warning, with no automatic consequence.
## Coaches (SportsActivity's own, distinct from the separate `Coaches` module)
Fields include employment type, coach type (independent/academy/club_employee), and payment model — **five real values exist in the live data: `per_session`, `per_player`, `monthly_fixed`, `hybrid`, `salary`** (the last one hardcoded and forced for academy-type coaches, whose hourly/session/monthly rates are all nulled out since their pay flows through the academy contract instead, not through this table). National ID, if given, overwrites any manually-entered birth date/gender via the shared parser.
**Fixed this session**: the SportsActivity Finance Report's coach-cost calculation only had a working case for `per_session` — the other four payment models (`monthly_fixed`, `per_player`, `hybrid`, and the intentionally-zero `salary`) either didn't match any switch case at all or matched non-existent legacy values (`'monthly'`, `'hourly'`) that don't exist in this schema. **On the live data, that meant 9 of 25 active coaches (36%) — everyone not on straight per-session pay — contributed zero to "coach costs" in this report, systematically overstating the net-profit figure it shows.** All five models are now costed correctly, using the same formulas the parallel `Coaches\Services\CoachPaymentService` already uses for the legacy coach system (per-session: sessions × rate; per-player: distinct players actually attended × rate; monthly-fixed: flat monthly rate; hybrid: base monthly rate plus a per-session bonus above a 20-sessions/month threshold; salary: intentionally not counted here, since it's meant to be netted through the academy contract settlement instead of billed as a direct cost — though as noted above, that settlement itself is never actually computed anywhere yet).
**Confirmed gap — there is no day-off/replacement workflow for these coaches at all.** That functionality exists only for the separate, legacy `TrainerPortal`/`Coaches` module stack (different tables entirely). If a SportsActivity coach needs a substitute for a session, there's no built-in way to request or arrange one through the app.
## Facilities & Facility Units
Facility: code, name, type, operating hours (packed into one JSON field with a slot-length, falling back to 06:00–22:00/60min if missing or malformed), optional pool-grid dimensions (capped at 25×25).
Unit: code (unique per facility, not globally), type, booking mode (exclusive vs. shared), max capacity, and **expected capacity — fixed this session**. The field was validated and accepted by the form but silently dropped before ever reaching the database, because the model's list of savable fields didn't include it. Since `BookingService` depends on this exact field as its default participant count and pricing fallback for hourly bookings, it could never actually be changed through this screen. It now saves correctly.
## Time Brackets & Pricing Rules — the exact price-resolution algorithm
This matters because more than one wizard in this system silently prices a booking at zero when nothing matches — knowing the precise resolution order tells you exactly what to check when that happens:
1. Find a time bracket for the facility whose day-of-week list includes the booking date and whose window contains the booking's **start time only** (the end time is never checked against the bracket — a booking can legally run past its bracket's boundary with no adjustment). If none matches, pricing fails outright.
2. Find a pricing rule for that exact (unit, bracket, group-size-range, date) combination, most-recently-effective one wins if several overlap. If none matches, pricing fails outright.
3. Pick member/non-member/organization rate based on booker type.
**The organization rate can basically never be used** — the pricing admin screen has no field to set `price_per_person_organization` at all, and the underlying model doesn't even list it as a savable field. It can only ever exist via direct database import.
Time brackets can only be created or soft-deleted — **there is no edit action for an existing bracket at all.** An overlap check (same facility, same day, overlapping time) only runs on creation, not on delete, which doesn't need it anyway.
## Attendance — two genuinely separate systems, correctly kept apart, but with an asymmetry worth knowing
Per-booking attendance (`sa_attendance`, ad-hoc hourly training sessions) and per-group bulk roster attendance (`sa_training_attendance`, recurring group sessions) are intentionally different tables for different concepts. Both read the same absence-threshold config (unified at 3 absences, resolving a historical mismatch noted in the architecture map).
**The two engines that watch for the threshold are not symmetric.** The bulk-roster path checks in real time, immediately after every batch of attendance is recorded, and fires a notification the moment a player crosses the threshold within the current month. The per-booking path only has a **batch** scanner that would need to run once a month against last month's data — and no cron wiring to it was found anywhere. If nothing external calls it, a player racking up absences through ad-hoc hourly bookings never triggers any notification at all; only the recurring-group attendance path actually works end to end today.
## SA Player Cards
Status machine: active → suspended (reversible) or → revoked (**terminal, no way back through this screen**). `expired` is a defined status with no controller transition into it anywhere — a card whose validity has lapsed keeps showing `active` in this table unless something external touches it (gate scanning independently checks the actual expiry date regardless of what the stored status says, so access control itself isn't affected — just the status label shown here).
**Confirmed bug, not yet fixed — renewal ignores the requested duration entirely.** Staff pick 1–24 months on the renewal form, but: the fee charged is flat regardless of how many months were requested (a 1-month and a 24-month renewal cost exactly the same), the requested month count is never even saved onto the pending payment request, and once payment clears, the completion code **hardcodes exactly 1 month** of extension no matter what was originally selected. The month-picker on this screen is currently decorative — every renewal, regardless of what's chosen, charges the 1-month fee and extends validity by exactly 1 month. This is a real revenue/entitlement mismatch worth fixing deliberately (it touches both a fee calculation and a payment-request payload, not a one-line change) rather than something to patch in passing.
## SA Reports — Players & Finance
**Players report**: an inconsistency worth knowing — the on-screen view only runs if at least one filter is set (empty filters just show nothing), but the **CSV/PDF export endpoints have no such guard** — exporting with zero filters dumps every non-archived player in the system, which the screen itself would never show you by default.
**Finance report**: covered in detail above (the coach-cost fix). Also worth knowing: the "yearly" preset uses a July–June fiscal year, everything else is plain calendar; subscription revenue counts a subscription if its billing period **overlaps** the report window at all (not strictly if it was paid within the window); registration-fee revenue is deliberately read from the actual transaction record rather than reconstructed from today's rate card, because historical rates have changed over time and a rate card can't recover what was actually charged in the past.
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