Facility booking-mode redesign, multi-plan subscriptions, and Classes

Implements the facility-classes-booking-dashboard plan (backend):

- Facility.BookingMode (single Scheduled/AdHoc choice) replaced with
  independent SupportsClasses/SupportsBooking flags, so one facility
  can run a class and accept walk-in bookings; a running class now
  blocks walk-in booking for its window on both create and reschedule.
- CustomerPlan.GrantsGymAccess + CustomerPlanClass join table let a
  customer hold one gym-access plan plus any number of class-only
  plans. Replaced the old single-subscription resolution
  (GetActiveSubscriptionByCustomerId/HasAnyActiveSubscriptionAsync)
  with gym-access-aware and class-aware resolvers across booking
  creation/cancellation, plan assignment, and customer self-service.
  Fixed ChangeMembershipPlan/renewal plan-changes, which resolved "the
  subscription to replace" by most-recent-created and would have
  deactivated a member's class subscription instead of their gym one.
- New FacilityClass model: recurring class definitions (Daily/Weekly/
  Monthly/Custom) with lazy occurrence generation (8-week horizon,
  race-safe via a unique index) and proper unlimited-capacity handling.
  "Mark not held" replaces the old bare-cancel: cascades to cancel and
  refund every confirmed booking on the occurrence through the same
  core CancelBooking uses, and frees the slot for walk-in booking.
  Scheduling conflicts (a walk-in booking now overlapping a class) are
  surfaced for staff to resolve via the existing Reschedule flow.
- Renamed the pre-existing "AdHoc" booking-mode terminology to
  "WalkIn" throughout (routes, methods, DTOs, the stored BookingMode
  value) for a clearer, simpler name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:26:59 +05:30
parent 1a502767ba
commit 5d711bd13e
38 changed files with 1437 additions and 226 deletions
+32 -5
View File
@@ -73,6 +73,11 @@ namespace Fitness_core_BE
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
public DbSet<CustomerPlanFacility> CustomerPlanFacilities => Set<CustomerPlanFacility>();
// Recurring class definitions + their generated FacilitySession occurrences (see
// Models/FacilityClass.cs), and which CustomerPlan(s) are sold against a class.
public DbSet<FacilityClass> FacilityClasses => Set<FacilityClass>();
public DbSet<CustomerPlanClass> CustomerPlanClasses => Set<CustomerPlanClass>();
// Booking-scoped RFID tag inventory (assigned to a Booking at check-in, released at
// Complete/no-show sweep) -- see Models/RfidTag.cs. Not to be confused with
// TimeBankCard's own, unrelated RfidTagId string field.
@@ -197,6 +202,7 @@ namespace Fitness_core_BE
// doesn't, since Client lives only in CentralDbContext).
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
modelBuilder.Entity<FacilityClass>().Ignore(fc => fc.Client);
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
@@ -210,10 +216,10 @@ namespace Fitness_core_BE
// A session's capacity is enforced by counting non-cancelled bookings against it at
// booking time, not by a unique index -- multiple guests/members can share one slot
// up to Facility.DefaultCapacity. What must stay unique is a member double-booking
// the same session twice. FKFacilitySessionId is null for every AdHoc booking, and
// the same session twice. FKFacilitySessionId is null for every WalkIn booking, and
// MySQL/InnoDB never treats two NULLs as duplicates in a unique index, so this index
// is naturally a no-op for AdHoc bookings -- their own overlap-vs-NumberOfUnits check
// lives in BookingRepository.GetOverlappingAdhocBookingCountAsync instead.
// is naturally a no-op for WalkIn bookings -- their own overlap-vs-NumberOfUnits check
// lives in FacilityRepository.GetOverlappingWalkInBookingCountAsync instead.
modelBuilder.Entity<Booking>()
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
.IsUnique()
@@ -222,6 +228,17 @@ namespace Fitness_core_BE
modelBuilder.Entity<FacilitySession>()
.HasIndex(s => new { s.FKFacilityId, s.StartTime });
modelBuilder.Entity<FacilityClass>()
.HasIndex(fc => fc.FKFacilityId);
// Guards against double-generating the same occurrence when two concurrent calendar
// loads both trigger EnsureOccurrencesGeneratedAsync for the same class -- a dedup
// check in code alone isn't race-safe. MySQL unique indexes ignore NULL, so every
// pre-existing, non-class session (FKFacilityClassId null) is unaffected.
modelBuilder.Entity<FacilitySession>()
.HasIndex(s => new { s.FKFacilityClassId, s.StartTime })
.IsUnique();
modelBuilder.Entity<TimeBankCard>()
.HasIndex(t => t.RfidTagId)
.IsUnique();
@@ -283,15 +300,25 @@ namespace Fitness_core_BE
.HasOne(pf => pf.Facility).WithMany()
.HasForeignKey(pf => pf.FKFacilityId).OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<CustomerPlanClass>()
.HasIndex(pc => new { pc.FKCustomerPlanId, pc.FKFacilityClassId })
.IsUnique();
modelBuilder.Entity<CustomerPlanClass>()
.HasOne(pc => pc.CustomerPlan).WithMany()
.HasForeignKey(pc => pc.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CustomerPlanClass>()
.HasOne(pc => pc.FacilityClass).WithMany()
.HasForeignKey(pc => pc.FKFacilityClassId).OnDelete(DeleteBehavior.Restrict);
// RFID tag inventory is per-client; a tag code only needs to be unique within one
// club's pool.
modelBuilder.Entity<RfidTag>()
.HasIndex(t => new { t.FKClientId, t.TagCode })
.IsUnique();
// AdHoc bookings share a facility's NumberOfUnits, checked by counting overlapping
// WalkIn bookings share a facility's NumberOfUnits, checked by counting overlapping
// non-cancelled/non-completed bookings at creation/reschedule time (see
// BookingRepository.GetOverlappingAdhocBookingCountAsync) rather than a DB
// FacilityRepository.GetOverlappingWalkInBookingCountAsync) rather than a DB
// constraint -- an exclusion constraint over a time range isn't expressible as a
// simple unique index, and MySQL/Pomelo has no range-overlap index type. This index
// just makes that overlap query efficient.