Refactor CustomerPlanService to use FacilityRepository, remove PlaceService, and introduce RfidTagService

- Updated CustomerPlanService to replace references to PlaceRepo with FacilityRepository for managing customer plans and associated facilities.
- Removed PlaceService and its related methods, consolidating functionality into FacilityService.
- Added RfidTagService for managing RFID tags, including methods for creating, updating, and setting statuses of tags.
- Updated TenantDbContext to include new DbSet for RfidTags and CustomerPlanFacilities, and adjusted model configurations accordingly.
- Enhanced FacilityService to support new booking modes and validation for facility creation and updates.
This commit is contained in:
2026-09-29 13:02:52 +05:30
parent bffc9b1686
commit 7d8a8478ff
39 changed files with 1596 additions and 678 deletions
+7 -7
View File
@@ -37,13 +37,13 @@ namespace Fitness_core_BE.Models
[MaxLength(160)]
public string? HardwareId { get; set; }
// Which Place (physical zone) this terminal sits in. Null = unscoped: reachable by
// every plan regardless of that plan's Places (keeps pre-existing devices and
// plans without any place restriction working unchanged). Unique when set --
// exactly one device per place for now (see Models/Place.cs).
public Guid? FKPlaceId { get; set; }
[ForeignKey(nameof(FKPlaceId))]
public Place? Place { get; set; }
// Which Facility (physical zone) this terminal sits in. Null = unscoped: reachable by
// every plan regardless of that plan's Facilities (keeps pre-existing devices and
// plans without any facility restriction working unchanged). Unique when set --
// exactly one device per facility for now (see Models/Facility.cs).
public Guid? FKFacilityId { get; set; }
[ForeignKey(nameof(FKFacilityId))]
public Facility? Facility { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? LastSeenAt { get; set; }
+50 -8
View File
@@ -3,19 +3,53 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>One reservation against a FacilitySession — either an existing member using a
/// CustomerPlan session allowance (FKCustomerId set, IsGuest = false) or a non-member who
/// booked online and pays per-session (IsGuest = true, no membership at all). The guest's
/// identity proof for check-in is the OTP issued at booking time, verified at the door
/// instead of a fingerprint/face profile a non-member will never have.</summary>
/// <summary>One reservation against a Facility — either a Scheduled-mode booking against a
/// pre-created FacilitySession (many bookings share one capacity pool), or an AdHoc-mode
/// booking that claims one of the Facility's NumberOfUnits directly for an arbitrary time
/// window, no session needed. Either an existing member using a CustomerPlan session
/// allowance (FKCustomerId set, IsGuest = false) or a non-member who booked online and pays
/// per-session (IsGuest = true, no membership at all). The guest's identity proof for
/// check-in is the OTP issued at booking time, verified at the door instead of a
/// fingerprint/face profile a non-member will never have.</summary>
public class Booking
{
[Key]
public Guid BookingId { get; set; }
public Guid FKFacilitySessionId { get; set; }
/// <summary>Null for an AdHoc booking (no pre-created session).</summary>
public Guid? FKFacilitySessionId { get; set; }
[ForeignKey(nameof(FKFacilitySessionId))]
public FacilitySession FacilitySession { get; set; }
public FacilitySession? FacilitySession { get; set; }
/// <summary>Always set, for both modes -- copied from FacilitySession.FKFacilityId at
/// creation (Scheduled) or the booked facility directly (AdHoc). Every place that needs
/// "which facility / what time window" reads these (and StartTime/EndTime below)
/// uniformly instead of reaching through FacilitySession, which doesn't exist for AdHoc
/// bookings.</summary>
public Guid FKFacilityId { get; set; }
[ForeignKey(nameof(FKFacilityId))]
public Facility Facility { get; set; }
/// <summary>Copied from FacilitySession.StartTime (Scheduled) or the requested start
/// (AdHoc) at creation; updated in place by Reschedule.</summary>
public DateTime StartTime { get; set; }
/// <summary>Same pattern as StartTime.</summary>
public DateTime EndTime { get; set; }
/// <summary>Informational headcount for an AdHoc booking (e.g. "how many will come") --
/// never enforced as a capacity mechanic; each AdHoc booking claims one whole unit
/// regardless of party size. Null for Scheduled bookings.</summary>
public int? PartySize { get; set; }
/// <summary>The RFID tag handed to the party at check-in, picked from the club's managed
/// tag inventory (RfidTag) -- never free-typed. Released back to "Available" by the
/// Complete action or the no-show/completion sweep, whichever runs first (both use the
/// same idempotent guarded update, so a race between them is a harmless no-op).</summary>
public Guid? FKRfidTagId { get; set; }
[ForeignKey(nameof(FKRfidTagId))]
public RfidTag? RfidTag { get; set; }
public DateTime? RfidAssignedAt { get; set; }
public DateTime? RfidReturnedAt { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
@@ -58,11 +92,19 @@ namespace Fitness_core_BE.Models
[ForeignKey(nameof(FKPaymentTransactionId))]
public PaymentTransaction? PaymentTransaction { get; set; }
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow".</summary>
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow and
/// Completed are reached either by explicit staff action (MarkNoShow/Complete) or lazily,
/// swept by BookingService.SweepOverdueBookingsAsync at the top of every check-in call
/// (not a background job -- overdue bookings are reconciled the next time any check-in
/// happens for this client, across every facility).</summary>
public string Status { get; set; } = "Confirmed";
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
public DateTime? CheckedInAt { get; set; }
/// <summary>Set when the booking reaches Completed, whether via the manual Complete
/// action or the lazy overdue-sweep -- the actual moment it was reconciled, not
/// necessarily EndTime.</summary>
public DateTime? CheckedOutAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>Many-to-many join: which Facility(ies) a CustomerPlan grants access to. A plan
/// with no rows here is unrestricted (reaches every active device) -- see
/// AccessDeviceService's allowed-device resolution. NOTE: this access-scoping is stored and
/// surfaced in the UI but not actually enforced by provisioning today -- a pre-existing gap,
/// unrelated to the Facility/Place rename, left exactly as unenforced as it was when this
/// table was named CustomerPlanPlace.</summary>
public class CustomerPlanFacility
{
[Key]
public Guid CustomerPlanFacilityId { get; set; }
public Guid FKCustomerPlanId { get; set; }
[ForeignKey(nameof(FKCustomerPlanId))]
public CustomerPlan CustomerPlan { get; set; } = null!;
public Guid FKFacilityId { get; set; }
[ForeignKey(nameof(FKFacilityId))]
public Facility Facility { get; set; } = null!;
}
}
-22
View File
@@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>Many-to-many join: which Place(s) a CustomerPlan grants access to. A plan
/// with no rows here is unrestricted (reaches every active device) -- see
/// AccessDeviceService's allowed-device resolution.</summary>
public class CustomerPlanPlace
{
[Key]
public Guid CustomerPlanPlaceId { get; set; }
public Guid FKCustomerPlanId { get; set; }
[ForeignKey(nameof(FKCustomerPlanId))]
public CustomerPlan CustomerPlan { get; set; } = null!;
public Guid FKPlaceId { get; set; }
[ForeignKey(nameof(FKPlaceId))]
public Place Place { get; set; } = null!;
}
}
+25 -8
View File
@@ -3,29 +3,46 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>A bookable area of the club — the gym floor, the pool, a court, a class type
/// (karate, etc). Gym-floor access itself keeps working through the existing
/// CustomerPlan/Attendance flow unchanged; Facility/FacilitySession/Booking below are for
/// the new capacity-limited, session-based areas (pool, courts, classes).</summary>
/// <summary>A physical area of the club (e.g. "Pool Area", "Indoor Gym", "Court 1") that can
/// both be booked (Booking/FacilitySession below) and fitted with its own fingerprint/face
/// terminal for membership-plan access scoping (AccessDevice.FKFacilityId,
/// CustomerPlanFacility). Merged from the formerly-separate Facility (bookable capacity) and
/// Place (access zone) concepts -- see the booking redesign plan for the migration that
/// carried Place's rows over reusing this table's original PlaceId values as FacilityId.</summary>
public class Facility
{
[Key]
public Guid FacilityId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
/// <summary>"Gym" | "Pool" | "Court" | "Class" — informational grouping for the admin
/// UI; booking rules themselves live on FacilitySession/CustomerPlan, not on this type.</summary>
public string FacilityType { get; set; } = "Class";
/// <summary>"Scheduled" | "AdHoc". Scheduled: staff pre-creates fixed-time
/// FacilitySessions that many bookings share one capacity pool against (classes).
/// AdHoc: a booking claims one of NumberOfUnits identical units directly for an
/// arbitrary time window, no pre-created session needed (courts/pool).</summary>
public string BookingMode { get; set; } = "Scheduled";
[MaxLength(500)]
public string? Description { get; set; }
/// <summary>Default per-session headcount limit; a specific FacilitySession can override.</summary>
/// <summary>Default per-session headcount limit when BookingMode == "Scheduled"; a
/// specific FacilitySession can override.</summary>
public int DefaultCapacity { get; set; } = 1;
/// <summary>Number of identical, independently-bookable units when BookingMode ==
/// "AdHoc" (e.g. 3 for "3 courts") -- availability is this minus overlapping
/// non-cancelled/non-completed AdHoc bookings in the requested window.</summary>
public int NumberOfUnits { get; set; } = 1;
/// <summary>Hourly rate used to compute a guest AdHoc booking's suggested AmountCharged
/// (PricePerHour * duration-in-hours) at creation time.</summary>
public decimal PricePerHour { get; set; } = 0m;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
-30
View File
@@ -1,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>A physical access zone within the club (e.g. "Pool Area", "Indoor Gym"),
/// each fitted with its own fingerprint/face terminal. CustomerPlan grants access to
/// one or more Places via CustomerPlanPlace; AccessDevice.FKPlaceId says which Place a
/// terminal sits in. Deliberately separate from Facility, which models capacity-limited
/// bookable sessions (pool/court/class bookings) -- Places are about which physical
/// machine a member's access profile should reach, not about booking a time slot.</summary>
public class Place
{
[Key]
public Guid PlaceId { get; set; }
public Guid FKClientId { get; set; }
[ForeignKey(nameof(FKClientId))]
public Client Client { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? Description { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace Fitness_core_BE.Models
{
/// <summary>One physical RFID tag in a club's managed inventory, handed to a booked party at
/// check-in (Booking.FKRfidTagId) and returned when the booking completes or is swept as a
/// no-show. Deliberately a fresh, booking-scoped concept -- not TimeBankCard's RfidTagId,
/// which is a free-typed string on an unrelated prepaid-minutes card. Assignment is manual
/// (staff picks an Available tag) in this phase; the Status transitions are factored so a
/// later physical-reader integration can drive the same assign/return methods without this
/// model changing again.</summary>
public class RfidTag
{
[Key]
public Guid RfidTagId { get; set; }
public Guid FKClientId { get; set; }
/// <summary>The tag's printed/embedded serial.</summary>
[MaxLength(64)]
public string TagCode { get; set; } = string.Empty;
/// <summary>Friendly name, e.g. "Red Band #12".</summary>
[MaxLength(100)]
public string? Label { get; set; }
/// <summary>"Available" | "InUse" | "Lost" | "Retired". Only ever flips to "InUse" via a
/// booking check-in, and back via Complete/the sweep -- staff cannot set "InUse" directly
/// through the tag-management endpoints.</summary>
public string Status { get; set; } = "Available";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}