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:
+32
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user