Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d711bd13e | |||
| 1a502767ba | |||
| 2eb88b058e | |||
| f96db48ec2 | |||
| b1fa7d9774 | |||
| a7b3b21e5c | |||
| 51f0021e27 | |||
| 6abef835e8 | |||
| 7d8a8478ff |
@@ -152,6 +152,7 @@ namespace Fitness_core_BE
|
||||
new NavItem { Key = "memberships", Label = "Memberships", Href = "/dashboard/memberships", Icon = "CreditCard", SortOrder = 3, IsOwnerOnly = false },
|
||||
new NavItem { Key = "bookings", Label = "Bookings", Href = "/dashboard/bookings", Icon = "CalendarClock", SortOrder = 4, IsOwnerOnly = false },
|
||||
new NavItem { Key = "customer-payments", Label = "Customer Payments", ShortLabel = "Payments", Href = "/dashboard/customer-payments", Icon = "Receipt", SortOrder = 5, IsOwnerOnly = false },
|
||||
new NavItem { Key = "inventory", Label = "Inventory", Href = "/dashboard/inventory", Icon = "Package", SortOrder = 6, IsOwnerOnly = false },
|
||||
new NavItem { Key = "workout-plans", Label = "Workout Plans", Href = "/dashboard/workout-plans", Icon = "Activity", SortOrder = 7, IsOwnerOnly = false },
|
||||
new NavItem { Key = "meal-plans", Label = "Meal Plans", Href = "/dashboard/meal-plans", Icon = "Utensils", SortOrder = 8, IsOwnerOnly = false },
|
||||
new NavItem { Key = "staff", Label = "Staff", Href = "/dashboard/staff", Icon = "UsersRound", SortOrder = 9, IsOwnerOnly = false },
|
||||
|
||||
@@ -32,6 +32,18 @@ namespace Fitness_core_BE.Controllers
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateMemberBooking([FromBody] CreateMemberBookingRequest request)
|
||||
=> Response(await _bookingService.CreateMemberBooking(new { facilitySessionId = request.FacilitySessionId }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[HttpPost("api/app/bookings/walkin")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateMemberWalkInBooking([FromBody] CreateWalkInBookingRequest request)
|
||||
=> Response(await _bookingService.CreateMemberWalkInBooking(new
|
||||
{
|
||||
facilityId = request.FacilityId,
|
||||
startTime = request.StartTime.ToString("o"),
|
||||
durationMinutes = request.DurationMinutes,
|
||||
partySize = request.PartySize
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[HttpGet("api/app/bookings/mine")]
|
||||
@@ -53,7 +65,23 @@ namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
facilitySessionId = request.FacilitySessionId,
|
||||
guestName = request.GuestName,
|
||||
guestPhone = request.GuestPhone
|
||||
guestPhone = request.GuestPhone,
|
||||
customerId = request.CustomerId
|
||||
}, HttpContext));
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/public/bookings/guest/walkin")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateGuestWalkInBooking([FromBody] CreateGuestWalkInBookingRequest request)
|
||||
=> Response(await _bookingService.CreateGuestWalkInBooking(new
|
||||
{
|
||||
facilityId = request.FacilityId,
|
||||
startTime = request.StartTime.ToString("o"),
|
||||
durationMinutes = request.DurationMinutes,
|
||||
guestName = request.GuestName,
|
||||
guestPhone = request.GuestPhone,
|
||||
partySize = request.PartySize,
|
||||
pricePerHour = request.PricePerHour,
|
||||
customerId = request.CustomerId
|
||||
}, HttpContext));
|
||||
|
||||
// The actual "copy the link and share it" page -- BookingDto.ShareUrl points here.
|
||||
@@ -72,6 +100,24 @@ namespace Fitness_core_BE.Controllers
|
||||
|
||||
// ---------- Staff ----------
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpGet("api/client/bookings")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetBookings(
|
||||
[FromQuery] Guid? facilityId, [FromQuery] Guid? facilitySessionId,
|
||||
[FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] string? status)
|
||||
=> Response(await _bookingService.GetBookings(new
|
||||
{
|
||||
facilityId,
|
||||
facilitySessionId,
|
||||
fromDate = fromDate?.ToString("o"),
|
||||
toDate = toDate?.ToString("o"),
|
||||
status
|
||||
}, HttpContext));
|
||||
|
||||
// Kept as a thin backward-compat wrapper around the unified GetBookings above --
|
||||
// prefer GET api/client/bookings?facilitySessionId= going forward.
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
@@ -84,14 +130,19 @@ namespace Fitness_core_BE.Controllers
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPost("api/client/bookings/check-in")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CheckInBooking([FromBody] CheckInBookingRequest request)
|
||||
=> Response(await _bookingService.CheckInBooking(new { otpCode = request.OtpCode }, HttpContext));
|
||||
=> Response(await _bookingService.CheckInBooking(new { otpCode = request.OtpCode, rfidTagId = request.RfidTagId }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPost("api/client/bookings/{id:guid}/mark-paid")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> MarkGuestBookingPaid(Guid id, [FromBody] MarkGuestBookingPaidRequest request)
|
||||
=> Response(await _bookingService.MarkGuestBookingPaid(new { bookingId = id, amountCharged = request.AmountCharged }, HttpContext));
|
||||
=> Response(await _bookingService.MarkGuestBookingPaid(new
|
||||
{
|
||||
bookingId = id,
|
||||
amountCharged = request.AmountCharged,
|
||||
paymentMethod = request.PaymentMethod
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
@@ -99,5 +150,81 @@ namespace Fitness_core_BE.Controllers
|
||||
[HttpPost("api/client/bookings/{id:guid}/cancel")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CancelBooking(Guid id)
|
||||
=> Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPut("api/client/bookings/{id:guid}/reassign")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> ReassignBooking(Guid id, [FromBody] ReassignBookingRequest request)
|
||||
=> Response(await _bookingService.ReassignBooking(new
|
||||
{
|
||||
bookingId = id,
|
||||
guestName = request.GuestName,
|
||||
guestPhone = request.GuestPhone
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPut("api/client/bookings/{id:guid}/reschedule")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> RescheduleBooking(Guid id, [FromBody] RescheduleBookingRequest request)
|
||||
=> Response(await _bookingService.RescheduleBooking(new
|
||||
{
|
||||
bookingId = id,
|
||||
startTime = request.StartTime?.ToString("o"),
|
||||
durationMinutes = request.DurationMinutes,
|
||||
facilitySessionId = request.FacilitySessionId
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPost("api/client/bookings/{id:guid}/no-show")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> MarkNoShow(Guid id)
|
||||
=> Response(await _bookingService.MarkNoShow(new { bookingId = id }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPost("api/client/bookings/{id:guid}/complete")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CompleteBooking(Guid id, [FromBody] CompleteBookingRequest? request)
|
||||
=> Response(await _bookingService.CompleteBooking(new
|
||||
{
|
||||
bookingId = id,
|
||||
markPaid = request?.MarkPaid,
|
||||
amountCollected = request?.AmountCollected,
|
||||
paymentMethod = request?.PaymentMethod
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpGet("api/client/bookings/invoices")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetBookingInvoices([FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate)
|
||||
=> Response(await _bookingService.GetBookingInvoices(new
|
||||
{
|
||||
fromDate = fromDate?.ToString("o"),
|
||||
toDate = toDate?.ToString("o")
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpGet("api/client/bookings/dashboard-summary")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetBookingDashboardSummary()
|
||||
=> Response(await _bookingService.GetBookingDashboardSummary(new { }, HttpContext));
|
||||
|
||||
// Public, unguessable-GUID receipt page -- same "public but the link IS the credential"
|
||||
// model as ViewBooking above.
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/public/booking-invoices/{id:guid}/view")]
|
||||
public async Task<ContentResult> ViewBookingInvoice(Guid id)
|
||||
{
|
||||
var invoice = await _bookingService.GetBookingInvoiceView(new { bookingInvoiceId = id }) as BookingInvoiceDto;
|
||||
if (invoice == null)
|
||||
return Content(BookingInvoiceHtml.NotFound(), "text/html");
|
||||
|
||||
return Content(BookingInvoiceHtml.Render(invoice), "text/html");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,6 +639,8 @@ namespace Fitness_core_BE.Controllers
|
||||
if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value;
|
||||
if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming;
|
||||
if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds;
|
||||
if (request.FacilityClassId.HasValue) payload["facilityClassId"] = request.FacilityClassId.Value;
|
||||
if (request.GrantsGymAccess.HasValue) payload["grantsGymAccess"] = request.GrantsGymAccess.Value;
|
||||
|
||||
return Response(await _customerPlanService.CreateCustomerPlan(payload, HttpContext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using Fitness_core_BE.DTOs.Facility;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static Fitness_core_BE.Helpers.JwtMiddleware;
|
||||
using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||
|
||||
namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
// Recurring class definitions + their occurrences -- the Classes tab's entry point. Gated on
|
||||
// "memberships" (same as FacilityController's own mutations) since class setup is staff/
|
||||
// membership configuration, not a booking-time read. The existing GET api/client/facilities/
|
||||
// sessions* ("bookings"-gated) stays separate for the Booking Dashboard calendar.
|
||||
[ApiController]
|
||||
[Route("api/client/facility-classes")]
|
||||
public class FacilityClassController(IClassService classService, IFacilityService facilityService) : ControllerBase
|
||||
{
|
||||
private readonly IClassService _classService = classService;
|
||||
private readonly IFacilityService _facilityService = facilityService;
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpGet("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetFacilityClasses([FromQuery] bool includeInactive = false)
|
||||
=> Response(await _classService.GetFacilityClasses(new { includeInactive }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPost("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateFacilityClass([FromBody] CreateFacilityClassRequest request)
|
||||
=> Response(await _classService.CreateFacilityClass(new
|
||||
{
|
||||
facilityId = request.FacilityId,
|
||||
name = request.Name,
|
||||
description = request.Description,
|
||||
recurrenceType = request.RecurrenceType,
|
||||
startTimeOfDay = request.StartTimeOfDay,
|
||||
durationMinutes = request.DurationMinutes,
|
||||
weekDaysMask = request.WeekDaysMask,
|
||||
monthDays = request.MonthDays,
|
||||
seriesStartDate = request.SeriesStartDate?.ToString("o"),
|
||||
seriesEndDate = request.SeriesEndDate?.ToString("o"),
|
||||
capacity = request.Capacity,
|
||||
isUnlimitedCapacity = request.IsUnlimitedCapacity,
|
||||
customOccurrences = request.CustomOccurrences?.Select(o => new { startTime = o.StartTime.ToString("o"), durationMinutes = o.DurationMinutes })
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> UpdateFacilityClass(Guid id, [FromBody] UpdateFacilityClassRequest request)
|
||||
{
|
||||
var payload = new Dictionary<string, object?> { ["facilityClassId"] = id };
|
||||
if (request.Name != null) payload["name"] = request.Name;
|
||||
if (request.Description != null) payload["description"] = request.Description;
|
||||
if (request.RecurrenceType != null) payload["recurrenceType"] = request.RecurrenceType;
|
||||
if (request.StartTimeOfDay != null) payload["startTimeOfDay"] = request.StartTimeOfDay;
|
||||
if (request.DurationMinutes.HasValue) payload["durationMinutes"] = request.DurationMinutes.Value;
|
||||
if (request.WeekDaysMask.HasValue) payload["weekDaysMask"] = request.WeekDaysMask.Value;
|
||||
if (request.MonthDays != null) payload["monthDays"] = request.MonthDays;
|
||||
if (request.SeriesStartDate.HasValue) payload["seriesStartDate"] = request.SeriesStartDate.Value.ToString("o");
|
||||
if (request.SeriesEndDate.HasValue) payload["seriesEndDate"] = request.SeriesEndDate.Value.ToString("o");
|
||||
if (request.Capacity.HasValue) payload["capacity"] = request.Capacity.Value;
|
||||
if (request.IsUnlimitedCapacity.HasValue) payload["isUnlimitedCapacity"] = request.IsUnlimitedCapacity.Value;
|
||||
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||
if (request.AddCustomOccurrences != null)
|
||||
payload["addCustomOccurrences"] = request.AddCustomOccurrences.Select(o => new { startTime = o.StartTime.ToString("o"), durationMinutes = o.DurationMinutes });
|
||||
|
||||
return Response(await _classService.UpdateFacilityClass(payload, HttpContext));
|
||||
}
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpGet("{id:guid}/occurrences")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetOccurrences(Guid id, [FromQuery] bool includeCancelled = false)
|
||||
=> Response(await _classService.GetOccurrences(new { facilityClassId = id, includeCancelled }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPost("{id:guid}/occurrences/{occurrenceId:guid}/not-held")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> MarkOccurrenceNotHeld(Guid id, Guid occurrenceId, [FromBody] MarkNotHeldRequest? request)
|
||||
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = occurrenceId, reason = request?.Reason }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpGet("scheduling-conflicts")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetSchedulingConflicts()
|
||||
=> Response(await _classService.GetSchedulingConflicts(new { }, HttpContext));
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,15 @@ using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||
|
||||
namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
// Staff/owner-facing facility + facility-session management. Gated on the "bookings" nav
|
||||
// permission (NavItem seeded in CentralDbContext.OnModelCreating).
|
||||
// Staff/owner-facing facility (bookable area + access zone -- merged from the former
|
||||
// Facility/Place split) management. Reads stay on the "bookings" nav permission (the
|
||||
// Bookings page's tabs need the list); mutations (create/edit/status/device-assign) are on
|
||||
// "memberships" since facility setup now lives on the Memberships page.
|
||||
[ApiController]
|
||||
[Route("api/client/facilities")]
|
||||
public class FacilityController : ControllerBase
|
||||
public class FacilityController(IFacilityService facilityService) : ControllerBase
|
||||
{
|
||||
private readonly IFacilityService _facilityService;
|
||||
|
||||
public FacilityController(IFacilityService facilityService)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
}
|
||||
private readonly IFacilityService _facilityService = facilityService;
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
@@ -30,28 +27,34 @@ namespace Fitness_core_BE.Controllers
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPost("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateFacility([FromBody] CreateFacilityRequest request)
|
||||
=> Response(await _facilityService.CreateFacility(new
|
||||
{
|
||||
name = request.Name,
|
||||
facilityType = request.FacilityType,
|
||||
supportsClasses = request.SupportsClasses,
|
||||
supportsBooking = request.SupportsBooking,
|
||||
description = request.Description,
|
||||
defaultCapacity = request.DefaultCapacity
|
||||
defaultCapacity = request.DefaultCapacity,
|
||||
numberOfUnits = request.NumberOfUnits,
|
||||
pricePerHour = request.PricePerHour
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> UpdateFacility(Guid id, [FromBody] UpdateFacilityRequest request)
|
||||
{
|
||||
var payload = new Dictionary<string, object> { ["facilityId"] = id };
|
||||
if (request.Name != null) payload["name"] = request.Name;
|
||||
if (request.FacilityType != null) payload["facilityType"] = request.FacilityType;
|
||||
if (request.SupportsClasses.HasValue) payload["supportsClasses"] = request.SupportsClasses.Value;
|
||||
if (request.SupportsBooking.HasValue) payload["supportsBooking"] = request.SupportsBooking.Value;
|
||||
if (request.Description != null) payload["description"] = request.Description;
|
||||
if (request.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value;
|
||||
if (request.NumberOfUnits.HasValue) payload["numberOfUnits"] = request.NumberOfUnits.Value;
|
||||
if (request.PricePerHour.HasValue) payload["pricePerHour"] = request.PricePerHour.Value;
|
||||
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||
|
||||
return Response(await _facilityService.UpdateFacility(payload, HttpContext));
|
||||
@@ -59,11 +62,18 @@ namespace Fitness_core_BE.Controllers
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> SetFacilityStatus(Guid id, [FromBody] SetFacilityStatusRequest request)
|
||||
=> Response(await _facilityService.SetFacilityStatus(new { facilityId = id, isActive = request.IsActive }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPut("{id:guid}/device")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> AssignDevice(Guid id, [FromBody] AssignFacilityDeviceRequest request)
|
||||
=> Response(await _facilityService.AssignDevice(new { facilityId = id, deviceId = request.DeviceId }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
@@ -82,19 +92,33 @@ namespace Fitness_core_BE.Controllers
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpGet("sessions")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetSessions(
|
||||
[FromQuery] Guid? facilityId, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate)
|
||||
[FromQuery] Guid? facilityId, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] bool includeCancelled = false)
|
||||
=> Response(await _facilityService.GetSessions(new
|
||||
{
|
||||
facilityId,
|
||||
fromDate = fromDate?.ToString("o"),
|
||||
toDate = toDate?.ToString("o")
|
||||
toDate = toDate?.ToString("o"),
|
||||
includeCancelled
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpDelete("sessions/{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id)
|
||||
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext));
|
||||
public async Task<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id, [FromBody] MarkNotHeldRequest? request)
|
||||
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id, reason = request?.Reason }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpGet("availability")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetAvailability(
|
||||
[FromQuery] Guid? facilityId, [FromQuery] DateTime startTime, [FromQuery] int durationMinutes)
|
||||
=> Response(await _facilityService.GetAvailability(new
|
||||
{
|
||||
facilityId,
|
||||
startTime = startTime.ToString("o"),
|
||||
durationMinutes
|
||||
}, HttpContext));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Fitness_core_BE.DTOs.Inventory;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static Fitness_core_BE.Helpers.JwtMiddleware;
|
||||
using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||
|
||||
namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
// Simple inventory: Products, Suppliers, GRN (stock in), Sales (stock out) and Returns
|
||||
// (stock write-offs). All gated on the "inventory" nav permission.
|
||||
[ApiController]
|
||||
[Route("api/client/inventory")]
|
||||
public class InventoryController(IInventoryService inventoryService) : ControllerBase
|
||||
{
|
||||
private readonly IInventoryService _inventoryService = inventoryService;
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("products")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetProducts([FromQuery] bool includeInactive = false)
|
||||
=> Response(await _inventoryService.GetProducts(new { includeInactive }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("products/{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetProductById(Guid id)
|
||||
=> Response(await _inventoryService.GetProductById(new { productId = id }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPost("products")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateProduct([FromBody] CreateProductRequest request)
|
||||
=> Response(await _inventoryService.CreateProduct(new
|
||||
{
|
||||
name = request.Name,
|
||||
unitOfMeasure = request.UnitOfMeasure,
|
||||
sellingPrice = request.SellingPrice
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPut("products/{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> UpdateProduct(Guid id, [FromBody] UpdateProductRequest request)
|
||||
{
|
||||
var payload = new Dictionary<string, object?> { ["productId"] = id };
|
||||
if (request.Name != null) payload["name"] = request.Name;
|
||||
if (request.UnitOfMeasure != null) payload["unitOfMeasure"] = request.UnitOfMeasure;
|
||||
if (request.SellingPrice.HasValue) payload["sellingPrice"] = request.SellingPrice.Value;
|
||||
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||
|
||||
return Response(await _inventoryService.UpdateProduct(payload, HttpContext));
|
||||
}
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPost("products/{id:guid}/image")]
|
||||
[RequestSizeLimit(6_000_000)]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> UploadProductImage(Guid id, [FromForm] IFormFile image)
|
||||
=> Response(await _inventoryService.UploadProductImage(id, image, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("suppliers")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetSuppliers([FromQuery] bool includeInactive = false)
|
||||
=> Response(await _inventoryService.GetSuppliers(new { includeInactive }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPost("suppliers")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateSupplier([FromBody] CreateSupplierRequest request)
|
||||
=> Response(await _inventoryService.CreateSupplier(new
|
||||
{
|
||||
name = request.Name,
|
||||
phone = request.Phone,
|
||||
email = request.Email,
|
||||
address = request.Address
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("grn")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetGrns()
|
||||
=> Response(await _inventoryService.GetGrns(new { }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("grn/{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetGrnById(Guid id)
|
||||
=> Response(await _inventoryService.GetGrnById(new { grnId = id }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPost("grn")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateGrn([FromBody] CreateGrnRequest request)
|
||||
=> Response(await _inventoryService.CreateGrn(new
|
||||
{
|
||||
supplierId = request.SupplierId,
|
||||
receivedAt = request.ReceivedAt?.ToString("o"),
|
||||
notes = request.Notes,
|
||||
items = request.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, unitCost = i.UnitCost })
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("sales")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetSales()
|
||||
=> Response(await _inventoryService.GetSales(new { }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("sales/{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetSaleById(Guid id)
|
||||
=> Response(await _inventoryService.GetSaleById(new { saleId = id }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPost("sales")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateSale([FromBody] CreateSaleRequest request)
|
||||
=> Response(await _inventoryService.CreateSale(new
|
||||
{
|
||||
soldAt = request.SoldAt?.ToString("o"),
|
||||
paymentMethod = request.PaymentMethod,
|
||||
customerId = request.CustomerId,
|
||||
customerName = request.CustomerName,
|
||||
items = request.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, unitPrice = i.UnitPrice })
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("returns")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetStockReturns()
|
||||
=> Response(await _inventoryService.GetStockReturns(new { }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("returns/{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetStockReturnById(Guid id)
|
||||
=> Response(await _inventoryService.GetStockReturnById(new { stockReturnId = id }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpPost("returns")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateStockReturn([FromBody] CreateStockReturnRequest request)
|
||||
=> Response(await _inventoryService.CreateStockReturn(new
|
||||
{
|
||||
productId = request.ProductId,
|
||||
quantity = request.Quantity,
|
||||
category = request.Category,
|
||||
saleId = request.SaleId,
|
||||
grnId = request.GrnId,
|
||||
notes = request.Notes,
|
||||
returnedAt = request.ReturnedAt?.ToString("o")
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("stock-movements")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetStockMovements()
|
||||
=> Response(await _inventoryService.GetStockMovements(new { }, HttpContext));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using Fitness_core_BE.DTOs.Place;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static Fitness_core_BE.Helpers.JwtMiddleware;
|
||||
using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||
|
||||
namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
// Staff/owner-facing management of physical access zones (Places) and which fingerprint
|
||||
// terminal (AccessDevice) sits in each. Gated on "memberships" since Places exist to
|
||||
// scope membership-plan access, not bookings.
|
||||
[ApiController]
|
||||
[Route("api/client/places")]
|
||||
public class PlaceController : ControllerBase
|
||||
{
|
||||
private readonly IPlaceService _placeService;
|
||||
|
||||
public PlaceController(IPlaceService placeService)
|
||||
{
|
||||
_placeService = placeService;
|
||||
}
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpGet("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetAllPlaces([FromQuery] bool includeInactive = false)
|
||||
=> Response(await _placeService.GetAllPlaces(new { includeInactive }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPost("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreatePlace([FromBody] CreatePlaceRequest request)
|
||||
=> Response(await _placeService.CreatePlace(new
|
||||
{
|
||||
name = request.Name,
|
||||
description = request.Description
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> UpdatePlace(Guid id, [FromBody] UpdatePlaceRequest request)
|
||||
{
|
||||
var payload = new Dictionary<string, object> { ["placeId"] = id };
|
||||
if (request.Name != null) payload["name"] = request.Name;
|
||||
if (request.Description != null) payload["description"] = request.Description;
|
||||
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||
|
||||
return Response(await _placeService.UpdatePlace(payload, HttpContext));
|
||||
}
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> SetPlaceStatus(Guid id, [FromBody] SetPlaceStatusRequest request)
|
||||
=> Response(await _placeService.SetPlaceStatus(new { placeId = id, isActive = request.IsActive }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("memberships")]
|
||||
[HttpPut("{id:guid}/device")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> AssignDevice(Guid id, [FromBody] AssignPlaceDeviceRequest request)
|
||||
=> Response(await _placeService.AssignDevice(new { placeId = id, deviceId = request.DeviceId }, HttpContext));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Fitness_core_BE.DTOs.RfidTag;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static Fitness_core_BE.Helpers.JwtMiddleware;
|
||||
using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||
|
||||
namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
// Staff-facing RFID tag inventory -- the pool of physical tags handed to a booked party at
|
||||
// check-in (Booking.FKRfidTagId). "InUse" is never staff-settable here, only reached via a
|
||||
// booking check-in/complete.
|
||||
[ApiController]
|
||||
[Route("api/client/rfid-tags")]
|
||||
public class RfidTagController(IRfidTagService rfidTagService) : ControllerBase
|
||||
{
|
||||
private readonly IRfidTagService _rfidTagService = rfidTagService;
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpGet("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetAllTags([FromQuery] string? status)
|
||||
=> Response(await _rfidTagService.GetAllTags(new { status }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPost("")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CreateTag([FromBody] CreateRfidTagRequest request)
|
||||
=> Response(await _rfidTagService.CreateTag(new { tagCode = request.TagCode, label = request.Label }, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> UpdateTag(Guid id, [FromBody] UpdateRfidTagRequest request)
|
||||
{
|
||||
var payload = new Dictionary<string, object> { ["tagId"] = id };
|
||||
if (request.TagCode != null) payload["tagCode"] = request.TagCode;
|
||||
if (request.Label != null) payload["label"] = request.Label;
|
||||
|
||||
return Response(await _rfidTagService.UpdateTag(payload, HttpContext));
|
||||
}
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> SetTagStatus(Guid id, [FromBody] SetRfidTagStatusRequest request)
|
||||
=> Response(await _rfidTagService.SetTagStatus(new { tagId = id, status = request.Status }, HttpContext));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Fitness_core_BE.DTOs.Booking
|
||||
{
|
||||
/// <summary>Backs the Bookings page's Dashboard tab stat-card row.</summary>
|
||||
public class BookingDashboardSummaryDto
|
||||
{
|
||||
public int BookingsToday { get; set; }
|
||||
public decimal RevenueToday { get; set; }
|
||||
public int CheckedInNow { get; set; }
|
||||
public int UpcomingConfirmed { get; set; }
|
||||
public decimal OutstandingUnpaid { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,22 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
public class BookingDto
|
||||
{
|
||||
public Guid BookingId { get; set; }
|
||||
public Guid FacilitySessionId { get; set; }
|
||||
|
||||
/// <summary>Null for a WalkIn booking (no pre-created session).</summary>
|
||||
public Guid? FacilitySessionId { get; set; }
|
||||
public Guid FacilityId { get; set; }
|
||||
public string? FacilityName { get; set; }
|
||||
public DateTime? SessionStartTime { get; set; }
|
||||
public DateTime? SessionEndTime { get; set; }
|
||||
|
||||
/// <summary>"Scheduled" | "WalkIn" -- tells the FE which action set applies (e.g. WalkIn
|
||||
/// bookings can be rescheduled to any free time; Scheduled ones only to another
|
||||
/// session).</summary>
|
||||
public string BookingMode { get; set; } = string.Empty;
|
||||
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
|
||||
/// <summary>Informational headcount, WalkIn only.</summary>
|
||||
public int? PartySize { get; set; }
|
||||
|
||||
public bool IsGuest { get; set; }
|
||||
public Guid? CustomerId { get; set; }
|
||||
@@ -18,11 +30,20 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
public string? OtpCode { get; set; }
|
||||
public bool OtpVerified { get; set; }
|
||||
|
||||
public Guid? RfidTagId { get; set; }
|
||||
public string? RfidTagLabel { get; set; }
|
||||
public DateTime? RfidAssignedAt { get; set; }
|
||||
public DateTime? RfidReturnedAt { get; set; }
|
||||
|
||||
public string PaymentStatus { get; set; } = string.Empty;
|
||||
public decimal AmountCharged { get; set; }
|
||||
/// <summary>"Cash" | "Bank". Null until PaymentStatus becomes "Paid".</summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
public DateTime? PaidAt { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTime BookedAt { get; set; }
|
||||
public DateTime? CheckedInAt { get; set; }
|
||||
public DateTime? CheckedOutAt { get; set; }
|
||||
|
||||
/// <summary>A public, unauthenticated link to this booking's confirmation page --
|
||||
/// copy-paste-and-share receipt. Works for both member and guest bookings.</summary>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Fitness_core_BE.DTOs.Booking
|
||||
{
|
||||
/// <summary>A booking payment receipt -- deliberately separate from the membership-based
|
||||
/// CustomerInvoice, listed on its own "Invoices" tab, not mixed into Customer Payments.</summary>
|
||||
public class BookingInvoiceDto
|
||||
{
|
||||
public Guid BookingInvoiceId { get; set; }
|
||||
public Guid BookingId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
public string GuestName { get; set; } = string.Empty;
|
||||
public string? GuestPhone { get; set; }
|
||||
public string FacilityName { get; set; } = string.Empty;
|
||||
public DateTime BookingStartTime { get; set; }
|
||||
public DateTime BookingEndTime { get; set; }
|
||||
public DateTime IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Public, unauthenticated link to this receipt's printable page.</summary>
|
||||
public string? ShareUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,54 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
|
||||
[Required]
|
||||
public string GuestPhone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional link to an existing registered customer (TenantCustomerLinkId) when
|
||||
/// staff recognizes the walk-in as an already-registered member -- purely for
|
||||
/// identification/auto-fill, doesn't change payment/membership handling at all.</summary>
|
||||
public Guid? CustomerId { get; set; }
|
||||
}
|
||||
|
||||
public class CreateWalkInBookingRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid FacilityId { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||
public int DurationMinutes { get; set; }
|
||||
|
||||
public int? PartySize { get; set; }
|
||||
}
|
||||
|
||||
public class CreateGuestWalkInBookingRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid FacilityId { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||
public int DurationMinutes { get; set; }
|
||||
|
||||
[Required]
|
||||
public string GuestName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string GuestPhone { get; set; } = string.Empty;
|
||||
|
||||
public int? PartySize { get; set; }
|
||||
|
||||
/// <summary>Overrides the facility's own PricePerHour for this booking's charge only --
|
||||
/// the FE pre-fills this from the facility but lets staff adjust it.</summary>
|
||||
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")]
|
||||
public decimal? PricePerHour { get; set; }
|
||||
|
||||
/// <summary>Optional link to an existing registered customer -- see
|
||||
/// CreateGuestBookingRequest.CustomerId.</summary>
|
||||
public Guid? CustomerId { get; set; }
|
||||
}
|
||||
|
||||
public class MarkGuestBookingPaidRequest
|
||||
@@ -28,12 +76,31 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
[Required]
|
||||
[Range(0.01, double.MaxValue, ErrorMessage = "AmountCharged must be greater than zero")]
|
||||
public decimal AmountCharged { get; set; }
|
||||
|
||||
/// <summary>"Cash" | "Bank". Defaults to "Cash" if omitted.</summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body for POST .../complete. Only required when the booking is a guest booking
|
||||
/// still Unpaid -- see BookingService.CompleteBooking. Ignored otherwise (member bookings,
|
||||
/// or a guest booking already Paid).</summary>
|
||||
public class CompleteBookingRequest
|
||||
{
|
||||
public bool? MarkPaid { get; set; }
|
||||
public decimal? AmountCollected { get; set; }
|
||||
/// <summary>"Cash" | "Bank". Defaults to "Cash" if omitted.</summary>
|
||||
public string? PaymentMethod { get; set; }
|
||||
}
|
||||
|
||||
public class CheckInBookingRequest
|
||||
{
|
||||
[Required]
|
||||
public string OtpCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Picked from the club's managed RFID tag pool (GET api/client/rfid-tags?
|
||||
/// status=Available) -- never free-typed. Required in practice for a WalkIn booking's
|
||||
/// check-in; optional for Scheduled bookings that don't hand out a physical tag.</summary>
|
||||
public Guid? RfidTagId { get; set; }
|
||||
}
|
||||
|
||||
public class CancelBookingRequest
|
||||
@@ -41,4 +108,26 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
[Required]
|
||||
public Guid BookingId { get; set; }
|
||||
}
|
||||
|
||||
public class ReassignBookingRequest
|
||||
{
|
||||
public string? GuestName { get; set; }
|
||||
public string? GuestPhone { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Exactly one shape applies, matching the booking's current BookingMode: WalkIn
|
||||
/// bookings move within the SAME facility to a new StartTime/DurationMinutes; Scheduled
|
||||
/// bookings move to a different FacilitySession (also on the same facility). Only allowed
|
||||
/// while the booking is still Confirmed, and only onto a slot with room -- re-runs the same
|
||||
/// availability check as creation, excluding the booking's own current slot.</summary>
|
||||
public class RescheduleBookingRequest
|
||||
{
|
||||
// WalkIn reschedule.
|
||||
public DateTime? StartTime { get; set; }
|
||||
[Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
// Scheduled reschedule.
|
||||
public Guid? FacilitySessionId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,5 +58,14 @@ namespace Fitness_core_BE.DTOs.CustomerPlan
|
||||
/// <summary>Places this plan grants access to. Null/omitted or empty = unrestricted
|
||||
/// (reaches every active access device, today's behavior).</summary>
|
||||
public List<Guid>? PlaceIds { get; set; }
|
||||
|
||||
/// <summary>Set only by the Classes tab's "Manage Plan" action -- links this new plan to
|
||||
/// one class via CustomerPlanClass. Omitted for an ordinary gym plan.</summary>
|
||||
public Guid? FacilityClassId { get; set; }
|
||||
|
||||
/// <summary>Only meaningful when FacilityClassId is set -- whether this class plan also
|
||||
/// grants general gym-floor access ("class + gym" as one product) instead of class-only.
|
||||
/// Defaults to false (class-only) when omitted.</summary>
|
||||
public bool? GrantsGymAccess { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Fitness_core_BE.DTOs.Facility
|
||||
{
|
||||
public class FacilityClassDto
|
||||
{
|
||||
public Guid FacilityClassId { get; set; }
|
||||
public Guid FacilityId { get; set; }
|
||||
public string? FacilityName { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
/// <summary>"Daily" | "Weekly" | "Monthly" | "Custom".</summary>
|
||||
public string RecurrenceType { get; set; } = string.Empty;
|
||||
/// <summary>"HH:mm" local time -- null for Custom.</summary>
|
||||
public string? StartTimeOfDay { get; set; }
|
||||
public int DurationMinutes { get; set; }
|
||||
public int WeekDaysMask { get; set; }
|
||||
public string? MonthDays { get; set; }
|
||||
public DateTime SeriesStartDate { get; set; }
|
||||
public DateTime? SeriesEndDate { get; set; }
|
||||
public int? Capacity { get; set; }
|
||||
public bool IsUnlimitedCapacity { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A confirmed ad-hoc booking whose window overlaps an active class occurrence on
|
||||
/// the same facility -- surfaced so staff can resolve it via the existing Reschedule flow.</summary>
|
||||
public class SchedulingConflictDto
|
||||
{
|
||||
public Guid BookingId { get; set; }
|
||||
public Guid FacilityId { get; set; }
|
||||
public string? FacilityName { get; set; }
|
||||
public string? GuestName { get; set; }
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Fitness_core_BE.DTOs.Facility
|
||||
{
|
||||
public class CustomOccurrenceRequest
|
||||
{
|
||||
[Required]
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||
public int DurationMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class CreateFacilityClassRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid FacilityId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"Daily" | "Weekly" | "Monthly" | "Custom".</summary>
|
||||
[Required]
|
||||
public string RecurrenceType { get; set; } = "Weekly";
|
||||
|
||||
/// <summary>"HH:mm" local time -- required unless RecurrenceType == "Custom".</summary>
|
||||
public string? StartTimeOfDay { get; set; }
|
||||
|
||||
[Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||
public int DurationMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>Weekly only. Bit flags, Sunday = 1 through Saturday = 64.</summary>
|
||||
public int WeekDaysMask { get; set; }
|
||||
|
||||
/// <summary>Monthly only. Comma-separated day-of-month list, e.g. "1,15".</summary>
|
||||
public string? MonthDays { get; set; }
|
||||
|
||||
public DateTime? SeriesStartDate { get; set; }
|
||||
public DateTime? SeriesEndDate { get; set; }
|
||||
|
||||
public int? Capacity { get; set; }
|
||||
public bool IsUnlimitedCapacity { get; set; }
|
||||
|
||||
/// <summary>RecurrenceType == "Custom" only -- explicit occurrence windows.</summary>
|
||||
public List<CustomOccurrenceRequest>? CustomOccurrences { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateFacilityClassRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? RecurrenceType { get; set; }
|
||||
public string? StartTimeOfDay { get; set; }
|
||||
public int? DurationMinutes { get; set; }
|
||||
public int? WeekDaysMask { get; set; }
|
||||
public string? MonthDays { get; set; }
|
||||
public DateTime? SeriesStartDate { get; set; }
|
||||
public DateTime? SeriesEndDate { get; set; }
|
||||
public int? Capacity { get; set; }
|
||||
public bool? IsUnlimitedCapacity { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
/// <summary>RecurrenceType == "Custom" only -- adds more explicit occurrences without
|
||||
/// touching existing ones (Custom has no rule to regenerate from).</summary>
|
||||
public List<CustomOccurrenceRequest>? AddCustomOccurrences { get; set; }
|
||||
}
|
||||
|
||||
public class MarkNotHeldRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,19 @@ namespace Fitness_core_BE.DTOs.Facility
|
||||
{
|
||||
public Guid FacilityId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string FacilityType { get; set; } = string.Empty;
|
||||
|
||||
public bool SupportsClasses { get; set; }
|
||||
public bool SupportsBooking { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int DefaultCapacity { get; set; }
|
||||
public int NumberOfUnits { get; set; }
|
||||
public decimal PricePerHour { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Absorbed from the old PlaceDto -- at most one for now, see AccessDevice.FKFacilityId.
|
||||
public Guid? DeviceId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public class FacilitySessionDto
|
||||
@@ -19,7 +27,35 @@ namespace Fitness_core_BE.DTOs.Facility
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
/// <summary>When true, Capacity above is meaningless -- this occurrence has no cap.</summary>
|
||||
public bool IsUnlimitedCapacity { get; set; }
|
||||
public int BookedCount { get; set; }
|
||||
public bool IsCancelled { get; set; }
|
||||
/// <summary>Null for a manually-created session; set for a class occurrence.</summary>
|
||||
public Guid? FacilityClassId { get; set; }
|
||||
public string? FacilityClassName { get; set; }
|
||||
public string? NotHeldReason { get; set; }
|
||||
public DateTime? NotHeldAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>GET api/client/facilities/availability -- how much room is left for a requested
|
||||
/// window. A facility can support both booking and classes at once, so the two number pairs
|
||||
/// are independently nullable: null means "this facility doesn't offer that mode," not zero.
|
||||
/// BookingUnitsFree is forced to 0 when an active (non-cancelled) class occupies the window --
|
||||
/// a running class occupies the whole facility, not just a seat.</summary>
|
||||
public class FacilityAvailabilityDto
|
||||
{
|
||||
public Guid FacilityId { get; set; }
|
||||
public string FacilityName { get; set; } = string.Empty;
|
||||
public bool SupportsClasses { get; set; }
|
||||
public bool SupportsBooking { get; set; }
|
||||
public int? BookingTotalUnits { get; set; }
|
||||
public int? BookingUnitsFree { get; set; }
|
||||
public int? ClassTotalCapacity { get; set; }
|
||||
public int? ClassUnitsFree { get; set; }
|
||||
/// <summary>True when an overlapping class occurrence has no headcount cap -- when set,
|
||||
/// ClassTotalCapacity/ClassUnitsFree stay null (unbounded, not zero).</summary>
|
||||
public bool ClassIsUnlimitedCapacity { get; set; }
|
||||
public decimal PricePerHour { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,22 @@ namespace Fitness_core_BE.DTOs.Facility
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>"Gym" | "Pool" | "Court" | "Class".</summary>
|
||||
[Required]
|
||||
public string FacilityType { get; set; } = "Class";
|
||||
/// <summary>At least one of SupportsClasses/SupportsBooking must be true.</summary>
|
||||
public bool SupportsClasses { get; set; } = true;
|
||||
public bool SupportsBooking { get; set; } = false;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Required (meaningfully) when SupportsClasses.</summary>
|
||||
[Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")]
|
||||
public int DefaultCapacity { get; set; } = 1;
|
||||
|
||||
/// <summary>Required (meaningfully) when SupportsBooking -- e.g. 3 for "3 courts".</summary>
|
||||
[Range(1, int.MaxValue, ErrorMessage = "NumberOfUnits must be at least 1")]
|
||||
public int NumberOfUnits { get; set; } = 1;
|
||||
|
||||
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")]
|
||||
public decimal PricePerHour { get; set; } = 0m;
|
||||
}
|
||||
|
||||
public class UpdateFacilityRequest
|
||||
@@ -22,9 +30,12 @@ namespace Fitness_core_BE.DTOs.Facility
|
||||
[Required]
|
||||
public Guid FacilityId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? FacilityType { get; set; }
|
||||
public bool? SupportsClasses { get; set; }
|
||||
public bool? SupportsBooking { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? DefaultCapacity { get; set; }
|
||||
public int? NumberOfUnits { get; set; }
|
||||
public decimal? PricePerHour { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -48,4 +59,22 @@ namespace Fitness_core_BE.DTOs.Facility
|
||||
/// <summary>Overrides the facility's default capacity for this specific session.</summary>
|
||||
public int? Capacity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Absorbed from the old AssignPlaceDeviceRequest.</summary>
|
||||
public class AssignFacilityDeviceRequest
|
||||
{
|
||||
// Null unassigns the facility's current device.
|
||||
public Guid? DeviceId { get; set; }
|
||||
}
|
||||
|
||||
public class GetFacilityAvailabilityRequest
|
||||
{
|
||||
public Guid? FacilityId { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||
public int DurationMinutes { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
namespace Fitness_core_BE.DTOs.Inventory
|
||||
{
|
||||
public class ProductDto
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Sku { get; set; }
|
||||
/// <summary>Base64 data URL, computed on read from ImageId -- null when no image is set.</summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
public string UnitOfMeasure { get; set; } = string.Empty;
|
||||
public decimal CostPrice { get; set; }
|
||||
public decimal SellingPrice { get; set; }
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One row in a Product's detail-page stock-movement history.</summary>
|
||||
public class ProductMovementDto
|
||||
{
|
||||
/// <summary>"GRN" | "Sale" | "Return".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
/// <summary>The GRN/Sale number, or the return's Category.</summary>
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
/// <summary>Signed: positive for a GRN (stock in), negative for a Sale or Return (stock out).</summary>
|
||||
public decimal QuantityChange { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
}
|
||||
|
||||
public class ProductDetailDto : ProductDto
|
||||
{
|
||||
public List<ProductMovementDto> Movements { get; set; } = [];
|
||||
}
|
||||
|
||||
public class SupplierDto
|
||||
{
|
||||
public Guid SupplierId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class GoodsReceivedNoteItemDto
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
|
||||
public class GoodsReceivedNoteDto
|
||||
{
|
||||
public Guid GrnId { get; set; }
|
||||
public string GrnNumber { get; set; } = string.Empty;
|
||||
/// <summary>Null when this GRN was recorded without a supplier.</summary>
|
||||
public Guid? SupplierId { get; set; }
|
||||
public string? SupplierName { get; set; }
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public List<GoodsReceivedNoteItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
public class SaleItemDto
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
|
||||
public class SaleDto
|
||||
{
|
||||
public Guid SaleId { get; set; }
|
||||
public string SaleNumber { get; set; } = string.Empty;
|
||||
public DateTime SoldAt { get; set; }
|
||||
public string PaymentMethod { get; set; } = string.Empty;
|
||||
public Guid? CustomerId { get; set; }
|
||||
public string? CustomerName { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public List<SaleItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>One row in the Stock Tracking tab's unified ledger -- every GRN line, sale line
|
||||
/// and return across every product, flattened into one shape for a single filterable table.
|
||||
/// Not persisted; built on read from GRNs/Sales/Returns.</summary>
|
||||
public class StockMovementDto
|
||||
{
|
||||
/// <summary>"GRN" | "Sale" | "Return".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
/// <summary>The GRN/Sale number this line belongs to, or the return's Category label.</summary>
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
/// <summary>Only set for Type == "Return" -- the write-off/return category.</summary>
|
||||
public string? Category { get; set; }
|
||||
/// <summary>Signed: positive for a GRN or a CustomerReturn (stock in), negative for a
|
||||
/// Sale or any other return category (stock out).</summary>
|
||||
public decimal QuantityChange { get; set; }
|
||||
/// <summary>This line's monetary value -- a GRN/Sale line's LineTotal, or a return's Amount.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
}
|
||||
|
||||
public class StockReturnDto
|
||||
{
|
||||
public Guid StockReturnId { get; set; }
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public string Category { get; set; } = string.Empty;
|
||||
/// <summary>Optional traceability link -- which sale this return came from, if any.</summary>
|
||||
public Guid? SaleId { get; set; }
|
||||
public string? SaleNumber { get; set; }
|
||||
/// <summary>Optional traceability link -- which GRN this return relates to, if any.</summary>
|
||||
public Guid? GrnId { get; set; }
|
||||
public string? GrnNumber { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime ReturnedAt { get; set; }
|
||||
/// <summary>Quantity * Product.CostPrice at the moment of the return -- auto-computed.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Fitness_core_BE.DTOs.Inventory
|
||||
{
|
||||
public class CreateProductRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string UnitOfMeasure { get; set; } = "pcs";
|
||||
|
||||
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "SellingPrice cannot be negative")]
|
||||
public decimal SellingPrice { get; set; } = 0m;
|
||||
}
|
||||
|
||||
/// <summary>Sku is system-generated at creation and never included here -- it can't be edited.</summary>
|
||||
public class UpdateProductRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? UnitOfMeasure { get; set; }
|
||||
public decimal? SellingPrice { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSupplierRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Address { get; set; }
|
||||
}
|
||||
|
||||
public class GrnItemRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid ProductId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "UnitCost cannot be negative")]
|
||||
public decimal UnitCost { get; set; }
|
||||
}
|
||||
|
||||
public class CreateGrnRequest
|
||||
{
|
||||
/// <summary>Optional -- some GRNs don't have a supplier on file.</summary>
|
||||
public Guid? SupplierId { get; set; }
|
||||
public DateTime? ReceivedAt { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
[Required, MinLength(1, ErrorMessage = "At least one item is required")]
|
||||
public List<GrnItemRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
public class SaleItemRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid ProductId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "UnitPrice cannot be negative")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSaleRequest
|
||||
{
|
||||
public DateTime? SoldAt { get; set; }
|
||||
|
||||
/// <summary>"Cash" | "Bank".</summary>
|
||||
[Required]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
public Guid? CustomerId { get; set; }
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
[Required, MinLength(1, ErrorMessage = "At least one item is required")]
|
||||
public List<SaleItemRequest> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CreateStockReturnRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid ProductId { get; set; }
|
||||
|
||||
[Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
/// <summary>"Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other".
|
||||
/// Only "CustomerReturn" adds back to stock; every other category removes from it.</summary>
|
||||
[Required]
|
||||
public string Category { get; set; } = "Other";
|
||||
|
||||
/// <summary>Optional traceability link -- e.g. a customer return that came from a
|
||||
/// specific sale. Many returns (damaged shelf stock, etc.) don't have one.</summary>
|
||||
public Guid? SaleId { get; set; }
|
||||
|
||||
/// <summary>Optional traceability link -- e.g. stock sent back to the supplier from a
|
||||
/// specific GRN. Many returns don't have one.</summary>
|
||||
public Guid? GrnId { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? ReturnedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace Fitness_core_BE.DTOs.Place
|
||||
{
|
||||
public class PlaceDto
|
||||
{
|
||||
public Guid PlaceId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// At most one for now -- see AccessDevice.FKPlaceId.
|
||||
public Guid? DeviceId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Fitness_core_BE.DTOs.Place
|
||||
{
|
||||
public class CreatePlaceRequest
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class UpdatePlaceRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid PlaceId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class SetPlaceStatusRequest
|
||||
{
|
||||
[Required]
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class AssignPlaceDeviceRequest
|
||||
{
|
||||
// Null unassigns the place's current device.
|
||||
public Guid? DeviceId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Fitness_core_BE.DTOs.RfidTag
|
||||
{
|
||||
public class RfidTagDto
|
||||
{
|
||||
public Guid RfidTagId { get; set; }
|
||||
public string TagCode { get; set; } = string.Empty;
|
||||
public string? Label { get; set; }
|
||||
|
||||
/// <summary>"Available" | "InUse" | "Lost" | "Retired".</summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Fitness_core_BE.DTOs.RfidTag
|
||||
{
|
||||
public class CreateRfidTagRequest
|
||||
{
|
||||
[Required]
|
||||
public string TagCode { get; set; } = string.Empty;
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateRfidTagRequest
|
||||
{
|
||||
public string? TagCode { get; set; }
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
public class SetRfidTagStatusRequest
|
||||
{
|
||||
/// <summary>"Available" | "Lost" | "Retired" only -- "InUse" is never staff-settable
|
||||
/// directly, only reached via a booking check-in.</summary>
|
||||
[Required]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Fitness_core_BE.DTOs.Booking;
|
||||
using System.Net;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Minimal, dependency-free HTML receipt for a booking payment -- mirrors BookingViewHtml.cs's
|
||||
// "self-contained page, share the link" pattern, kept as its own file since a receipt's
|
||||
// content (itemized charge, payment method) is different enough from the booking confirmation
|
||||
// page to not share a template.
|
||||
public static class BookingInvoiceHtml
|
||||
{
|
||||
public static string Render(BookingInvoiceDto invoice)
|
||||
{
|
||||
var when = $"{invoice.BookingStartTime:dddd, dd MMM yyyy}, {invoice.BookingStartTime:h:mm tt} - {invoice.BookingEndTime:h:mm tt}";
|
||||
|
||||
return $$"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Payment Receipt - Vijaya Sport Club</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif; background: #f3f4f6; margin: 0; padding: 24px 16px; color: #111827; }
|
||||
.card { max-width: 420px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
|
||||
.header { background: #0f172a; color: #fff; padding: 20px; }
|
||||
.header h1 { margin: 0; font-size: 18px; }
|
||||
.body { padding: 20px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
|
||||
.row .label { color: #6b7280; font-size: 13px; }
|
||||
.row .value { font-weight: 600; font-size: 13px; text-align: right; }
|
||||
.total { margin-top: 16px; text-align: center; background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 16px; }
|
||||
.total-label { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
|
||||
.total-amount { font-size: 28px; font-weight: 700; color: #0f172a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header"><h1>Vijaya Sport Club — Receipt</h1></div>
|
||||
<div class="body">
|
||||
<div class="row"><span class="label">Facility</span><span class="value">{{Encode(invoice.FacilityName)}}</span></div>
|
||||
<div class="row"><span class="label">Session</span><span class="value">{{Encode(when)}}</span></div>
|
||||
<div class="row"><span class="label">Paid by</span><span class="value">{{Encode(invoice.GuestName)}}</span></div>
|
||||
<div class="row"><span class="label">Payment Method</span><span class="value">{{Encode(invoice.PaymentMethod)}}</span></div>
|
||||
<div class="row"><span class="label">Issued</span><span class="value">{{invoice.IssuedAt:dd MMM yyyy, h:mm tt}}</span></div>
|
||||
<div class="total">
|
||||
<div class="total-label">Amount Paid</div>
|
||||
<div class="total-amount">Rs. {{invoice.Amount:N2}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
public static string NotFound() => """
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8" /><title>Receipt not found</title></head>
|
||||
<body style="font-family: sans-serif; text-align: center; padding: 60px 20px; color: #6b7280;">
|
||||
<h2>Receipt not found</h2>
|
||||
<p>This link may be invalid.</p>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? "");
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,7 @@ namespace Fitness_core_BE.Helpers
|
||||
public static string Render(BookingDto booking)
|
||||
{
|
||||
var who = booking.IsGuest ? Encode(booking.GuestName) : "Member booking";
|
||||
var when = booking.SessionStartTime.HasValue
|
||||
? $"{booking.SessionStartTime:dddd, dd MMM yyyy}, {booking.SessionStartTime:h:mm tt} - {booking.SessionEndTime:h:mm tt}"
|
||||
: "-";
|
||||
var when = $"{booking.StartTime:dddd, dd MMM yyyy}, {booking.StartTime:h:mm tt} - {booking.EndTime:h:mm tt}";
|
||||
|
||||
var statusColor = booking.Status switch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Every gym this runs for is Sri Lanka-based -- shared by SubscriptionRenewalJob (daily
|
||||
// trigger wake-up) and the Classes feature (converting a class's local StartTimeOfDay to
|
||||
// UTC per generated occurrence). Lifted out of SubscriptionRenewalJob rather than each
|
||||
// caller re-deriving it.
|
||||
public static class TimeZoneHelper
|
||||
{
|
||||
public static readonly TimeZoneInfo SriLanka = ResolveSriLankaTimeZone();
|
||||
|
||||
private static TimeZoneInfo ResolveSriLankaTimeZone()
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
|
||||
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
|
||||
// zone is a safe fallback rather than failing the whole caller.
|
||||
return TimeZoneInfo.CreateCustomTimeZone(
|
||||
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,37 @@ namespace Fitness_core_BE.Interfaces
|
||||
Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId);
|
||||
Task<List<Booking>> GetBookingsForCustomer(Guid customerId);
|
||||
Task<bool> CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId);
|
||||
|
||||
/// <summary>Unified roster query covering both Scheduled and WalkIn bookings -- backs
|
||||
/// GET api/client/bookings. All filters are optional/AND-combined.</summary>
|
||||
Task<List<Booking>> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status);
|
||||
|
||||
// --- Lazy overdue reconciliation (BookingService.SweepOverdueBookingsAsync), run at the
|
||||
// top of every check-in AND every checkout instead of a background job -- see
|
||||
// Booking.Status doc comment. ---
|
||||
|
||||
/// <summary>Bulk-flips every Confirmed booking for this client whose EndTime has passed
|
||||
/// to NoShow. Returns the number of rows affected (informational/logging only).</summary>
|
||||
Task<int> MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now);
|
||||
|
||||
/// <summary>Atomically flips a tag from Available to InUse -- the ONE place a real race
|
||||
/// matters (two staff picking the same tag for two different bookings must not both
|
||||
/// win), so this uses a conditional bulk update rather than read-then-write. Returns
|
||||
/// false if the tag was no longer Available.</summary>
|
||||
Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now);
|
||||
|
||||
// --- Booking invoices (snapshot receipts created when a payment is recorded) ---
|
||||
|
||||
Task<BookingInvoice> CreateBookingInvoice(BookingInvoice invoice);
|
||||
|
||||
/// <summary>Listed on their own "Invoices" tab -- deliberately not mixed into the
|
||||
/// membership-based Customer Payments view.</summary>
|
||||
Task<List<BookingInvoice>> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc);
|
||||
|
||||
Task<BookingInvoice?> GetBookingInvoiceById(Guid bookingInvoiceId);
|
||||
|
||||
/// <summary>Aggregate counts/sums backing the Bookings page's Dashboard tab.</summary>
|
||||
Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)>
|
||||
GetDashboardSummaryAsync(Guid clientId, DateTime now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ namespace Fitness_core_BE.Interfaces
|
||||
// gym's own schema) -- no clientId parameter needed.
|
||||
Task<TenantCustomerLink?> FindActiveLinkByIdentifierAsync(string identifier);
|
||||
|
||||
// Staff picking an existing member to link a walk-in booking to -- identification only.
|
||||
Task<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id);
|
||||
|
||||
Task<bool> DeleteCustomer(Guid customerId);
|
||||
// Client-initiated delete: deactivates only this customer's presence at the calling
|
||||
// client's gym (subscriptions/members/plan assignments scoped to clientId). The shared
|
||||
|
||||
@@ -23,14 +23,31 @@ namespace Fitness_core_BE.Interfaces
|
||||
Task<CustomerCards?> GetCardByIdAsync(Guid cardId, CancellationToken ct = default);
|
||||
Task<CustomerSubscription?> GetCustomerSubscriptionById(Guid subscriptionId);
|
||||
Task<List<CustomerSubscription>> GetCustomerSubscriptionsByCustomerId(Guid customerId);
|
||||
Task<CustomerSubscription?> GetActiveSubscriptionByCustomerId(Guid customerId);
|
||||
/// <summary>True for an ordinary plan (no CustomerPlanClass links) or a class-linked
|
||||
/// plan with GrantsGymAccess == true.</summary>
|
||||
Task<bool> PlanGrantsGymAccessAsync(Guid customerPlanId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The customer's active subscription whose plan grants gym access, if any --
|
||||
/// at most one can exist (enforced by HasActiveGymAccessPlanAsync at assignment time).
|
||||
/// Primary-only, same as the old GetActiveSubscriptionByCustomerId this replaces.</summary>
|
||||
Task<CustomerSubscription?> GetActiveGymAccessSubscriptionAsync(Guid customerId);
|
||||
|
||||
/// <summary>The customer's active subscription on this exact plan, if any -- the
|
||||
/// idempotency check for re-assigning a class-only plan (which, unlike a gym plan,
|
||||
/// doesn't block on ANY other active plan, only a duplicate of itself).</summary>
|
||||
Task<CustomerSubscription?> GetActiveSubscriptionForPlanAsync(Guid customerId, Guid customerPlanId);
|
||||
|
||||
/// <summary>The subscription that covers booking a specific class, preferring one with
|
||||
/// quota left this cycle. Null if none of the customer's active subscriptions link to
|
||||
/// this class at all.</summary>
|
||||
Task<CustomerSubscription?> GetActiveSubscriptionForClassAsync(Guid customerId, Guid facilityClassId);
|
||||
|
||||
// Whether this customer is currently an active member (primary OR secondary/group) of
|
||||
// ANY subscription at this gym. GetActiveSubscriptionByCustomerId only sees subscriptions
|
||||
// where they're the primary -- this also catches a group co-member, so "one customer, one
|
||||
// plan at a time" can be enforced everywhere a plan gets assigned, not just the primary
|
||||
// path.
|
||||
Task<bool> HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default);
|
||||
// ANY gym-access subscription at this gym -- a class-only subscription never counts, so
|
||||
// unlimited concurrent class-only subscriptions are allowed. "One customer, one
|
||||
// GYM-ACCESS plan at a time" is enforced everywhere a gym plan gets assigned, not just
|
||||
// the primary path.
|
||||
Task<bool> HasActiveGymAccessPlanAsync(Guid customerId, CancellationToken ct = default);
|
||||
|
||||
Task<SubscriptionMember> AddSubscriptionMemberAsync(SubscriptionMember member);
|
||||
Task<int> GetActiveMemberCountAsync(Guid subscriptionId);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Fitness_core_BE.Models;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces
|
||||
{
|
||||
public interface IFacilityClassRepository
|
||||
{
|
||||
Task<FacilityClass> CreateFacilityClass(FacilityClass facilityClass);
|
||||
Task<List<FacilityClass>> GetFacilityClasses(Guid clientId, bool includeInactive = false);
|
||||
Task<FacilityClass?> GetFacilityClassById(Guid facilityClassId);
|
||||
Task<FacilityClass> UpdateFacilityClass(FacilityClass facilityClass);
|
||||
|
||||
/// <summary>Active, non-Custom classes whose OccurrencesGeneratedThrough hasn't reached
|
||||
/// the horizon yet -- Custom classes are never generated, their occurrences are always
|
||||
/// written explicitly.</summary>
|
||||
Task<List<FacilityClass>> GetClassesNeedingGenerationAsync(Guid clientId, DateTime horizon);
|
||||
|
||||
/// <summary>Existing occurrence start times for this class in [from, to) -- for
|
||||
/// pre-generation dedup. The unique (FKFacilityClassId, StartTime) index is the actual
|
||||
/// race-safety net; this just avoids the common sequential case needlessly retrying.</summary>
|
||||
Task<HashSet<DateTime>> GetExistingOccurrenceStartTimesAsync(Guid facilityClassId, DateTime from, DateTime to);
|
||||
|
||||
Task AddOccurrencesAsync(IEnumerable<FacilitySession> occurrences);
|
||||
|
||||
Task<List<FacilitySession>> GetOccurrencesForClassAsync(Guid facilityClassId, bool includeCancelled);
|
||||
|
||||
/// <summary>Deletes occurrences that are both in the future and have zero Booking rows
|
||||
/// of ANY status (a Cancelled booking still holds the FK, so it would fail the delete) --
|
||||
/// the safe-to-regenerate set when a class's recurrence rule changes. Returns the count
|
||||
/// deleted.</summary>
|
||||
Task<int> DeleteFutureUnbookedOccurrencesAsync(Guid facilityClassId, DateTime nowUtc);
|
||||
|
||||
/// <summary>Confirmed, non-cancelled ad-hoc bookings whose window overlaps an active
|
||||
/// (non-cancelled) class occurrence on the same facility -- surfaced to staff as a
|
||||
/// conflict to resolve via the existing Reschedule flow, not auto-resolved.</summary>
|
||||
Task<List<Booking>> GetSchedulingConflictsAsync(Guid clientId);
|
||||
|
||||
/// <summary>Links a newly-created CustomerPlan to the class it's sold against -- the
|
||||
/// "Manage Plan" action's whole reason for being a class-scoped entry point rather than
|
||||
/// the general Plans tab.</summary>
|
||||
Task LinkPlanToClassAsync(Guid customerPlanId, Guid facilityClassId);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,39 @@ namespace Fitness_core_BE.Interfaces
|
||||
|
||||
Task<FacilitySession> CreateFacilitySession(FacilitySession session);
|
||||
Task<FacilitySession?> GetFacilitySessionById(Guid facilitySessionId);
|
||||
Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc);
|
||||
Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc, bool includeCancelled = false);
|
||||
Task<int> GetActiveBookingCount(Guid facilitySessionId);
|
||||
Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId);
|
||||
Task<List<FacilitySession>> GetOverlappingActiveSessionsAsync(Guid facilityId, DateTime startUtc, DateTime endUtc);
|
||||
|
||||
/// <summary>"Mark not held" -- see FacilityService.CancelFacilitySession for the full
|
||||
/// cascade this backs. Returns the session with its Bookings included so the caller can
|
||||
/// cancel+refund each Confirmed one.</summary>
|
||||
Task<FacilitySession?> MarkFacilitySessionNotHeldAsync(Guid facilitySessionId, Guid clientId, string? reason);
|
||||
|
||||
// Device binding (absorbed from the old IPlaceRepo -- one AccessDevice per Facility).
|
||||
Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId);
|
||||
Task<Dictionary<Guid, AccessDevice>> GetDevicesByFacilityIdsAsync(List<Guid> facilityIds);
|
||||
Task<bool> IsDeviceAssignedToAnotherFacilityAsync(Guid deviceId, Guid facilityId);
|
||||
Task AssignDeviceAsync(Guid facilityId, Guid deviceId);
|
||||
Task UnassignDeviceAsync(Guid facilityId);
|
||||
|
||||
// CustomerPlan <-> Facility access-scoping (many-to-many). Replace-all semantics, like a
|
||||
// tags field. Absorbed from the old IPlaceRepo.
|
||||
Task SetPlanFacilitiesAsync(Guid customerPlanId, List<Guid> facilityIds);
|
||||
Task<List<Guid>> GetFacilityIdsForPlanAsync(Guid customerPlanId);
|
||||
Task<Dictionary<Guid, List<Guid>>> GetFacilityIdsByPlanIdsAsync(List<Guid> customerPlanIds);
|
||||
Task<Dictionary<Guid, string>> GetFacilityNamesByIdsAsync(List<Guid> facilityIds);
|
||||
|
||||
// WalkIn booking support (new). See TenantDbContext's Booking (FKFacilityId, StartTime,
|
||||
// EndTime) index comment -- overlap is checked here rather than by a DB constraint.
|
||||
/// <summary>Counts non-cancelled/non-completed WalkIn bookings on this facility whose
|
||||
/// [StartTime, EndTime) overlaps the given window. Pass excludeBookingId when
|
||||
/// re-checking a booking being rescheduled, so it doesn't count against itself.</summary>
|
||||
Task<int> GetOverlappingWalkInBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null);
|
||||
|
||||
/// <summary>Issues a row lock on the facility (SELECT ... FOR UPDATE) inside the caller's
|
||||
/// already-open unit-of-work transaction, serializing concurrent WalkIn-slot claims on the
|
||||
/// same facility so two overlapping bookings can't both pass the overlap-count check.</summary>
|
||||
Task LockFacilityForUpdateAsync(Guid facilityId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Fitness_core_BE.Models;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces
|
||||
{
|
||||
public interface IInventoryRepository
|
||||
{
|
||||
// Products
|
||||
Task<List<Product>> GetProducts(Guid clientId, bool includeInactive = false);
|
||||
Task<Product?> GetProductById(Guid productId);
|
||||
Task<Product> CreateProduct(Product product);
|
||||
Task<Product> UpdateProduct(Product product);
|
||||
/// <summary>Next sequential number for this client, e.g. 3 -> "PRD-0004" -- Sku is
|
||||
/// system-generated, never user-entered.</summary>
|
||||
Task<int> GetNextProductSequence(Guid clientId);
|
||||
|
||||
// Product detail page: stock-movement history (GRN-in, Sale-out, Return-out).
|
||||
Task<List<GoodsReceivedNoteItem>> GetGrnItemsForProduct(Guid productId);
|
||||
Task<List<SaleItem>> GetSaleItemsForProduct(Guid productId);
|
||||
Task<List<StockReturn>> GetStockReturnsForProduct(Guid productId);
|
||||
|
||||
// Suppliers
|
||||
Task<List<Supplier>> GetSuppliers(Guid clientId, bool includeInactive = false);
|
||||
Task<Supplier?> GetSupplierById(Guid supplierId);
|
||||
Task<Supplier> CreateSupplier(Supplier supplier);
|
||||
|
||||
// GRN
|
||||
Task<GoodsReceivedNote> CreateGrn(GoodsReceivedNote grn);
|
||||
Task<List<GoodsReceivedNote>> GetGrns(Guid clientId);
|
||||
Task<GoodsReceivedNote?> GetGrnById(Guid grnId);
|
||||
/// <summary>Next sequential number for this client, e.g. 3 -> "GRN-0004".</summary>
|
||||
Task<int> GetNextGrnSequence(Guid clientId);
|
||||
|
||||
// Sales
|
||||
Task<Sale> CreateSale(Sale sale);
|
||||
Task<List<Sale>> GetSales(Guid clientId);
|
||||
Task<Sale?> GetSaleById(Guid saleId);
|
||||
Task<int> GetNextSaleSequence(Guid clientId);
|
||||
|
||||
// Returns
|
||||
Task<StockReturn> CreateStockReturn(StockReturn stockReturn);
|
||||
Task<List<StockReturn>> GetStockReturns(Guid clientId);
|
||||
Task<StockReturn?> GetStockReturnById(Guid stockReturnId);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using Fitness_core_BE.Models;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces
|
||||
{
|
||||
public interface IPlaceRepo
|
||||
{
|
||||
Task<List<Place>> GetAllPlaces(Guid clientId, bool includeInactive = false);
|
||||
Task<Place?> GetPlaceById(Guid placeId);
|
||||
Task<Place> CreatePlace(Place place);
|
||||
Task<Place> UpdatePlace(Place place);
|
||||
Task<Place?> SetPlaceActive(Guid placeId, Guid clientId, bool isActive);
|
||||
|
||||
Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId);
|
||||
Task<Dictionary<Guid, AccessDevice>> GetDevicesByPlaceIdsAsync(List<Guid> placeIds);
|
||||
Task<bool> IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId);
|
||||
Task AssignDeviceAsync(Guid placeId, Guid deviceId);
|
||||
Task UnassignDeviceAsync(Guid placeId);
|
||||
|
||||
// CustomerPlan <-> Place (many-to-many). Replace-all semantics, like a tags field.
|
||||
Task SetPlanPlacesAsync(Guid customerPlanId, List<Guid> placeIds);
|
||||
Task<List<Guid>> GetPlaceIdsForPlanAsync(Guid customerPlanId);
|
||||
Task<Dictionary<Guid, List<Guid>>> GetPlaceIdsByPlanIdsAsync(List<Guid> customerPlanIds);
|
||||
Task<Dictionary<Guid, string>> GetPlaceNamesByIdsAsync(List<Guid> placeIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Fitness_core_BE.Models;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces
|
||||
{
|
||||
public interface IRfidTagRepository
|
||||
{
|
||||
Task<List<RfidTag>> GetAllTags(Guid clientId, string? status = null);
|
||||
Task<RfidTag?> GetTagById(Guid tagId, Guid clientId);
|
||||
Task<bool> TagCodeExistsAsync(Guid clientId, string tagCode, Guid? excludeTagId = null);
|
||||
Task<RfidTag> CreateTag(RfidTag tag);
|
||||
Task<RfidTag> UpdateTag(RfidTag tag);
|
||||
|
||||
/// <summary>Only for staff-driven transitions (Available/Lost/Retired) -- rejects if the
|
||||
/// tag is currently InUse, since that transition only happens via booking check-in/
|
||||
/// complete/sweep.</summary>
|
||||
Task<RfidTag?> SetStatusAsync(Guid tagId, Guid clientId, string status);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces.Services
|
||||
{
|
||||
public interface IBookingService
|
||||
{
|
||||
/// <summary>Shared cancel+refund core -- see BookingService.CancelBookingCore.</summary>
|
||||
Task CancelBookingCore(Booking booking);
|
||||
|
||||
/// <summary>Logged-in member books a session against their own plan's allowance.</summary>
|
||||
Task<object> CreateMemberBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>No membership at all -- pay-per-session, identity proof is the returned OTP.</summary>
|
||||
Task<object> CreateGuestBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Member books a WalkIn-mode facility directly for an arbitrary time window, no
|
||||
/// pre-created session.</summary>
|
||||
Task<object> CreateMemberWalkInBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Guest/staff walk-in books a WalkIn-mode facility directly.</summary>
|
||||
Task<object> CreateGuestWalkInBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Renders the public, unauthenticated confirmation page a booking's ShareUrl
|
||||
/// points to -- the "copy the link and share it" receipt. Returns null if not found, so
|
||||
/// the controller can render a 404 page instead of a JSON error for a browser hit.</summary>
|
||||
@@ -18,11 +29,45 @@ namespace Fitness_core_BE.Interfaces.Services
|
||||
/// <summary>Staff records a guest booking's payment (cash/bank/at-venue) after the fact.</summary>
|
||||
Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Staff/door check-in: verifies the OTP and marks the booking checked in.</summary>
|
||||
/// <summary>Staff/door check-in: verifies the OTP, optionally assigns an RFID tag, marks
|
||||
/// the booking checked in. Also lazily sweeps this client's overdue bookings first (see
|
||||
/// BookingService.SweepOverdueBookingsAsync) instead of a background job.</summary>
|
||||
Task<object> CheckInBooking(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> CancelBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Staff hands a Confirmed guest booking to a different person (name/phone) --
|
||||
/// no cancel/recreate needed. Guest bookings only.</summary>
|
||||
Task<object> ReassignBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Staff moves a Confirmed booking to a different time (WalkIn) or session
|
||||
/// (Scheduled), only onto a slot with room.</summary>
|
||||
Task<object> RescheduleBooking(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Staff manually flags a Confirmed, never-arrived booking.</summary>
|
||||
Task<object> MarkNoShow(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Staff checks out a CheckedIn booking, releasing its RFID tag if any. A guest
|
||||
/// booking still Unpaid must confirm payment in this same call -- see
|
||||
/// BookingService.CompleteBooking. Also lazily sweeps this client's overdue Confirmed
|
||||
/// bookings to NoShow first, same as CheckInBooking.</summary>
|
||||
Task<object> CompleteBooking(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> GetMyBookings(object payload, HttpContext httpContext);
|
||||
Task<object> GetSessionBookings(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Unified roster covering both booking modes.</summary>
|
||||
Task<object> GetBookings(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Receipts generated whenever a booking's payment is recorded -- their own
|
||||
/// "Invoices" tab, separate from the membership-based Customer Payments view.</summary>
|
||||
Task<object> GetBookingInvoices(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Renders the public, unauthenticated receipt page a BookingInvoice's ShareUrl
|
||||
/// points to. Returns null if not found.</summary>
|
||||
Task<object?> GetBookingInvoiceView(object payload);
|
||||
|
||||
/// <summary>Stat-card summary for the Bookings page's Dashboard tab.</summary>
|
||||
Task<object> GetBookingDashboardSummary(object payload, HttpContext httpContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Fitness_core_BE.Interfaces.Services
|
||||
{
|
||||
public interface IClassService
|
||||
{
|
||||
Task<object> GetFacilityClasses(object payload, HttpContext httpContext);
|
||||
Task<object> CreateFacilityClass(object payload, HttpContext httpContext);
|
||||
Task<object> UpdateFacilityClass(object payload, HttpContext httpContext);
|
||||
Task<object> GetOccurrences(object payload, HttpContext httpContext);
|
||||
Task<object> GetSchedulingConflicts(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Lazy occurrence generation, mirroring the existing SweepOverdueBookingsAsync
|
||||
/// convention (no background job) -- generates the missing FacilitySession rows up to
|
||||
/// "today + 8 weeks" for every active, non-Custom class whose watermark hasn't reached
|
||||
/// it yet, and advances the watermark. Called from the top of FacilityService.GetSessions,
|
||||
/// the one read path everything already goes through.</summary>
|
||||
Task EnsureOccurrencesGeneratedAsync(Guid clientId);
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,10 @@ namespace Fitness_core_BE.Interfaces.Services
|
||||
Task<object> CreateFacilitySession(object payload, HttpContext httpContext);
|
||||
Task<object> GetSessions(object payload, HttpContext httpContext);
|
||||
Task<object> CancelFacilitySession(object payload, HttpContext httpContext);
|
||||
|
||||
// Absorbed from the old IPlaceService.
|
||||
Task<object> AssignDevice(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> GetAvailability(object payload, HttpContext httpContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Fitness_core_BE.Interfaces.Services
|
||||
{
|
||||
public interface IInventoryService
|
||||
{
|
||||
Task<object> GetProducts(object payload, HttpContext httpContext);
|
||||
Task<object> GetProductById(object payload, HttpContext httpContext);
|
||||
Task<object> CreateProduct(object payload, HttpContext httpContext);
|
||||
Task<object> UpdateProduct(object payload, HttpContext httpContext);
|
||||
Task<object> UploadProductImage(Guid productId, IFormFile image, HttpContext httpContext);
|
||||
|
||||
Task<object> GetSuppliers(object payload, HttpContext httpContext);
|
||||
Task<object> CreateSupplier(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> CreateGrn(object payload, HttpContext httpContext);
|
||||
Task<object> GetGrns(object payload, HttpContext httpContext);
|
||||
Task<object> GetGrnById(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> CreateSale(object payload, HttpContext httpContext);
|
||||
Task<object> GetSales(object payload, HttpContext httpContext);
|
||||
Task<object> GetSaleById(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> CreateStockReturn(object payload, HttpContext httpContext);
|
||||
Task<object> GetStockReturns(object payload, HttpContext httpContext);
|
||||
Task<object> GetStockReturnById(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> GetStockMovements(object payload, HttpContext httpContext);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces.Services
|
||||
{
|
||||
public interface IPlaceService
|
||||
{
|
||||
Task<object> GetAllPlaces(object payload, HttpContext httpContext);
|
||||
Task<object> CreatePlace(object payload, HttpContext httpContext);
|
||||
Task<object> UpdatePlace(object payload, HttpContext httpContext);
|
||||
Task<object> SetPlaceStatus(object payload, HttpContext httpContext);
|
||||
Task<object> AssignDevice(object payload, HttpContext httpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Interfaces.Services
|
||||
{
|
||||
public interface IRfidTagService
|
||||
{
|
||||
Task<object> GetAllTags(object payload, HttpContext httpContext);
|
||||
Task<object> CreateTag(object payload, HttpContext httpContext);
|
||||
Task<object> UpdateTag(object payload, HttpContext httpContext);
|
||||
Task<object> SetTagStatus(object payload, HttpContext httpContext);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+60
-8
@@ -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 a WalkIn-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 a WalkIn 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 (WalkIn). 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 WalkIn
|
||||
/// 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
|
||||
/// (WalkIn) 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 a WalkIn booking (e.g. "how many will come") --
|
||||
/// never enforced as a capacity mechanic; each WalkIn 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))]
|
||||
@@ -52,17 +86,35 @@ namespace Fitness_core_BE.Models
|
||||
public string PaymentStatus { get; set; } = "NotRequired";
|
||||
public decimal AmountCharged { get; set; } = 0m;
|
||||
|
||||
/// <summary>"Cash" | "Bank" -- set alongside PaymentStatus becoming "Paid" (MarkGuestBookingPaid
|
||||
/// or the Complete checkout flow). Null until then.</summary>
|
||||
[MaxLength(20)]
|
||||
public string? PaymentMethod { get; set; }
|
||||
/// <summary>Set the moment PaymentStatus becomes "Paid" -- distinct from BookedAt/CheckedOutAt,
|
||||
/// this is specifically when the money was recorded, used to bucket booking revenue into
|
||||
/// Profit & Loss and to date-stamp the BookingInvoice snapshot.</summary>
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
// Set only once payment is confirmed for a pay-per-session guest booking, or
|
||||
// immediately for a member session (no payment gate) -- see PaymentStatus.
|
||||
public Guid? FKPaymentTransactionId { get; set; }
|
||||
[ForeignKey(nameof(FKPaymentTransactionId))]
|
||||
public PaymentTransaction? PaymentTransaction { get; set; }
|
||||
|
||||
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow".</summary>
|
||||
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow is
|
||||
/// reached either by explicit staff action (MarkNoShow) or lazily, swept by
|
||||
/// BookingService.SweepOverdueBookingsAsync at the top of every check-in or checkout call
|
||||
/// (not a background job -- overdue Confirmed bookings are reconciled the next time any
|
||||
/// check-in/checkout happens for this client, across every facility). Completed is
|
||||
/// reached ONLY by the explicit Complete action -- a guest booking can't reach it while
|
||||
/// still Unpaid, so there's no automatic path that would check someone out unpaid.</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 via the manual Complete action -- the
|
||||
/// actual moment it was checked out, not necessarily EndTime.</summary>
|
||||
public DateTime? CheckedOutAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>A snapshot receipt generated the moment a Booking's payment is recorded (either
|
||||
/// via MarkGuestBookingPaid or the Complete checkout flow) -- deliberately NOT a live join to
|
||||
/// Booking/Facility, so it stays stable and printable even if the booking is later reassigned,
|
||||
/// rescheduled, or the facility renamed. Purpose-built for bookings and kept entirely separate
|
||||
/// from CustomerInvoice, which is hardwired to the membership/subscription domain
|
||||
/// (FKCustomerSubscriptionId is required there) and has no concept of a one-off booking
|
||||
/// charge.</summary>
|
||||
public class BookingInvoice
|
||||
{
|
||||
[Key]
|
||||
public Guid BookingInvoiceId { get; set; }
|
||||
|
||||
public Guid FKBookingId { get; set; }
|
||||
[ForeignKey(nameof(FKBookingId))]
|
||||
public Booking? Booking { get; set; }
|
||||
|
||||
public Guid FKClientId { get; set; }
|
||||
[ForeignKey(nameof(FKClientId))]
|
||||
public Client Client { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>"Cash" | "Bank".</summary>
|
||||
[MaxLength(20)]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
[MaxLength(200)]
|
||||
public string GuestName { get; set; } = string.Empty;
|
||||
[MaxLength(32)]
|
||||
public string? GuestPhone { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string FacilityName { get; set; } = string.Empty;
|
||||
public DateTime BookingStartTime { get; set; }
|
||||
public DateTime BookingEndTime { get; set; }
|
||||
|
||||
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -63,5 +63,14 @@ namespace Fitness_core_BE.Models
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Meaningful only for a plan linked to one or more classes (via
|
||||
/// CustomerPlanClass) -- never consulted for an ordinary plan with no class links,
|
||||
/// which always grants gym access regardless of this flag's stored value. Lets a class
|
||||
/// plan optionally also grant general gym-floor access ("class + gym" as one product)
|
||||
/// instead of class-only. Migration default true (so every pre-existing plan, which has
|
||||
/// no class links, keeps behaving exactly as before); new class plans default this to
|
||||
/// false in the UI.</summary>
|
||||
public bool GrantsGymAccess { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>Many-to-many join: which FacilityClass(es) a CustomerPlan is sold against. A
|
||||
/// plan with no rows here is an ordinary gym plan, completely unaffected by the Classes
|
||||
/// feature -- CustomerPlan.GrantsGymAccess is never consulted for it. Structurally identical
|
||||
/// to CustomerPlanFacility.</summary>
|
||||
public class CustomerPlanClass
|
||||
{
|
||||
[Key]
|
||||
public Guid CustomerPlanClassId { get; set; }
|
||||
|
||||
public Guid FKCustomerPlanId { get; set; }
|
||||
[ForeignKey(nameof(FKCustomerPlanId))]
|
||||
public CustomerPlan CustomerPlan { get; set; } = null!;
|
||||
|
||||
public Guid FKFacilityClassId { get; set; }
|
||||
[ForeignKey(nameof(FKFacilityClassId))]
|
||||
public FacilityClass FacilityClass { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
+30
-8
@@ -3,29 +3,51 @@ 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>Staff pre-creates fixed-time FacilitySessions (classes) that many bookings
|
||||
/// share one capacity pool against. Independent of SupportsBooking -- both can be true,
|
||||
/// e.g. a court that hosts a scheduled class AND accepts ad-hoc walk-in bookings outside
|
||||
/// class hours.</summary>
|
||||
public bool SupportsClasses { get; set; } = true;
|
||||
|
||||
/// <summary>A booking can claim one of NumberOfUnits identical units directly for an
|
||||
/// arbitrary time window, no pre-created session needed (courts/pool). At least one of
|
||||
/// SupportsClasses/SupportsBooking must be true.</summary>
|
||||
public bool SupportsBooking { get; set; } = false;
|
||||
|
||||
[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 SupportsClasses; a specific
|
||||
/// FacilitySession can override.</summary>
|
||||
public int DefaultCapacity { get; set; } = 1;
|
||||
|
||||
/// <summary>Number of identical, independently-bookable units when SupportsBooking (e.g.
|
||||
/// 3 for "3 courts") -- availability is this minus overlapping non-cancelled/
|
||||
/// non-completed WalkIn bookings in the requested window.</summary>
|
||||
public int NumberOfUnits { get; set; } = 1;
|
||||
|
||||
/// <summary>Hourly rate used to compute a guest WalkIn 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>A recurring class definition (Zumba, Yoga) on a Facility that SupportsClasses --
|
||||
/// generates FacilitySession rows ("occurrences") on a schedule. Replaces manually creating
|
||||
/// one-off FacilitySession rows from the Bookings page for anything that recurs.</summary>
|
||||
public class FacilityClass
|
||||
{
|
||||
[Key]
|
||||
public Guid FacilityClassId { get; set; }
|
||||
|
||||
public Guid FKClientId { get; set; }
|
||||
[ForeignKey(nameof(FKClientId))]
|
||||
public Client Client { get; set; }
|
||||
|
||||
public Guid FKFacilityId { get; set; }
|
||||
[ForeignKey(nameof(FKFacilityId))]
|
||||
public Facility Facility { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"Daily" | "Weekly" | "Monthly" | "Custom". Custom skips every rule field
|
||||
/// below -- occurrences are written explicitly (as FacilitySession rows) at creation/
|
||||
/// edit time with their own dates, instead of being generated from a rule.</summary>
|
||||
[MaxLength(20)]
|
||||
public string RecurrenceType { get; set; } = "Weekly";
|
||||
|
||||
/// <summary>The class's daily time window, in the facility's local time (not UTC) -- the
|
||||
/// generator converts to UTC per-occurrence using the club's timezone. Unused (0) for
|
||||
/// Custom, where each occurrence's own FacilitySession.StartTime/EndTime is explicit.</summary>
|
||||
public TimeSpan StartTimeOfDay { get; set; }
|
||||
public int DurationMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>Weekly only. Bit flags, Sunday = 1 (bit 0) through Saturday = 64 (bit 6).</summary>
|
||||
public int WeekDaysMask { get; set; } = 0;
|
||||
|
||||
/// <summary>Monthly only. Comma-separated day-of-month list, e.g. "1,15".</summary>
|
||||
[MaxLength(200)]
|
||||
public string? MonthDays { get; set; }
|
||||
|
||||
/// <summary>Unused for Custom (occurrences are explicit, not generated from a range).</summary>
|
||||
public DateTime SeriesStartDate { get; set; } = DateTime.UtcNow.Date;
|
||||
/// <summary>Null = ongoing, no end date. Unused for Custom.</summary>
|
||||
public DateTime? SeriesEndDate { get; set; }
|
||||
|
||||
/// <summary>Null = unlimited (see IsUnlimitedCapacity); otherwise the per-occurrence
|
||||
/// headcount cap, same meaning as Facility.DefaultCapacity/FacilitySession.Capacity.</summary>
|
||||
public int? Capacity { get; set; }
|
||||
|
||||
/// <summary>When true, Capacity is ignored and every generated occurrence has no cap --
|
||||
/// a distinct flag rather than a sentinel value, since FacilitySession.Capacity == null
|
||||
/// already means "use the facility default," and int.MaxValue would overflow the
|
||||
/// checked Sum() in FacilityService.GetAvailability.</summary>
|
||||
public bool IsUnlimitedCapacity { get; set; } = false;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Watermark: occurrences have been generated through this date already.
|
||||
/// Advanced by FacilityService.EnsureOccurrencesGeneratedAsync; never consulted for
|
||||
/// Custom classes (their occurrences are all written explicitly, never generated).</summary>
|
||||
public DateTime OccurrencesGeneratedThrough { get; set; } = DateTime.UtcNow.Date;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,30 @@ namespace Fitness_core_BE.Models
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
|
||||
/// <summary>Overrides Facility.DefaultCapacity when set; null = use the facility default.</summary>
|
||||
/// <summary>Overrides Facility.DefaultCapacity when set; null = use the facility default.
|
||||
/// Ignored when IsUnlimitedCapacity is true.</summary>
|
||||
public int? Capacity { get; set; }
|
||||
|
||||
/// <summary>Copied from FacilityClass.IsUnlimitedCapacity at generation time (or set
|
||||
/// directly for a manually-created session) -- see that flag's doc comment for why this
|
||||
/// isn't just a Capacity sentinel value.</summary>
|
||||
public bool IsUnlimitedCapacity { get; set; } = false;
|
||||
|
||||
public bool IsCancelled { get; set; } = false;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null for a manually-created session (the pre-Classes path, unaffected).
|
||||
/// Set for a class occurrence -- which FacilityClass generated or explicitly added it.</summary>
|
||||
public Guid? FKFacilityClassId { get; set; }
|
||||
[ForeignKey(nameof(FKFacilityClassId))]
|
||||
public FacilityClass? FacilityClass { get; set; }
|
||||
|
||||
/// <summary>Set together by FacilityService's "mark not held" cascade (which replaces
|
||||
/// the old bare-IsCancelled CancelFacilitySession) -- null means this occurrence is
|
||||
/// either still scheduled or was never a class occurrence at all.</summary>
|
||||
public string? NotHeldReason { get; set; }
|
||||
public DateTime? NotHeldAt { get; set; }
|
||||
|
||||
public virtual ICollection<Booking> Bookings { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>Stock coming in, optionally from a Supplier -- some GRNs don't have one (e.g.
|
||||
/// self-produced stock, or the supplier just isn't on file). Creating one increments each
|
||||
/// line's Product.QuantityOnHand and overwrites Product.CostPrice with that line's UnitCost
|
||||
/// (a simple latest-cost approach, not a weighted average).</summary>
|
||||
public class GoodsReceivedNote
|
||||
{
|
||||
[Key]
|
||||
public Guid GrnId { get; set; }
|
||||
|
||||
public Guid FKClientId { get; set; }
|
||||
[ForeignKey(nameof(FKClientId))]
|
||||
public Client Client { get; set; }
|
||||
|
||||
public Guid? FKSupplierId { get; set; }
|
||||
[ForeignKey(nameof(FKSupplierId))]
|
||||
public Supplier? Supplier { get; set; }
|
||||
|
||||
[MaxLength(32)]
|
||||
public string GrnNumber { get; set; } = string.Empty;
|
||||
|
||||
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[MaxLength(300)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public decimal TotalAmount { get; set; } = 0m;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public virtual ICollection<GoodsReceivedNoteItem> Items { get; set; } = new List<GoodsReceivedNoteItem>();
|
||||
}
|
||||
|
||||
public class GoodsReceivedNoteItem
|
||||
{
|
||||
[Key]
|
||||
public Guid GrnItemId { get; set; }
|
||||
|
||||
public Guid FKGrnId { get; set; }
|
||||
[ForeignKey(nameof(FKGrnId))]
|
||||
public GoodsReceivedNote? Grn { get; set; }
|
||||
|
||||
public Guid FKProductId { get; set; }
|
||||
[ForeignKey(nameof(FKProductId))]
|
||||
public Product? Product { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>A stocked item sold at the gym (snacks, supplements, merchandise). QuantityOnHand
|
||||
/// is a maintained running balance -- adjusted by GoodsReceivedNoteItem (+), SaleItem (-) and
|
||||
/// StockReturn (-) -- not computed from those tables on every read.</summary>
|
||||
public class Product
|
||||
{
|
||||
[Key]
|
||||
public Guid ProductId { 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(64)]
|
||||
public string? Sku { get; set; }
|
||||
|
||||
/// <summary>Relative /Uploads path to the product's photo (same convention as
|
||||
/// ClientBusiness.BusinessLogoId) -- null when no image has been uploaded.</summary>
|
||||
[MaxLength(300)]
|
||||
public string? ImageId { get; set; }
|
||||
|
||||
[MaxLength(20)]
|
||||
public string UnitOfMeasure { get; set; } = "pcs";
|
||||
|
||||
/// <summary>Set from the most recent GRN line's UnitCost -- a simple latest-cost
|
||||
/// approach, not a weighted average.</summary>
|
||||
public decimal CostPrice { get; set; } = 0m;
|
||||
|
||||
public decimal SellingPrice { get; set; } = 0m;
|
||||
|
||||
public decimal QuantityOnHand { get; set; } = 0m;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>Stock going out to a customer. Creating one decrements each line's
|
||||
/// Product.QuantityOnHand -- the whole sale is rejected up front if any line would go
|
||||
/// negative (no backorders).</summary>
|
||||
public class Sale
|
||||
{
|
||||
[Key]
|
||||
public Guid SaleId { get; set; }
|
||||
|
||||
public Guid FKClientId { get; set; }
|
||||
[ForeignKey(nameof(FKClientId))]
|
||||
public Client Client { get; set; }
|
||||
|
||||
[MaxLength(32)]
|
||||
public string SaleNumber { get; set; } = string.Empty;
|
||||
|
||||
public DateTime SoldAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>"Cash" | "Bank" -- same convention as Booking.PaymentMethod.</summary>
|
||||
[MaxLength(20)]
|
||||
public string PaymentMethod { get; set; } = "Cash";
|
||||
|
||||
/// <summary>Optional link to a registered member, purely for identification -- mirrors
|
||||
/// Booking.FKCustomerId (see BookingService.ResolveOptionalCustomerLinkAsync). A walk-in
|
||||
/// buyer leaves this null and uses CustomerName instead.</summary>
|
||||
public Guid? FKCustomerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
public decimal TotalAmount { get; set; } = 0m;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public virtual ICollection<SaleItem> Items { get; set; } = new List<SaleItem>();
|
||||
}
|
||||
|
||||
public class SaleItem
|
||||
{
|
||||
[Key]
|
||||
public Guid SaleItemId { get; set; }
|
||||
|
||||
public Guid FKSaleId { get; set; }
|
||||
[ForeignKey(nameof(FKSaleId))]
|
||||
public Sale? Sale { get; set; }
|
||||
|
||||
public Guid FKProductId { get; set; }
|
||||
[ForeignKey(nameof(FKProductId))]
|
||||
public Product? Product { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>A stock adjustment -- expired, damaged, lost, sent back to the supplier, handed
|
||||
/// back by a customer, or anything else (Category == "Other"). Direction depends on the
|
||||
/// category: "CustomerReturn" is the one case that ADDS back to Product.QuantityOnHand (the
|
||||
/// item physically comes back after an earlier sale already removed it); every other
|
||||
/// category REMOVES from it (stock we currently hold being written off or sent away). Amount
|
||||
/// is auto-computed (Quantity * Product.CostPrice at the moment of the return) for the
|
||||
/// Profit & Loss impact. FKSaleId/FKGrnId are both optional, independent traceability
|
||||
/// links to the specific Sale or GRN this return relates to -- many returns (e.g. damaged
|
||||
/// stock sitting on the shelf) don't trace back to either.</summary>
|
||||
public class StockReturn
|
||||
{
|
||||
[Key]
|
||||
public Guid StockReturnId { get; set; }
|
||||
|
||||
public Guid FKClientId { get; set; }
|
||||
[ForeignKey(nameof(FKClientId))]
|
||||
public Client Client { get; set; }
|
||||
|
||||
public Guid FKProductId { get; set; }
|
||||
[ForeignKey(nameof(FKProductId))]
|
||||
public Product? Product { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
/// <summary>"Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other".
|
||||
/// Only "CustomerReturn" adds back to stock -- see class doc comment.</summary>
|
||||
[MaxLength(20)]
|
||||
public string Category { get; set; } = "Other";
|
||||
|
||||
/// <summary>Optional -- e.g. a customer return that came from a specific sale.</summary>
|
||||
public Guid? FKSaleId { get; set; }
|
||||
[ForeignKey(nameof(FKSaleId))]
|
||||
public Sale? Sale { get; set; }
|
||||
|
||||
/// <summary>Optional -- e.g. stock sent back to the supplier from a specific GRN.</summary>
|
||||
public Guid? FKGrnId { get; set; }
|
||||
[ForeignKey(nameof(FKGrnId))]
|
||||
public GoodsReceivedNote? Grn { get; set; }
|
||||
|
||||
[MaxLength(300)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime ReturnedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Quantity * Product.CostPrice at the moment of the return -- snapshotted so
|
||||
/// it doesn't drift later if CostPrice changes.</summary>
|
||||
public decimal Amount { get; set; } = 0m;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Fitness_core_BE.Models
|
||||
{
|
||||
/// <summary>Who stock is received from on a GoodsReceivedNote.</summary>
|
||||
public class Supplier
|
||||
{
|
||||
[Key]
|
||||
public Guid SupplierId { 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(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[MaxLength(300)]
|
||||
public string? Address { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -141,9 +141,11 @@ builder.Services.AddScoped<IOverdueLockRepository, OverdueLockRepository>();
|
||||
builder.Services.AddScoped<IPlatformBillingRepository, PlatformBillingRepository>();
|
||||
builder.Services.AddScoped<ICustomerNotificationRepository, CustomerNotificationRepository>();
|
||||
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
||||
builder.Services.AddScoped<IFacilityClassRepository, FacilityClassRepository>();
|
||||
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
||||
builder.Services.AddScoped<IInventoryRepository, InventoryRepository>();
|
||||
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
||||
builder.Services.AddScoped<IPlaceRepo, PlaceRepo>();
|
||||
builder.Services.AddScoped<IRfidTagRepository, RfidTagRepository>();
|
||||
|
||||
// Register Helpers
|
||||
builder.Services.AddScoped<Fitness_core_BE.Infra.Tenancy.ITenantContext, Fitness_core_BE.Infra.Tenancy.TenantContext>();
|
||||
@@ -163,11 +165,13 @@ builder.Services.AddScoped<ICustomerInvoiceService, CustomerInvoiceService>();
|
||||
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
||||
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
||||
builder.Services.AddScoped<IBookingService, BookingService>();
|
||||
builder.Services.AddScoped<IClassService, ClassService>();
|
||||
builder.Services.AddScoped<IInventoryService, InventoryService>();
|
||||
builder.Services.AddScoped<IRfidTagService, RfidTagService>();
|
||||
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
||||
builder.Services.AddScoped<IBootstrapService, BootstrapService>();
|
||||
builder.Services.AddScoped<ISubscriptionRenewalApprovalService, SubscriptionRenewalApprovalService>();
|
||||
builder.Services.AddScoped<ICustomerPlanService, CustomerPlanService>();
|
||||
builder.Services.AddScoped<IPlaceService, PlaceService>();
|
||||
builder.Services.AddScoped<IMealService, MealService>();
|
||||
builder.Services.AddScoped<IWorkoutService, WorkoutService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
@@ -24,23 +24,31 @@ namespace Fitness_core_BE.Repos
|
||||
|
||||
public Task<Booking?> GetBookingById(Guid bookingId)
|
||||
=> _context.Bookings
|
||||
.Include(b => b.FacilitySession).ThenInclude(s => s.Facility)
|
||||
.Include(b => b.FacilitySession)
|
||||
.Include(b => b.Facility)
|
||||
.Include(b => b.RfidTag)
|
||||
.FirstOrDefaultAsync(b => b.BookingId == bookingId);
|
||||
|
||||
public Task<Booking?> GetActiveBookingByOtp(string otpCode)
|
||||
=> _context.Bookings
|
||||
.Include(b => b.FacilitySession).ThenInclude(s => s.Facility)
|
||||
.Include(b => b.FacilitySession)
|
||||
.Include(b => b.Facility)
|
||||
.Include(b => b.RfidTag)
|
||||
.FirstOrDefaultAsync(b => b.OtpCode == otpCode && b.Status == "Confirmed");
|
||||
|
||||
public Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId)
|
||||
=> _context.Bookings
|
||||
.Include(b => b.Facility)
|
||||
.Include(b => b.RfidTag)
|
||||
.Where(b => b.FKFacilitySessionId == facilitySessionId)
|
||||
.OrderBy(b => b.BookedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<Booking>> GetBookingsForCustomer(Guid customerId)
|
||||
=> _context.Bookings
|
||||
.Include(b => b.FacilitySession).ThenInclude(s => s.Facility)
|
||||
.Include(b => b.FacilitySession)
|
||||
.Include(b => b.Facility)
|
||||
.Include(b => b.RfidTag)
|
||||
.Where(b => b.FKCustomerId == customerId)
|
||||
.OrderByDescending(b => b.BookedAt)
|
||||
.ToListAsync();
|
||||
@@ -50,5 +58,84 @@ namespace Fitness_core_BE.Repos
|
||||
b.FKFacilitySessionId == facilitySessionId &&
|
||||
b.FKCustomerId == customerId &&
|
||||
b.Status != "Cancelled");
|
||||
|
||||
public async Task<List<Booking>> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status)
|
||||
{
|
||||
var query = _context.Bookings
|
||||
.Include(b => b.FacilitySession)
|
||||
.Include(b => b.Facility)
|
||||
.Include(b => b.RfidTag)
|
||||
.Where(b => b.FKClientId == clientId);
|
||||
|
||||
if (facilityId.HasValue) query = query.Where(b => b.FKFacilityId == facilityId.Value);
|
||||
if (facilitySessionId.HasValue) query = query.Where(b => b.FKFacilitySessionId == facilitySessionId.Value);
|
||||
if (fromUtc.HasValue) query = query.Where(b => b.EndTime >= fromUtc.Value);
|
||||
if (toUtc.HasValue) query = query.Where(b => b.StartTime <= toUtc.Value);
|
||||
if (!string.IsNullOrEmpty(status)) query = query.Where(b => b.Status == status);
|
||||
|
||||
return await query.OrderByDescending(b => b.StartTime).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<int> MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now)
|
||||
=> _context.Bookings
|
||||
.Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Status, "NoShow"));
|
||||
|
||||
public async Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now)
|
||||
{
|
||||
var rows = await _context.RfidTags
|
||||
.Where(t => t.RfidTagId == tagId && t.Status == "Available")
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, "InUse")
|
||||
.SetProperty(t => t.UpdatedAt, now));
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<BookingInvoice> CreateBookingInvoice(BookingInvoice invoice)
|
||||
{
|
||||
invoice.BookingInvoiceId = Guid.NewGuid();
|
||||
invoice.IssuedAt = DateTime.UtcNow;
|
||||
|
||||
_context.BookingInvoices.Add(invoice);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
public Task<List<BookingInvoice>> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc)
|
||||
{
|
||||
var query = _context.BookingInvoices.Where(i => i.FKClientId == clientId);
|
||||
if (fromUtc.HasValue) query = query.Where(i => i.IssuedAt >= fromUtc.Value);
|
||||
if (toUtc.HasValue) query = query.Where(i => i.IssuedAt <= toUtc.Value);
|
||||
|
||||
return query.OrderByDescending(i => i.IssuedAt).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<BookingInvoice?> GetBookingInvoiceById(Guid bookingInvoiceId)
|
||||
=> _context.BookingInvoices.FirstOrDefaultAsync(i => i.BookingInvoiceId == bookingInvoiceId);
|
||||
|
||||
public async Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)>
|
||||
GetDashboardSummaryAsync(Guid clientId, DateTime now)
|
||||
{
|
||||
var todayStart = now.Date;
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
|
||||
var bookingsToday = await _context.Bookings
|
||||
.CountAsync(b => b.FKClientId == clientId && b.StartTime >= todayStart && b.StartTime < todayEnd);
|
||||
|
||||
var revenueToday = await _context.Bookings
|
||||
.Where(b => b.FKClientId == clientId && b.PaidAt >= todayStart && b.PaidAt < todayEnd)
|
||||
.SumAsync(b => (decimal?)b.AmountCharged) ?? 0m;
|
||||
|
||||
var checkedInNow = await _context.Bookings
|
||||
.CountAsync(b => b.FKClientId == clientId && b.Status == "CheckedIn");
|
||||
|
||||
var upcomingConfirmed = await _context.Bookings
|
||||
.CountAsync(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.StartTime > now);
|
||||
|
||||
var outstandingUnpaid = await _context.Bookings
|
||||
.Where(b => b.FKClientId == clientId && b.PaymentStatus == "Unpaid")
|
||||
.SumAsync(b => (decimal?)b.AmountCharged) ?? 0m;
|
||||
|
||||
return (bookingsToday, revenueToday, checkedInNow, upcomingConfirmed, outstandingUnpaid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,12 +354,22 @@ namespace Fitness_core_BE.Repos
|
||||
// Unlike CustomerSubscription.FKCustomerId (which only ever points at the primary
|
||||
// member), this resolves the caller's own SubscriptionMember row whether they're
|
||||
// the primary or a secondary member of a group subscription.
|
||||
// Both callers (ClientService.ChangeCustomerMembershipPlan,
|
||||
// SubscriptionRenewalApprovalService.ChangePlanAsync) reject a Group-scope new plan and
|
||||
// are conceptually "swap the member's individual gym plan" -- so this must resolve to
|
||||
// their GYM-ACCESS subscription specifically, not just whichever active subscription
|
||||
// happens to be most recently created. Without this filter, a member holding both a gym
|
||||
// plan and a class-only plan would have their class subscription silently deactivated
|
||||
// and replaced instead of their gym one, once a class subscription could outrank it by
|
||||
// CreatedAt.
|
||||
public async Task<SubscriptionMember?> GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default)
|
||||
{
|
||||
return await _dbContext.SubscriptionMembers
|
||||
.Include(m => m.Subscription)
|
||||
.ThenInclude(s => s.CustomerPlan)
|
||||
.Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive)
|
||||
.Where(m => !_dbContext.CustomerPlanClasses.Any(c => c.FKCustomerPlanId == m.Subscription.FKCustomerPlanId)
|
||||
|| m.Subscription.CustomerPlan.GrantsGymAccess)
|
||||
.OrderByDescending(m => m.Subscription.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,13 @@ namespace Fitness_core_BE.Repos
|
||||
(l.PrimaryPhoneCached == id || l.EmailCached == id));
|
||||
}
|
||||
|
||||
// Staff picking an existing member to link a walk-in booking to (identification only,
|
||||
// see BookingService.CreateGuestBooking/CreateGuestWalkInBooking) -- tenant-scoped by
|
||||
// construction, same as FindActiveLinkByIdentifierAsync above.
|
||||
public Task<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id)
|
||||
=> _context.TenantCustomerLink
|
||||
.FirstOrDefaultAsync(l => l.TenantCustomerLinkId == id && l.IsActiveAtThisClient);
|
||||
|
||||
|
||||
public async Task<Customer?> UpdateCustomer(Guid customerId, Customer updatedCustomer)
|
||||
{
|
||||
|
||||
@@ -134,23 +134,69 @@ namespace Fitness_core_BE.Repos
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomerSubscription?> GetActiveSubscriptionByCustomerId(Guid customerId)
|
||||
// A plan with no CustomerPlanClass links is an ordinary gym plan -- GrantsGymAccess is
|
||||
// never consulted for it (always true in effect). A class-linked plan only grants gym
|
||||
// access when GrantsGymAccess is explicitly set. Reused by every gym-access query below.
|
||||
private IQueryable<CustomerPlan> GymAccessPlans() => _context.CustomerPlans.Where(p =>
|
||||
!_context.CustomerPlanClasses.Any(c => c.FKCustomerPlanId == p.CustomerPlanId) || p.GrantsGymAccess);
|
||||
|
||||
public Task<bool> PlanGrantsGymAccessAsync(Guid customerPlanId, CancellationToken ct = default)
|
||||
=> GymAccessPlans().AnyAsync(p => p.CustomerPlanId == customerPlanId, ct);
|
||||
|
||||
public async Task<CustomerSubscription?> GetActiveGymAccessSubscriptionAsync(Guid customerId)
|
||||
{
|
||||
return await _context.CustomerSubscriptions
|
||||
.Include(s => s.TenantCustomerLink)
|
||||
.Include(s => s.CustomerPlan)
|
||||
.Where(s => s.FKCustomerId == customerId && s.IsActive)
|
||||
.Where(s => GymAccessPlans().Any(p => p.CustomerPlanId == s.FKCustomerPlanId))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomerSubscription?> GetActiveSubscriptionForPlanAsync(Guid customerId, Guid customerPlanId)
|
||||
{
|
||||
return await _context.CustomerSubscriptions
|
||||
.Include(s => s.CustomerPlan)
|
||||
.Where(s => s.FKCustomerId == customerId && s.IsActive && s.FKCustomerPlanId == customerPlanId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>Resolves which of the customer's (possibly several) active subscriptions
|
||||
/// covers a specific class, preferring one that still has quota left this cycle over one
|
||||
/// that's already exhausted it -- two different plans can link the same class.</summary>
|
||||
public async Task<CustomerSubscription?> GetActiveSubscriptionForClassAsync(Guid customerId, Guid facilityClassId)
|
||||
{
|
||||
var candidates = await _context.CustomerSubscriptions
|
||||
.Include(s => s.CustomerPlan)
|
||||
.Where(s => s.FKCustomerId == customerId && s.IsActive)
|
||||
.Where(s => _context.CustomerPlanClasses.Any(c =>
|
||||
c.FKCustomerPlanId == s.FKCustomerPlanId && c.FKFacilityClassId == facilityClassId))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
bool HasQuota(CustomerSubscription s)
|
||||
{
|
||||
if (!s.CustomerPlan.SessionsPerCycle.HasValue) return true;
|
||||
var carriedOver = s.SessionsCarriedOverExpiresAt.HasValue && s.SessionsCarriedOverExpiresAt.Value < DateTime.UtcNow
|
||||
? 0 : s.SessionsCarriedOver;
|
||||
return s.SessionsUsedThisCycle < s.CustomerPlan.SessionsPerCycle.Value + carriedOver;
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(HasQuota) ?? candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
// SubscriptionMembers always has a row for the primary too (CustomerSubscriptionFactory
|
||||
// adds it alongside the CustomerSubscription itself), so this one query covers both
|
||||
// "primary somewhere" and "secondary/group member somewhere" in a single check.
|
||||
public Task<bool> HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default)
|
||||
// "primary somewhere" and "secondary/group member somewhere" in a single check. Scoped
|
||||
// to gym-access plans only -- a class-only subscription never blocks a second one via
|
||||
// this check (unlimited concurrent class-only subscriptions are allowed).
|
||||
public Task<bool> HasActiveGymAccessPlanAsync(Guid customerId, CancellationToken ct = default)
|
||||
{
|
||||
return _context.SubscriptionMembers.AnyAsync(m =>
|
||||
m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive, ct);
|
||||
m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive &&
|
||||
GymAccessPlans().Any(p => p.CustomerPlanId == m.Subscription.FKCustomerPlanId), ct);
|
||||
}
|
||||
|
||||
// ---------- Group membership ----------
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Repos
|
||||
{
|
||||
public class FacilityClassRepository : IFacilityClassRepository
|
||||
{
|
||||
private readonly TenantDbContext _context;
|
||||
|
||||
public FacilityClassRepository(TenantDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<FacilityClass> CreateFacilityClass(FacilityClass facilityClass)
|
||||
{
|
||||
facilityClass.FacilityClassId = Guid.NewGuid();
|
||||
facilityClass.CreatedAt = DateTime.UtcNow;
|
||||
facilityClass.IsActive = true;
|
||||
|
||||
_context.FacilityClasses.Add(facilityClass);
|
||||
return facilityClass;
|
||||
}
|
||||
|
||||
public async Task<List<FacilityClass>> GetFacilityClasses(Guid clientId, bool includeInactive = false)
|
||||
{
|
||||
var query = _context.FacilityClasses
|
||||
.Include(fc => fc.Facility)
|
||||
.Where(fc => fc.FKClientId == clientId);
|
||||
if (!includeInactive)
|
||||
query = query.Where(fc => fc.IsActive);
|
||||
|
||||
return await query.OrderBy(fc => fc.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<FacilityClass?> GetFacilityClassById(Guid facilityClassId)
|
||||
=> _context.FacilityClasses
|
||||
.Include(fc => fc.Facility)
|
||||
.FirstOrDefaultAsync(fc => fc.FacilityClassId == facilityClassId);
|
||||
|
||||
public async Task<FacilityClass> UpdateFacilityClass(FacilityClass facilityClass)
|
||||
{
|
||||
var existing = await _context.FacilityClasses.FindAsync(facilityClass.FacilityClassId)
|
||||
?? throw new KeyNotFoundException("Class not found");
|
||||
|
||||
existing.Name = facilityClass.Name;
|
||||
existing.Description = facilityClass.Description;
|
||||
existing.RecurrenceType = facilityClass.RecurrenceType;
|
||||
existing.StartTimeOfDay = facilityClass.StartTimeOfDay;
|
||||
existing.DurationMinutes = facilityClass.DurationMinutes;
|
||||
existing.WeekDaysMask = facilityClass.WeekDaysMask;
|
||||
existing.MonthDays = facilityClass.MonthDays;
|
||||
existing.SeriesStartDate = facilityClass.SeriesStartDate;
|
||||
existing.SeriesEndDate = facilityClass.SeriesEndDate;
|
||||
existing.Capacity = facilityClass.Capacity;
|
||||
existing.IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity;
|
||||
existing.IsActive = facilityClass.IsActive;
|
||||
existing.OccurrencesGeneratedThrough = facilityClass.OccurrencesGeneratedThrough;
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
public Task<List<FacilityClass>> GetClassesNeedingGenerationAsync(Guid clientId, DateTime horizon)
|
||||
=> _context.FacilityClasses
|
||||
.Where(fc => fc.FKClientId == clientId && fc.IsActive && fc.RecurrenceType != "Custom"
|
||||
&& fc.OccurrencesGeneratedThrough < horizon)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<HashSet<DateTime>> GetExistingOccurrenceStartTimesAsync(Guid facilityClassId, DateTime from, DateTime to)
|
||||
{
|
||||
var times = await _context.FacilitySessions
|
||||
.Where(s => s.FKFacilityClassId == facilityClassId && s.StartTime >= from && s.StartTime < to)
|
||||
.Select(s => s.StartTime)
|
||||
.ToListAsync();
|
||||
return times.ToHashSet();
|
||||
}
|
||||
|
||||
public async Task AddOccurrencesAsync(IEnumerable<FacilitySession> occurrences)
|
||||
{
|
||||
var list = occurrences.ToList();
|
||||
foreach (var occurrence in list)
|
||||
{
|
||||
occurrence.FacilitySessionId = Guid.NewGuid();
|
||||
occurrence.CreatedAt = DateTime.UtcNow;
|
||||
occurrence.IsCancelled = false;
|
||||
}
|
||||
await _context.FacilitySessions.AddRangeAsync(list);
|
||||
}
|
||||
|
||||
public Task<List<FacilitySession>> GetOccurrencesForClassAsync(Guid facilityClassId, bool includeCancelled)
|
||||
{
|
||||
var query = _context.FacilitySessions.Where(s => s.FKFacilityClassId == facilityClassId);
|
||||
if (!includeCancelled)
|
||||
query = query.Where(s => !s.IsCancelled);
|
||||
|
||||
return query.OrderByDescending(s => s.StartTime).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> DeleteFutureUnbookedOccurrencesAsync(Guid facilityClassId, DateTime nowUtc)
|
||||
{
|
||||
var candidates = await _context.FacilitySessions
|
||||
.Where(s => s.FKFacilityClassId == facilityClassId && s.StartTime > nowUtc)
|
||||
.Where(s => !_context.Bookings.Any(b => b.FKFacilitySessionId == s.FacilitySessionId))
|
||||
.ToListAsync();
|
||||
|
||||
_context.FacilitySessions.RemoveRange(candidates);
|
||||
return candidates.Count;
|
||||
}
|
||||
|
||||
public async Task LinkPlanToClassAsync(Guid customerPlanId, Guid facilityClassId)
|
||||
{
|
||||
_context.CustomerPlanClasses.Add(new CustomerPlanClass
|
||||
{
|
||||
CustomerPlanClassId = Guid.NewGuid(),
|
||||
FKCustomerPlanId = customerPlanId,
|
||||
FKFacilityClassId = facilityClassId
|
||||
});
|
||||
}
|
||||
|
||||
public Task<List<Booking>> GetSchedulingConflictsAsync(Guid clientId)
|
||||
{
|
||||
return _context.Bookings
|
||||
.Include(b => b.Facility)
|
||||
.Where(b => b.FKClientId == clientId && b.FKFacilitySessionId == null && b.Status == "Confirmed")
|
||||
.Where(b => _context.FacilitySessions.Any(s =>
|
||||
s.FKFacilityId == b.FKFacilityId && s.FKFacilityClassId != null && !s.IsCancelled &&
|
||||
s.StartTime < b.EndTime && s.EndTime > b.StartTime))
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-4
@@ -41,9 +41,12 @@ namespace Fitness_core_BE.Repos
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
existing.Name = facility.Name;
|
||||
existing.FacilityType = facility.FacilityType;
|
||||
existing.SupportsClasses = facility.SupportsClasses;
|
||||
existing.SupportsBooking = facility.SupportsBooking;
|
||||
existing.Description = facility.Description;
|
||||
existing.DefaultCapacity = facility.DefaultCapacity;
|
||||
existing.NumberOfUnits = facility.NumberOfUnits;
|
||||
existing.PricePerHour = facility.PricePerHour;
|
||||
existing.IsActive = facility.IsActive;
|
||||
|
||||
return existing;
|
||||
@@ -74,12 +77,15 @@ namespace Fitness_core_BE.Repos
|
||||
.Include(s => s.Facility)
|
||||
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId);
|
||||
|
||||
public async Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc)
|
||||
public async Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc, bool includeCancelled = false)
|
||||
{
|
||||
var query = _context.FacilitySessions
|
||||
.Include(s => s.Facility)
|
||||
.Where(s => s.FKClientId == clientId && s.StartTime >= fromUtc && s.StartTime <= toUtc && !s.IsCancelled);
|
||||
.Include(s => s.FacilityClass)
|
||||
.Where(s => s.FKClientId == clientId && s.StartTime >= fromUtc && s.StartTime <= toUtc);
|
||||
|
||||
if (!includeCancelled)
|
||||
query = query.Where(s => !s.IsCancelled);
|
||||
if (facilityId.HasValue)
|
||||
query = query.Where(s => s.FKFacilityId == facilityId.Value);
|
||||
|
||||
@@ -89,14 +95,141 @@ namespace Fitness_core_BE.Repos
|
||||
public Task<int> GetActiveBookingCount(Guid facilitySessionId)
|
||||
=> _context.Bookings.CountAsync(b => b.FKFacilitySessionId == facilitySessionId && b.Status != "Cancelled");
|
||||
|
||||
public async Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId)
|
||||
/// <summary>True interval overlap (StartTime < end && EndTime > start), not a
|
||||
/// "StartTime falls inside the window" filter like GetSessions -- a class that started
|
||||
/// before the window and is still running must still count as occupying it.</summary>
|
||||
public Task<List<FacilitySession>> GetOverlappingActiveSessionsAsync(Guid facilityId, DateTime startUtc, DateTime endUtc)
|
||||
=> _context.FacilitySessions
|
||||
.Where(s => s.FKFacilityId == facilityId && !s.IsCancelled && s.StartTime < endUtc && s.EndTime > startUtc)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<FacilitySession?> MarkFacilitySessionNotHeldAsync(Guid facilitySessionId, Guid clientId, string? reason)
|
||||
{
|
||||
var session = await _context.FacilitySessions
|
||||
.Include(s => s.Bookings)
|
||||
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId && s.FKClientId == clientId);
|
||||
if (session == null) return null;
|
||||
|
||||
session.IsCancelled = true;
|
||||
session.NotHeldReason = reason;
|
||||
session.NotHeldAt = DateTime.UtcNow;
|
||||
return session;
|
||||
}
|
||||
|
||||
// --- Device binding (absorbed from PlaceRepo) ---
|
||||
|
||||
public Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId)
|
||||
=> _context.AccessDevices
|
||||
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId);
|
||||
|
||||
public async Task<Dictionary<Guid, string>> GetFacilityNamesByIdsAsync(List<Guid> facilityIds)
|
||||
{
|
||||
return await _context.Facilities
|
||||
.Where(f => facilityIds.Contains(f.FacilityId))
|
||||
.ToDictionaryAsync(f => f.FacilityId, f => f.Name);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, AccessDevice>> GetDevicesByFacilityIdsAsync(List<Guid> facilityIds)
|
||||
{
|
||||
var devices = await _context.AccessDevices
|
||||
.Where(d => d.FKFacilityId != null && facilityIds.Contains(d.FKFacilityId.Value))
|
||||
.ToListAsync();
|
||||
|
||||
return devices.ToDictionary(d => d.FKFacilityId!.Value, d => d);
|
||||
}
|
||||
|
||||
public Task<bool> IsDeviceAssignedToAnotherFacilityAsync(Guid deviceId, Guid facilityId)
|
||||
=> _context.AccessDevices
|
||||
.AnyAsync(d => d.DeviceId == deviceId && d.FKFacilityId != null && d.FKFacilityId != facilityId);
|
||||
|
||||
public async Task AssignDeviceAsync(Guid facilityId, Guid deviceId)
|
||||
{
|
||||
// A device can only sit in one facility at a time -- clear anywhere else it's
|
||||
// currently assigned before pointing it at this facility.
|
||||
var previouslyAssigned = await _context.AccessDevices
|
||||
.Where(d => d.FKFacilityId == facilityId && d.DeviceId != deviceId)
|
||||
.ToListAsync();
|
||||
foreach (var device in previouslyAssigned)
|
||||
device.FKFacilityId = null;
|
||||
|
||||
var target = await _context.AccessDevices.FindAsync(deviceId)
|
||||
?? throw new KeyNotFoundException("Device not found");
|
||||
target.FKFacilityId = facilityId;
|
||||
}
|
||||
|
||||
public async Task UnassignDeviceAsync(Guid facilityId)
|
||||
{
|
||||
var devices = await _context.AccessDevices
|
||||
.Where(d => d.FKFacilityId == facilityId)
|
||||
.ToListAsync();
|
||||
foreach (var device in devices)
|
||||
device.FKFacilityId = null;
|
||||
}
|
||||
|
||||
// --- CustomerPlan access-scoping (absorbed from PlaceRepo) ---
|
||||
|
||||
public async Task SetPlanFacilitiesAsync(Guid customerPlanId, List<Guid> facilityIds)
|
||||
{
|
||||
var existing = await _context.CustomerPlanFacilities
|
||||
.Where(pf => pf.FKCustomerPlanId == customerPlanId)
|
||||
.ToListAsync();
|
||||
_context.CustomerPlanFacilities.RemoveRange(existing);
|
||||
|
||||
foreach (var facilityId in facilityIds.Distinct())
|
||||
{
|
||||
_context.CustomerPlanFacilities.Add(new CustomerPlanFacility
|
||||
{
|
||||
CustomerPlanFacilityId = Guid.NewGuid(),
|
||||
FKCustomerPlanId = customerPlanId,
|
||||
FKFacilityId = facilityId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<Guid>> GetFacilityIdsForPlanAsync(Guid customerPlanId)
|
||||
=> _context.CustomerPlanFacilities
|
||||
.Where(pf => pf.FKCustomerPlanId == customerPlanId)
|
||||
.Select(pf => pf.FKFacilityId)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<Dictionary<Guid, List<Guid>>> GetFacilityIdsByPlanIdsAsync(List<Guid> customerPlanIds)
|
||||
{
|
||||
var rows = await _context.CustomerPlanFacilities
|
||||
.Where(pf => customerPlanIds.Contains(pf.FKCustomerPlanId))
|
||||
.ToListAsync();
|
||||
|
||||
return rows
|
||||
.GroupBy(pf => pf.FKCustomerPlanId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(pf => pf.FKFacilityId).ToList());
|
||||
}
|
||||
|
||||
// --- WalkIn booking support (new) ---
|
||||
|
||||
public Task<int> GetOverlappingWalkInBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null)
|
||||
{
|
||||
var query = _context.Bookings.Where(b =>
|
||||
b.FKFacilityId == facilityId &&
|
||||
b.FKFacilitySessionId == null && // WalkIn bookings only
|
||||
b.Status != "Cancelled" && b.Status != "NoShow" && b.Status != "Completed" &&
|
||||
b.StartTime < endTime && b.EndTime > startTime);
|
||||
|
||||
if (excludeBookingId.HasValue)
|
||||
query = query.Where(b => b.BookingId != excludeBookingId.Value);
|
||||
|
||||
return query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task LockFacilityForUpdateAsync(Guid facilityId)
|
||||
{
|
||||
// FromSqlInterpolated (not ExecuteSqlInterpolatedAsync, which is for non-query DML)
|
||||
// is the correct EF Core idiom for a locking SELECT -- it runs through the same ADO
|
||||
// connection/transaction as the caller's already-open _uow, so the row lock is held
|
||||
// until that transaction commits/rolls back. AsNoTracking avoids clashing with any
|
||||
// already-tracked Facility instance in this same context/request.
|
||||
await _context.Facilities
|
||||
.FromSqlInterpolated($"SELECT * FROM Facilities WHERE FacilityId = {facilityId} FOR UPDATE")
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Repos
|
||||
{
|
||||
public class InventoryRepository : IInventoryRepository
|
||||
{
|
||||
private readonly TenantDbContext _context;
|
||||
|
||||
public InventoryRepository(TenantDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// --- Products ---
|
||||
|
||||
public async Task<List<Product>> GetProducts(Guid clientId, bool includeInactive = false)
|
||||
{
|
||||
var query = _context.Products.Where(p => p.FKClientId == clientId);
|
||||
if (!includeInactive)
|
||||
query = query.Where(p => p.IsActive);
|
||||
|
||||
return await query.OrderBy(p => p.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Product?> GetProductById(Guid productId)
|
||||
=> _context.Products.FirstOrDefaultAsync(p => p.ProductId == productId);
|
||||
|
||||
public async Task<Product> CreateProduct(Product product)
|
||||
{
|
||||
product.ProductId = Guid.NewGuid();
|
||||
product.CreatedAt = DateTime.UtcNow;
|
||||
product.IsActive = true;
|
||||
product.CostPrice = 0m;
|
||||
product.QuantityOnHand = 0m;
|
||||
|
||||
_context.Products.Add(product);
|
||||
return product;
|
||||
}
|
||||
|
||||
public async Task<Product> UpdateProduct(Product product)
|
||||
{
|
||||
var existing = await _context.Products.FindAsync(product.ProductId)
|
||||
?? throw new KeyNotFoundException("Product not found");
|
||||
|
||||
// Sku is system-generated at creation and never edited -- not copied here.
|
||||
existing.Name = product.Name;
|
||||
existing.UnitOfMeasure = product.UnitOfMeasure;
|
||||
existing.SellingPrice = product.SellingPrice;
|
||||
existing.IsActive = product.IsActive;
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<int> GetNextProductSequence(Guid clientId)
|
||||
=> await _context.Products.CountAsync(p => p.FKClientId == clientId) + 1;
|
||||
|
||||
public Task<List<GoodsReceivedNoteItem>> GetGrnItemsForProduct(Guid productId)
|
||||
=> _context.GoodsReceivedNoteItems
|
||||
.Include(i => i.Grn)
|
||||
.Where(i => i.FKProductId == productId)
|
||||
.OrderByDescending(i => i.Grn!.ReceivedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<SaleItem>> GetSaleItemsForProduct(Guid productId)
|
||||
=> _context.SaleItems
|
||||
.Include(i => i.Sale)
|
||||
.Where(i => i.FKProductId == productId)
|
||||
.OrderByDescending(i => i.Sale!.SoldAt)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<StockReturn>> GetStockReturnsForProduct(Guid productId)
|
||||
=> _context.StockReturns
|
||||
.Where(r => r.FKProductId == productId)
|
||||
.OrderByDescending(r => r.ReturnedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// --- Suppliers ---
|
||||
|
||||
public async Task<List<Supplier>> GetSuppliers(Guid clientId, bool includeInactive = false)
|
||||
{
|
||||
var query = _context.Suppliers.Where(s => s.FKClientId == clientId);
|
||||
if (!includeInactive)
|
||||
query = query.Where(s => s.IsActive);
|
||||
|
||||
return await query.OrderBy(s => s.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Supplier?> GetSupplierById(Guid supplierId)
|
||||
=> _context.Suppliers.FirstOrDefaultAsync(s => s.SupplierId == supplierId);
|
||||
|
||||
public async Task<Supplier> CreateSupplier(Supplier supplier)
|
||||
{
|
||||
supplier.SupplierId = Guid.NewGuid();
|
||||
supplier.CreatedAt = DateTime.UtcNow;
|
||||
supplier.IsActive = true;
|
||||
|
||||
_context.Suppliers.Add(supplier);
|
||||
return supplier;
|
||||
}
|
||||
|
||||
// --- GRN ---
|
||||
|
||||
public async Task<GoodsReceivedNote> CreateGrn(GoodsReceivedNote grn)
|
||||
{
|
||||
grn.GrnId = Guid.NewGuid();
|
||||
grn.CreatedAt = DateTime.UtcNow;
|
||||
foreach (var item in grn.Items)
|
||||
item.GrnItemId = Guid.NewGuid();
|
||||
|
||||
_context.GoodsReceivedNotes.Add(grn);
|
||||
return grn;
|
||||
}
|
||||
|
||||
public Task<List<GoodsReceivedNote>> GetGrns(Guid clientId)
|
||||
=> _context.GoodsReceivedNotes
|
||||
.Include(g => g.Supplier)
|
||||
.Include(g => g.Items).ThenInclude(i => i.Product)
|
||||
.Where(g => g.FKClientId == clientId)
|
||||
.OrderByDescending(g => g.ReceivedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<GoodsReceivedNote?> GetGrnById(Guid grnId)
|
||||
=> _context.GoodsReceivedNotes
|
||||
.Include(g => g.Supplier)
|
||||
.Include(g => g.Items).ThenInclude(i => i.Product)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId);
|
||||
|
||||
public async Task<int> GetNextGrnSequence(Guid clientId)
|
||||
=> await _context.GoodsReceivedNotes.CountAsync(g => g.FKClientId == clientId) + 1;
|
||||
|
||||
// --- Sales ---
|
||||
|
||||
public async Task<Sale> CreateSale(Sale sale)
|
||||
{
|
||||
sale.SaleId = Guid.NewGuid();
|
||||
sale.CreatedAt = DateTime.UtcNow;
|
||||
foreach (var item in sale.Items)
|
||||
item.SaleItemId = Guid.NewGuid();
|
||||
|
||||
_context.Sales.Add(sale);
|
||||
return sale;
|
||||
}
|
||||
|
||||
public Task<List<Sale>> GetSales(Guid clientId)
|
||||
=> _context.Sales
|
||||
.Include(s => s.Items).ThenInclude(i => i.Product)
|
||||
.Where(s => s.FKClientId == clientId)
|
||||
.OrderByDescending(s => s.SoldAt)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<Sale?> GetSaleById(Guid saleId)
|
||||
=> _context.Sales
|
||||
.Include(s => s.Items).ThenInclude(i => i.Product)
|
||||
.FirstOrDefaultAsync(s => s.SaleId == saleId);
|
||||
|
||||
public async Task<int> GetNextSaleSequence(Guid clientId)
|
||||
=> await _context.Sales.CountAsync(s => s.FKClientId == clientId) + 1;
|
||||
|
||||
// --- Returns ---
|
||||
|
||||
public async Task<StockReturn> CreateStockReturn(StockReturn stockReturn)
|
||||
{
|
||||
stockReturn.StockReturnId = Guid.NewGuid();
|
||||
stockReturn.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.StockReturns.Add(stockReturn);
|
||||
return stockReturn;
|
||||
}
|
||||
|
||||
public Task<List<StockReturn>> GetStockReturns(Guid clientId)
|
||||
=> _context.StockReturns
|
||||
.Include(r => r.Product)
|
||||
.Include(r => r.Sale)
|
||||
.Include(r => r.Grn)
|
||||
.Where(r => r.FKClientId == clientId)
|
||||
.OrderByDescending(r => r.ReturnedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<StockReturn?> GetStockReturnById(Guid stockReturnId)
|
||||
=> _context.StockReturns
|
||||
.Include(r => r.Product)
|
||||
.Include(r => r.Sale)
|
||||
.Include(r => r.Grn)
|
||||
.FirstOrDefaultAsync(r => r.StockReturnId == stockReturnId);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Repos
|
||||
{
|
||||
public class PlaceRepo : IPlaceRepo
|
||||
{
|
||||
private readonly TenantDbContext _context;
|
||||
|
||||
public PlaceRepo(TenantDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<Place>> GetAllPlaces(Guid clientId, bool includeInactive = false)
|
||||
{
|
||||
var query = _context.Places.Where(p => p.FKClientId == clientId);
|
||||
if (!includeInactive)
|
||||
query = query.Where(p => p.IsActive);
|
||||
|
||||
return await query.OrderBy(p => p.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Place?> GetPlaceById(Guid placeId)
|
||||
=> _context.Places.FirstOrDefaultAsync(p => p.PlaceId == placeId);
|
||||
|
||||
public async Task<Place> CreatePlace(Place place)
|
||||
{
|
||||
place.PlaceId = Guid.NewGuid();
|
||||
place.CreatedAt = DateTime.UtcNow;
|
||||
place.IsActive = true;
|
||||
|
||||
_context.Places.Add(place);
|
||||
return place;
|
||||
}
|
||||
|
||||
public async Task<Place> UpdatePlace(Place place)
|
||||
{
|
||||
var existing = await _context.Places.FindAsync(place.PlaceId)
|
||||
?? throw new KeyNotFoundException("Place not found");
|
||||
|
||||
existing.Name = place.Name;
|
||||
existing.Description = place.Description;
|
||||
existing.IsActive = place.IsActive;
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<Place?> SetPlaceActive(Guid placeId, Guid clientId, bool isActive)
|
||||
{
|
||||
var place = await _context.Places
|
||||
.FirstOrDefaultAsync(p => p.PlaceId == placeId && p.FKClientId == clientId);
|
||||
if (place == null) return null;
|
||||
|
||||
place.IsActive = isActive;
|
||||
return place;
|
||||
}
|
||||
|
||||
public Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId)
|
||||
=> _context.AccessDevices
|
||||
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId);
|
||||
|
||||
public async Task<Dictionary<Guid, string>> GetPlaceNamesByIdsAsync(List<Guid> placeIds)
|
||||
{
|
||||
return await _context.Places
|
||||
.Where(p => placeIds.Contains(p.PlaceId))
|
||||
.ToDictionaryAsync(p => p.PlaceId, p => p.Name);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, AccessDevice>> GetDevicesByPlaceIdsAsync(List<Guid> placeIds)
|
||||
{
|
||||
var devices = await _context.AccessDevices
|
||||
.Where(d => d.FKPlaceId != null && placeIds.Contains(d.FKPlaceId.Value))
|
||||
.ToListAsync();
|
||||
|
||||
return devices.ToDictionary(d => d.FKPlaceId!.Value, d => d);
|
||||
}
|
||||
|
||||
public Task<bool> IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId)
|
||||
=> _context.AccessDevices
|
||||
.AnyAsync(d => d.DeviceId == deviceId && d.FKPlaceId != null && d.FKPlaceId != placeId);
|
||||
|
||||
public async Task AssignDeviceAsync(Guid placeId, Guid deviceId)
|
||||
{
|
||||
// A device can only sit in one place at a time -- clear anywhere else it's
|
||||
// currently assigned before pointing it at this place.
|
||||
var previouslyAssigned = await _context.AccessDevices
|
||||
.Where(d => d.FKPlaceId == placeId && d.DeviceId != deviceId)
|
||||
.ToListAsync();
|
||||
foreach (var device in previouslyAssigned)
|
||||
device.FKPlaceId = null;
|
||||
|
||||
var target = await _context.AccessDevices.FindAsync(deviceId)
|
||||
?? throw new KeyNotFoundException("Device not found");
|
||||
target.FKPlaceId = placeId;
|
||||
}
|
||||
|
||||
public async Task UnassignDeviceAsync(Guid placeId)
|
||||
{
|
||||
var devices = await _context.AccessDevices
|
||||
.Where(d => d.FKPlaceId == placeId)
|
||||
.ToListAsync();
|
||||
foreach (var device in devices)
|
||||
device.FKPlaceId = null;
|
||||
}
|
||||
|
||||
public async Task SetPlanPlacesAsync(Guid customerPlanId, List<Guid> placeIds)
|
||||
{
|
||||
var existing = await _context.CustomerPlanPlaces
|
||||
.Where(pp => pp.FKCustomerPlanId == customerPlanId)
|
||||
.ToListAsync();
|
||||
_context.CustomerPlanPlaces.RemoveRange(existing);
|
||||
|
||||
foreach (var placeId in placeIds.Distinct())
|
||||
{
|
||||
_context.CustomerPlanPlaces.Add(new CustomerPlanPlace
|
||||
{
|
||||
CustomerPlanPlaceId = Guid.NewGuid(),
|
||||
FKCustomerPlanId = customerPlanId,
|
||||
FKPlaceId = placeId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<Guid>> GetPlaceIdsForPlanAsync(Guid customerPlanId)
|
||||
=> _context.CustomerPlanPlaces
|
||||
.Where(pp => pp.FKCustomerPlanId == customerPlanId)
|
||||
.Select(pp => pp.FKPlaceId)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<Dictionary<Guid, List<Guid>>> GetPlaceIdsByPlanIdsAsync(List<Guid> customerPlanIds)
|
||||
{
|
||||
var rows = await _context.CustomerPlanPlaces
|
||||
.Where(pp => customerPlanIds.Contains(pp.FKCustomerPlanId))
|
||||
.ToListAsync();
|
||||
|
||||
return rows
|
||||
.GroupBy(pp => pp.FKCustomerPlanId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(pp => pp.FKPlaceId).ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,91 @@ namespace Fitness_core_BE.Repos
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Paid facility bookings (guest/walk-in) -- a plain Booking read, not a
|
||||
// PaymentTransaction, since Booking has no invoice at all (see BookingInvoice, kept
|
||||
// deliberately separate from CustomerInvoice/PaymentTransaction's membership-only
|
||||
// wiring). PaidAt is the recognition date, not BookedAt/CheckedOutAt.
|
||||
var bookingPayments = await _context.Bookings
|
||||
.AsNoTracking()
|
||||
.Where(booking =>
|
||||
booking.FKClientId == clientId &&
|
||||
booking.PaymentStatus == "Paid" &&
|
||||
booking.PaidAt != null &&
|
||||
booking.PaidAt >= fromUtc &&
|
||||
booking.PaidAt < toUtc)
|
||||
.Select(booking => new LedgerEntryDto
|
||||
{
|
||||
EntryType = "Income",
|
||||
Category = "Facility Bookings",
|
||||
Description = booking.GuestName,
|
||||
Amount = booking.AmountCharged,
|
||||
EntryDate = booking.PaidAt!.Value,
|
||||
IsManual = false
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Inventory: a Sale's revenue, a GRN's cost, and a StockReturn's Amount (staff-
|
||||
// entered -- see StockReturn's doc comment). Every return Category still posts the
|
||||
// same way here (one Expense category, reducing profit) even though CustomerReturn
|
||||
// is the one category that adds the stock back -- the P&L side stays uniform while
|
||||
// the stock-direction side doesn't.
|
||||
var productSales = await _context.Sales
|
||||
.AsNoTracking()
|
||||
.Where(sale =>
|
||||
sale.FKClientId == clientId &&
|
||||
sale.SoldAt >= fromUtc &&
|
||||
sale.SoldAt < toUtc)
|
||||
.Select(sale => new LedgerEntryDto
|
||||
{
|
||||
EntryType = "Income",
|
||||
Category = "Product Sales",
|
||||
Description = sale.SaleNumber,
|
||||
Amount = sale.TotalAmount,
|
||||
EntryDate = sale.SoldAt,
|
||||
IsManual = false
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var inventoryPurchases = await _context.GoodsReceivedNotes
|
||||
.AsNoTracking()
|
||||
.Where(grn =>
|
||||
grn.FKClientId == clientId &&
|
||||
grn.ReceivedAt >= fromUtc &&
|
||||
grn.ReceivedAt < toUtc)
|
||||
.Select(grn => new LedgerEntryDto
|
||||
{
|
||||
EntryType = "Expense",
|
||||
Category = "Inventory Purchases",
|
||||
Description = grn.GrnNumber,
|
||||
Amount = grn.TotalAmount,
|
||||
EntryDate = grn.ReceivedAt,
|
||||
IsManual = false
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var inventoryWriteOffs = await _context.StockReturns
|
||||
.AsNoTracking()
|
||||
.Where(stockReturn =>
|
||||
stockReturn.FKClientId == clientId &&
|
||||
stockReturn.ReturnedAt >= fromUtc &&
|
||||
stockReturn.ReturnedAt < toUtc)
|
||||
.Select(stockReturn => new LedgerEntryDto
|
||||
{
|
||||
EntryType = "Expense",
|
||||
Category = "Inventory Write-offs",
|
||||
Description = stockReturn.Category + " — " + stockReturn.Product!.Name,
|
||||
Amount = stockReturn.Amount,
|
||||
EntryDate = stockReturn.ReturnedAt,
|
||||
IsManual = false
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
memberPayments.AddRange(platformInvoices);
|
||||
memberPayments.AddRange(staffPayments);
|
||||
memberPayments.AddRange(bookingPayments);
|
||||
memberPayments.AddRange(productSales);
|
||||
memberPayments.AddRange(inventoryPurchases);
|
||||
memberPayments.AddRange(inventoryWriteOffs);
|
||||
return memberPayments;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Repos
|
||||
{
|
||||
public class RfidTagRepository(TenantDbContext context) : IRfidTagRepository
|
||||
{
|
||||
private readonly TenantDbContext _context = context;
|
||||
|
||||
public async Task<List<RfidTag>> GetAllTags(Guid clientId, string? status = null)
|
||||
{
|
||||
var query = _context.RfidTags.Where(t => t.FKClientId == clientId);
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
query = query.Where(t => t.Status == status);
|
||||
|
||||
return await query.OrderBy(t => t.TagCode).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<RfidTag?> GetTagById(Guid tagId, Guid clientId)
|
||||
=> _context.RfidTags.FirstOrDefaultAsync(t => t.RfidTagId == tagId && t.FKClientId == clientId);
|
||||
|
||||
public Task<bool> TagCodeExistsAsync(Guid clientId, string tagCode, Guid? excludeTagId = null)
|
||||
{
|
||||
var query = _context.RfidTags.Where(t => t.FKClientId == clientId && t.TagCode == tagCode);
|
||||
if (excludeTagId.HasValue)
|
||||
query = query.Where(t => t.RfidTagId != excludeTagId.Value);
|
||||
return query.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<RfidTag> CreateTag(RfidTag tag)
|
||||
{
|
||||
tag.RfidTagId = Guid.NewGuid();
|
||||
tag.Status = "Available";
|
||||
tag.CreatedAt = DateTime.UtcNow;
|
||||
tag.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.RfidTags.Add(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public async Task<RfidTag> UpdateTag(RfidTag tag)
|
||||
{
|
||||
var existing = await _context.RfidTags.FindAsync(tag.RfidTagId)
|
||||
?? throw new KeyNotFoundException("RFID tag not found");
|
||||
|
||||
existing.TagCode = tag.TagCode;
|
||||
existing.Label = tag.Label;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<RfidTag?> SetStatusAsync(Guid tagId, Guid clientId, string status)
|
||||
{
|
||||
var tag = await _context.RfidTags.FirstOrDefaultAsync(t => t.RfidTagId == tagId && t.FKClientId == clientId);
|
||||
if (tag == null) return null;
|
||||
|
||||
if (tag.Status == "InUse")
|
||||
throw new ArgumentException("This tag is currently assigned to a booking -- it can only be freed via that booking's Complete action.");
|
||||
|
||||
tag.Status = status;
|
||||
tag.UpdatedAt = DateTime.UtcNow;
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
+608
-55
@@ -5,30 +5,30 @@ using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class BookingService : IBookingService
|
||||
public class BookingService(
|
||||
IBookingRepository bookingRepository,
|
||||
IFacilityRepository facilityRepository,
|
||||
IRfidTagRepository rfidTagRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper) : IBookingService
|
||||
{
|
||||
private readonly IBookingRepository _bookingRepository;
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IBookingRepository _bookingRepository = bookingRepository;
|
||||
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
||||
private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository;
|
||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository;
|
||||
private readonly ICustomerRepository _customerRepository = customerRepository;
|
||||
private readonly IUnitOfWork _uow = uow;
|
||||
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||
|
||||
// ---------- Scheduled-mode creation (existing behavior, now also stamping the
|
||||
// denormalized FKFacilityId/StartTime/EndTime every Booking carries) ----------
|
||||
|
||||
public BookingService(
|
||||
IBookingRepository bookingRepository,
|
||||
IFacilityRepository facilityRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_bookingRepository = bookingRepository;
|
||||
_facilityRepository = facilityRepository;
|
||||
_customerSubscriptionRepository = customerSubscriptionRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> CreateMemberBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
@@ -50,8 +50,14 @@ namespace Fitness_core_BE.Services
|
||||
if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId))
|
||||
throw new ArgumentException("You already have a booking for this session");
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId)
|
||||
?? throw new ArgumentException("No active membership plan found");
|
||||
// A class occurrence (FKFacilityClassId set) must be covered by a subscription
|
||||
// linked to that specific class; a legacy/manually-created session (null) falls
|
||||
// back to the customer's gym-access subscription, same as before Classes existed.
|
||||
var subscription = session.FKFacilityClassId.HasValue
|
||||
? await _customerSubscriptionRepository.GetActiveSubscriptionForClassAsync(cusId, session.FKFacilityClassId.Value)
|
||||
?? throw new ArgumentException("Your membership doesn't cover this class.")
|
||||
: await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId)
|
||||
?? throw new ArgumentException("No active membership plan found");
|
||||
|
||||
// Null SessionsPerCycle = unlimited-access plan (existing gym behavior) --
|
||||
// no allowance to check. A set value caps how many Facility sessions this
|
||||
@@ -70,14 +76,21 @@ namespace Fitness_core_BE.Services
|
||||
throw new ArgumentException("You've used all the sessions included in your current plan cycle");
|
||||
}
|
||||
|
||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
if (!session.IsUnlimitedCapacity)
|
||||
{
|
||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
}
|
||||
|
||||
var booking = new Booking
|
||||
{
|
||||
FKFacilitySessionId = facilitySessionId,
|
||||
FKFacilityId = session.FKFacilityId,
|
||||
Facility = session.Facility,
|
||||
StartTime = session.StartTime,
|
||||
EndTime = session.EndTime,
|
||||
FKClientId = session.FKClientId,
|
||||
IsGuest = false,
|
||||
FKCustomerId = cusId,
|
||||
@@ -93,7 +106,7 @@ namespace Fitness_core_BE.Services
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
||||
return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -111,6 +124,7 @@ namespace Fitness_core_BE.Services
|
||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
||||
var customerId = await ResolveOptionalCustomerLinkAsync(data);
|
||||
|
||||
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
|
||||
?? throw new KeyNotFoundException("Facility session not found");
|
||||
@@ -119,16 +133,24 @@ namespace Fitness_core_BE.Services
|
||||
if (session.StartTime <= DateTime.UtcNow)
|
||||
throw new ArgumentException("This session has already started");
|
||||
|
||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
if (!session.IsUnlimitedCapacity)
|
||||
{
|
||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
}
|
||||
|
||||
var booking = new Booking
|
||||
{
|
||||
FKFacilitySessionId = facilitySessionId,
|
||||
FKFacilityId = session.FKFacilityId,
|
||||
Facility = session.Facility,
|
||||
StartTime = session.StartTime,
|
||||
EndTime = session.EndTime,
|
||||
FKClientId = session.FKClientId,
|
||||
IsGuest = true,
|
||||
FKCustomerId = customerId,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
@@ -142,7 +164,7 @@ namespace Fitness_core_BE.Services
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// The only time the OTP is ever returned -- this response IS the guest's receipt.
|
||||
return MapToDto(booking, session, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
|
||||
return MapToDto(booking, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -151,6 +173,159 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WalkIn-mode creation (new) ----------
|
||||
|
||||
public async Task<object> CreateMemberWalkInBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var (facility, startTime, endTime, partySize, _) = await ValidateWalkInRequestAsync(payload);
|
||||
|
||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var booking = new Booking
|
||||
{
|
||||
FKFacilitySessionId = null,
|
||||
FKFacilityId = facility.FacilityId,
|
||||
Facility = facility,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
PartySize = partySize,
|
||||
FKClientId = facility.FKClientId,
|
||||
IsGuest = false,
|
||||
FKCustomerId = cusId,
|
||||
// Simplifying assumption mirroring the existing Scheduled member-booking
|
||||
// path: membership covers facility use, no separate per-booking charge.
|
||||
PaymentStatus = "NotRequired",
|
||||
Status = "Confirmed"
|
||||
};
|
||||
|
||||
await _bookingRepository.CreateBooking(booking);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateGuestWalkInBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var (facility, startTime, endTime, partySize, pricePerHour) = await ValidateWalkInRequestAsync(payload);
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
||||
var customerId = await ResolveOptionalCustomerLinkAsync(data);
|
||||
|
||||
var durationHours = (decimal)(endTime - startTime).TotalHours;
|
||||
|
||||
var booking = new Booking
|
||||
{
|
||||
FKFacilitySessionId = null,
|
||||
FKFacilityId = facility.FacilityId,
|
||||
Facility = facility,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
PartySize = partySize,
|
||||
FKClientId = facility.FKClientId,
|
||||
IsGuest = true,
|
||||
FKCustomerId = customerId,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
// Staff can override the facility's default rate per booking (e.g. a discount)
|
||||
// -- the pre-filled suggestion in the FE comes from facility.PricePerHour, but
|
||||
// the charge actually uses whatever rate was submitted.
|
||||
AmountCharged = Math.Round((pricePerHour ?? facility.PricePerHour) * durationHours, 2),
|
||||
PaymentStatus = "Unpaid",
|
||||
Status = "Confirmed"
|
||||
};
|
||||
|
||||
await _bookingRepository.CreateBooking(booking);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(booking, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared validation for both WalkIn creation paths: facility must support
|
||||
/// walk-in booking, requested window must have a free unit. Locks the facility row for
|
||||
/// the duration of the caller's transaction so two concurrent claims on the same facility
|
||||
/// serialize instead of both reading "room available" before either commits.</summary>
|
||||
private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateWalkInRequestAsync(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
var endTime = startTime.AddMinutes(durationMinutes);
|
||||
|
||||
int? partySize = data.ContainsKey("partySize") && data["partySize"].ValueKind != JsonValueKind.Null
|
||||
? data["partySize"].GetInt32()
|
||||
: null;
|
||||
|
||||
// Optional per-booking rate override -- the FE pre-fills this from the facility's
|
||||
// own PricePerHour but lets staff adjust it (e.g. a discount) before charging.
|
||||
decimal? pricePerHour = data.ContainsKey("pricePerHour") && data["pricePerHour"].ValueKind != JsonValueKind.Null
|
||||
? data["pricePerHour"].GetDecimal()
|
||||
: null;
|
||||
if (pricePerHour.HasValue && pricePerHour.Value < 0)
|
||||
throw new ArgumentException("pricePerHour cannot be negative");
|
||||
|
||||
var facility = await _facilityRepository.GetFacilityById(facilityId)
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
if (!facility.SupportsBooking)
|
||||
throw new ArgumentException("This facility does not support walk-in booking");
|
||||
if (!facility.IsActive)
|
||||
throw new ArgumentException("This facility is not active");
|
||||
|
||||
await _facilityRepository.LockFacilityForUpdateAsync(facilityId);
|
||||
|
||||
// A running class occupies the whole facility, not just a seat -- reject the walk-in
|
||||
// request outright rather than letting it compete for units.
|
||||
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facilityId, startTime, endTime);
|
||||
if (overlappingSessions.Count > 0)
|
||||
throw new ArgumentException("This facility is occupied by a scheduled class during the requested time");
|
||||
|
||||
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facilityId, startTime, endTime);
|
||||
if (overlapping >= facility.NumberOfUnits)
|
||||
throw new ArgumentException("No units are free for the requested time");
|
||||
|
||||
return (facility, startTime, endTime, partySize, pricePerHour);
|
||||
}
|
||||
|
||||
/// <summary>Staff can optionally identify a walk-in guest as an already-registered member
|
||||
/// (search-and-pick, not free-typed) -- purely for identification/auto-fill on the FE, see
|
||||
/// CreateGuestBooking/CreateGuestWalkInBooking. Doesn't change IsGuest, PaymentStatus, or
|
||||
/// any membership handling; FKCustomerId is just an informational link layered on top of
|
||||
/// an otherwise-ordinary guest booking.</summary>
|
||||
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> data)
|
||||
{
|
||||
if (!data.ContainsKey("customerId") || data["customerId"].ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
_ = await _customerRepository.GetTenantCustomerLinkById(customerId)
|
||||
?? throw new KeyNotFoundException("Customer not found");
|
||||
return customerId;
|
||||
}
|
||||
|
||||
// ---------- Payment / check-in / lifecycle ----------
|
||||
|
||||
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
@@ -159,6 +334,8 @@ namespace Fitness_core_BE.Services
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
var amountCharged = data["amountCharged"].GetDecimal();
|
||||
var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null
|
||||
? data["paymentMethod"].GetString() ?? "Cash" : "Cash";
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
@@ -167,9 +344,12 @@ namespace Fitness_core_BE.Services
|
||||
|
||||
booking.PaymentStatus = "Paid";
|
||||
booking.AmountCharged = amountCharged;
|
||||
booking.PaymentMethod = paymentMethod;
|
||||
booking.PaidAt = DateTime.UtcNow;
|
||||
await CreateBookingInvoiceAsync(booking, paymentMethod);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -185,19 +365,49 @@ namespace Fitness_core_BE.Services
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required");
|
||||
Guid? rfidTagId = data.ContainsKey("rfidTagId") && data["rfidTagId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["rfidTagId"].GetString()!)
|
||||
: null;
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
// Reconcile everything the passage of time has made stale for this client BEFORE
|
||||
// processing the new check-in, instead of a background job -- see
|
||||
// Booking.Status doc comment. If the booking being checked in is itself already
|
||||
// past its window, this sweep flips it to NoShow first, so the OTP lookup below
|
||||
// (Status == "Confirmed" only) correctly refuses it.
|
||||
await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow);
|
||||
|
||||
var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode)
|
||||
?? throw new KeyNotFoundException("No confirmed booking found for this code");
|
||||
if (booking.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("No confirmed booking found for this code");
|
||||
|
||||
if (booking.OtpVerifiedAt.HasValue)
|
||||
throw new ArgumentException("This booking has already been checked in");
|
||||
|
||||
if (rfidTagId.HasValue)
|
||||
{
|
||||
var tag = await _rfidTagRepository.GetTagById(rfidTagId.Value, clientId)
|
||||
?? throw new KeyNotFoundException("RFID tag not found");
|
||||
if (tag.FKClientId != booking.FKClientId)
|
||||
throw new KeyNotFoundException("RFID tag not found");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (!await _bookingRepository.TryClaimRfidTagAsync(rfidTagId.Value, now))
|
||||
throw new ArgumentException("This tag is no longer available -- pick another one.");
|
||||
|
||||
booking.FKRfidTagId = rfidTagId.Value;
|
||||
booking.RfidAssignedAt = now;
|
||||
booking.RfidTag = tag; // for the immediate response's RfidTagLabel only
|
||||
}
|
||||
|
||||
booking.OtpVerifiedAt = DateTime.UtcNow;
|
||||
booking.CheckedInAt = DateTime.UtcNow;
|
||||
booking.Status = "CheckedIn";
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -206,6 +416,42 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lazily reconciles what a background job would otherwise have swept: Confirmed
|
||||
/// bookings whose window passed with no check-in become NoShow. Runs at the top of every
|
||||
/// check-in AND every checkout (Complete), across every facility for this client (not
|
||||
/// scoped to whichever facility is being acted on right now), so the whole roster stays
|
||||
/// accurate as long as staff keep interacting with bookings anywhere in the club.
|
||||
/// Deliberately does NOT auto-complete overdue CheckedIn bookings -- a guest booking can't
|
||||
/// be checked out while still Unpaid (see CompleteBooking), and nothing here can ask
|
||||
/// whether payment was collected, so those stay CheckedIn (tag stays InUse) until a staff
|
||||
/// member manually completes them.</summary>
|
||||
private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now)
|
||||
{
|
||||
await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now);
|
||||
// Caller's _uow.CommitAsync() persists this alongside its own changes.
|
||||
}
|
||||
|
||||
/// <summary>Snapshots a receipt the moment a booking's payment is recorded (from
|
||||
/// MarkGuestBookingPaid or the Complete checkout flow) -- a copy of the booking's numbers
|
||||
/// at that instant, not a live join, so it stays accurate even if the booking is later
|
||||
/// reassigned/rescheduled. Caller's _uow.CommitAsync() persists this alongside its own
|
||||
/// changes.</summary>
|
||||
private async Task CreateBookingInvoiceAsync(Booking booking, string paymentMethod)
|
||||
{
|
||||
await _bookingRepository.CreateBookingInvoice(new BookingInvoice
|
||||
{
|
||||
FKBookingId = booking.BookingId,
|
||||
FKClientId = booking.FKClientId,
|
||||
Amount = booking.AmountCharged,
|
||||
PaymentMethod = paymentMethod,
|
||||
GuestName = booking.GuestName ?? string.Empty,
|
||||
GuestPhone = booking.GuestPhone,
|
||||
FacilityName = booking.Facility?.Name ?? string.Empty,
|
||||
BookingStartTime = booking.StartTime,
|
||||
BookingEndTime = booking.EndTime
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
@@ -216,26 +462,15 @@ namespace Fitness_core_BE.Services
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (booking.Status == "Cancelled")
|
||||
throw new ArgumentException("This booking is already cancelled");
|
||||
if (booking.Status is "Cancelled" or "NoShow" or "Completed")
|
||||
throw new ArgumentException($"This booking is already {booking.Status} and can't be cancelled");
|
||||
if (booking.Status == "CheckedIn")
|
||||
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
|
||||
|
||||
booking.Status = "Cancelled";
|
||||
|
||||
// Give the session allowance back -- only member bookings draw one down.
|
||||
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
||||
{
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(booking.FKCustomerId!.Value);
|
||||
if (subscription != null && subscription.SubscriptionId == booking.FKCustomerSubscriptionId.Value
|
||||
&& subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
||||
{
|
||||
subscription.SessionsUsedThisCycle--;
|
||||
}
|
||||
}
|
||||
await CancelBookingCore(booking);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -244,11 +479,236 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Just the in-memory mutations (status + subscription allowance refund), no
|
||||
/// transaction demarcation or status validation -- called both by CancelBooking (after
|
||||
/// its own status checks) and by FacilityService's "mark not held" cascade (which does
|
||||
/// its own per-booking filtering before calling this for each affected booking), so
|
||||
/// there's one refund code path instead of two. Looks the subscription up by the exact
|
||||
/// id the booking drew its allowance from, rather than re-resolving "the customer's
|
||||
/// current active subscription" -- correct regardless of which kind of subscription
|
||||
/// (gym-access or class-linked) it was, and unaffected by the customer holding others.</summary>
|
||||
public async Task CancelBookingCore(Booking booking)
|
||||
{
|
||||
booking.Status = "Cancelled";
|
||||
|
||||
// Give the session allowance back -- only member bookings draw one down.
|
||||
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
||||
{
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(booking.FKCustomerSubscriptionId.Value);
|
||||
if (subscription != null && subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
||||
{
|
||||
subscription.SessionsUsedThisCycle--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> ReassignBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (!booking.IsGuest)
|
||||
throw new ArgumentException("Only guest bookings can be reassigned");
|
||||
if (booking.Status != "Confirmed")
|
||||
throw new ArgumentException("Only a booking that hasn't been checked in yet can be reassigned");
|
||||
|
||||
if (data.ContainsKey("guestName")) booking.GuestName = data["guestName"].GetString() ?? booking.GuestName;
|
||||
if (data.ContainsKey("guestPhone")) booking.GuestPhone = data["guestPhone"].GetString() ?? booking.GuestPhone;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> RescheduleBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (booking.Status != "Confirmed")
|
||||
throw new ArgumentException("Only a booking that hasn't been checked in yet can be rescheduled");
|
||||
|
||||
if (booking.FKFacilitySessionId == null)
|
||||
{
|
||||
// WalkIn: move to a new time on the same facility.
|
||||
if (!data.ContainsKey("startTime") || !data.ContainsKey("durationMinutes"))
|
||||
throw new ArgumentException("startTime and durationMinutes are required to reschedule a walk-in booking");
|
||||
|
||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
var endTime = startTime.AddMinutes(durationMinutes);
|
||||
|
||||
await _facilityRepository.LockFacilityForUpdateAsync(booking.FKFacilityId);
|
||||
|
||||
// Same class-occupancy guard as a fresh walk-in request -- otherwise staff
|
||||
// could reschedule straight into a scheduled class's time.
|
||||
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(
|
||||
booking.FKFacilityId, startTime, endTime);
|
||||
if (overlappingSessions.Count > 0)
|
||||
throw new ArgumentException("This facility is occupied by a scheduled class during the requested time");
|
||||
|
||||
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(
|
||||
booking.FKFacilityId, startTime, endTime, excludeBookingId: booking.BookingId);
|
||||
|
||||
var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId)
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
if (overlapping >= facility.NumberOfUnits)
|
||||
throw new ArgumentException("No units are free for the requested time");
|
||||
|
||||
if (booking.PaymentStatus != "Paid" && booking.IsGuest)
|
||||
{
|
||||
var durationHours = (decimal)(endTime - startTime).TotalHours;
|
||||
booking.AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2);
|
||||
}
|
||||
|
||||
booking.StartTime = startTime;
|
||||
booking.EndTime = endTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scheduled: move to a different pre-created session on the same facility.
|
||||
if (!data.ContainsKey("facilitySessionId"))
|
||||
throw new ArgumentException("facilitySessionId is required to reschedule a scheduled booking");
|
||||
|
||||
var newSessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
var newSession = await _facilityRepository.GetFacilitySessionById(newSessionId)
|
||||
?? throw new KeyNotFoundException("Facility session not found");
|
||||
if (newSession.FKFacilityId != booking.FKFacilityId)
|
||||
throw new ArgumentException("The new session must be on the same facility");
|
||||
if (newSession.IsCancelled)
|
||||
throw new ArgumentException("This session has been cancelled");
|
||||
if (newSession.StartTime <= DateTime.UtcNow)
|
||||
throw new ArgumentException("This session has already started");
|
||||
|
||||
var capacity = newSession.Capacity ?? newSession.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(newSessionId);
|
||||
// Excluding this booking's own current session membership naturally falls out
|
||||
// here since bookedCount only counts rows already on newSessionId, which this
|
||||
// booking isn't yet.
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
|
||||
booking.FKFacilitySessionId = newSessionId;
|
||||
booking.StartTime = newSession.StartTime;
|
||||
booking.EndTime = newSession.EndTime;
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> MarkNoShow(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (booking.Status != "Confirmed")
|
||||
throw new ArgumentException("Only a Confirmed booking can be marked no-show");
|
||||
|
||||
booking.Status = "NoShow";
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CompleteBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow);
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (booking.Status != "CheckedIn")
|
||||
throw new ArgumentException("Only a CheckedIn booking can be completed");
|
||||
|
||||
// A guest booking can't be checked out while still Unpaid -- payment must be
|
||||
// confirmed in this same call. Member bookings (never Unpaid) and guest bookings
|
||||
// already Paid skip straight through.
|
||||
if (booking.IsGuest && booking.PaymentStatus == "Unpaid")
|
||||
{
|
||||
var markPaid = data.ContainsKey("markPaid") && data["markPaid"].ValueKind != JsonValueKind.Null
|
||||
&& data["markPaid"].GetBoolean();
|
||||
if (!markPaid)
|
||||
throw new ArgumentException("This booking must be paid before it can be checked out");
|
||||
|
||||
var amountCollected = data.ContainsKey("amountCollected") && data["amountCollected"].ValueKind != JsonValueKind.Null
|
||||
? data["amountCollected"].GetDecimal() : (decimal?)null;
|
||||
var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null
|
||||
? data["paymentMethod"].GetString() ?? "Cash" : "Cash";
|
||||
|
||||
booking.PaymentStatus = "Paid";
|
||||
booking.AmountCharged = amountCollected ?? booking.AmountCharged;
|
||||
booking.PaymentMethod = paymentMethod;
|
||||
booking.PaidAt = DateTime.UtcNow;
|
||||
await CreateBookingInvoiceAsync(booking, paymentMethod);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
booking.Status = "Completed";
|
||||
booking.CheckedOutAt = now;
|
||||
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
|
||||
{
|
||||
booking.RfidTag.Status = "Available";
|
||||
booking.RfidTag.UpdatedAt = now;
|
||||
booking.RfidReturnedAt = now;
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reads ----------
|
||||
|
||||
public async Task<object> GetMyBookings(object payload, HttpContext httpContext)
|
||||
{
|
||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var bookings = await _bookingRepository.GetBookingsForCustomer(cusId);
|
||||
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
|
||||
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetSessionBookings(object payload, HttpContext httpContext)
|
||||
@@ -259,7 +719,28 @@ namespace Fitness_core_BE.Services
|
||||
var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId);
|
||||
// Staff view -- never expose guest OTPs here either; the door check-in flow reads
|
||||
// the OTP the guest already has, it doesn't need staff to look it up beforehand.
|
||||
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
|
||||
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetBookings(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["facilityId"].GetString()!) : null;
|
||||
Guid? facilitySessionId = data.ContainsKey("facilitySessionId") && data["facilitySessionId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["facilitySessionId"].GetString()!) : null;
|
||||
DateTime? fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : null;
|
||||
DateTime? toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : null;
|
||||
string? status = data.ContainsKey("status") && data["status"].ValueKind != JsonValueKind.Null
|
||||
? data["status"].GetString() : null;
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var bookings = await _bookingRepository.GetBookings(clientId, facilityId, facilitySessionId, fromUtc, toUtc, status);
|
||||
|
||||
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
|
||||
}
|
||||
|
||||
public async Task<object?> GetBookingView(object payload)
|
||||
@@ -273,7 +754,49 @@ namespace Fitness_core_BE.Services
|
||||
|
||||
// The share link itself is the credential (an unguessable GUID) -- the OTP is only
|
||||
// withheld once it's already been used, same rule as every other read path.
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: !booking.OtpVerifiedAt.HasValue);
|
||||
return MapToDto(booking, includeOtp: !booking.OtpVerifiedAt.HasValue);
|
||||
}
|
||||
|
||||
public async Task<object> GetBookingInvoices(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
DateTime? fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : null;
|
||||
DateTime? toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : null;
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var invoices = await _bookingRepository.GetBookingInvoices(clientId, fromUtc, toUtc);
|
||||
|
||||
return invoices.Select(i => MapInvoiceToDto(i, BuildInvoiceShareUrl(httpContext, i.BookingInvoiceId))).ToList();
|
||||
}
|
||||
|
||||
public async Task<object?> GetBookingInvoiceView(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingInvoiceId = Guid.Parse(data["bookingInvoiceId"].GetString()!);
|
||||
|
||||
var invoice = await _bookingRepository.GetBookingInvoiceById(bookingInvoiceId);
|
||||
if (invoice == null)
|
||||
return null;
|
||||
|
||||
return MapInvoiceToDto(invoice, shareUrl: null);
|
||||
}
|
||||
|
||||
public async Task<object> GetBookingDashboardSummary(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var (bookingsToday, revenueToday, checkedInNow, upcomingConfirmed, outstandingUnpaid) =
|
||||
await _bookingRepository.GetDashboardSummaryAsync(clientId, DateTime.UtcNow);
|
||||
|
||||
return new BookingDashboardSummaryDto
|
||||
{
|
||||
BookingsToday = bookingsToday,
|
||||
RevenueToday = revenueToday,
|
||||
CheckedInNow = checkedInNow,
|
||||
UpcomingConfirmed = upcomingConfirmed,
|
||||
OutstandingUnpaid = outstandingUnpaid
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
|
||||
@@ -284,24 +807,54 @@ namespace Fitness_core_BE.Services
|
||||
private static string BuildShareUrl(HttpContext httpContext, Guid bookingId)
|
||||
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view";
|
||||
|
||||
private static BookingDto MapToDto(Booking booking, FacilitySession? session, bool includeOtp, string? shareUrl = null) => new()
|
||||
private static string BuildInvoiceShareUrl(HttpContext httpContext, Guid bookingInvoiceId)
|
||||
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/booking-invoices/{bookingInvoiceId}/view";
|
||||
|
||||
private static BookingDto MapToDto(Booking booking, bool includeOtp, string? shareUrl = null) => new()
|
||||
{
|
||||
BookingId = booking.BookingId,
|
||||
FacilitySessionId = booking.FKFacilitySessionId,
|
||||
FacilityName = session?.Facility?.Name,
|
||||
SessionStartTime = session?.StartTime,
|
||||
SessionEndTime = session?.EndTime,
|
||||
FacilityId = booking.FKFacilityId,
|
||||
FacilityName = booking.Facility?.Name,
|
||||
// Per-booking fact (which path created it) -- a facility can support both modes now,
|
||||
// so this no longer derives from the facility at all.
|
||||
BookingMode = booking.FKFacilitySessionId.HasValue ? "Scheduled" : "WalkIn",
|
||||
StartTime = booking.StartTime,
|
||||
EndTime = booking.EndTime,
|
||||
PartySize = booking.PartySize,
|
||||
IsGuest = booking.IsGuest,
|
||||
CustomerId = booking.FKCustomerId,
|
||||
GuestName = booking.GuestName,
|
||||
GuestPhone = booking.GuestPhone,
|
||||
OtpCode = includeOtp ? booking.OtpCode : null,
|
||||
OtpVerified = booking.OtpVerifiedAt.HasValue,
|
||||
RfidTagId = booking.FKRfidTagId,
|
||||
RfidTagLabel = booking.RfidTag?.Label ?? booking.RfidTag?.TagCode,
|
||||
RfidAssignedAt = booking.RfidAssignedAt,
|
||||
RfidReturnedAt = booking.RfidReturnedAt,
|
||||
PaymentStatus = booking.PaymentStatus,
|
||||
AmountCharged = booking.AmountCharged,
|
||||
PaymentMethod = booking.PaymentMethod,
|
||||
PaidAt = booking.PaidAt,
|
||||
Status = booking.Status,
|
||||
BookedAt = booking.BookedAt,
|
||||
CheckedInAt = booking.CheckedInAt,
|
||||
CheckedOutAt = booking.CheckedOutAt,
|
||||
ShareUrl = shareUrl
|
||||
};
|
||||
|
||||
private static BookingInvoiceDto MapInvoiceToDto(BookingInvoice invoice, string? shareUrl) => new()
|
||||
{
|
||||
BookingInvoiceId = invoice.BookingInvoiceId,
|
||||
BookingId = invoice.FKBookingId,
|
||||
Amount = invoice.Amount,
|
||||
PaymentMethod = invoice.PaymentMethod,
|
||||
GuestName = invoice.GuestName,
|
||||
GuestPhone = invoice.GuestPhone,
|
||||
FacilityName = invoice.FacilityName,
|
||||
BookingStartTime = invoice.BookingStartTime,
|
||||
BookingEndTime = invoice.BookingEndTime,
|
||||
IssuedAt = invoice.IssuedAt,
|
||||
ShareUrl = shareUrl
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
using Fitness_core_BE.DTOs.Facility;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class ClassService : IClassService
|
||||
{
|
||||
private static readonly string[] ValidRecurrenceTypes = ["Daily", "Weekly", "Monthly", "Custom"];
|
||||
private const int GenerationHorizonDays = 56; // 8 weeks
|
||||
|
||||
private readonly IFacilityClassRepository _classRepository;
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public ClassService(
|
||||
IFacilityClassRepository classRepository,
|
||||
IFacilityRepository facilityRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_classRepository = classRepository;
|
||||
_facilityRepository = facilityRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetFacilityClasses(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var classes = await _classRepository.GetFacilityClasses(clientId, includeInactive);
|
||||
|
||||
return classes.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreateFacilityClass(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
||||
if (facility == null || facility.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Facility not found");
|
||||
if (!facility.SupportsClasses)
|
||||
throw new ArgumentException("This facility does not support classes");
|
||||
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
|
||||
var recurrenceType = data.ContainsKey("recurrenceType") ? data["recurrenceType"].GetString() ?? "Weekly" : "Weekly";
|
||||
if (!ValidRecurrenceTypes.Contains(recurrenceType))
|
||||
throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}");
|
||||
|
||||
var isUnlimitedCapacity = data.ContainsKey("isUnlimitedCapacity") && data["isUnlimitedCapacity"].GetBoolean();
|
||||
int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null
|
||||
? data["capacity"].GetInt32() : null;
|
||||
if (!isUnlimitedCapacity && capacity.HasValue && capacity.Value < 1)
|
||||
throw new ArgumentException("capacity must be at least 1 when set");
|
||||
|
||||
var durationMinutes = data.ContainsKey("durationMinutes") ? data["durationMinutes"].GetInt32() : 60;
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
|
||||
var facilityClass = new FacilityClass
|
||||
{
|
||||
FKClientId = clientId,
|
||||
FKFacilityId = facilityId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
RecurrenceType = recurrenceType,
|
||||
DurationMinutes = durationMinutes,
|
||||
Capacity = isUnlimitedCapacity ? null : capacity,
|
||||
IsUnlimitedCapacity = isUnlimitedCapacity
|
||||
};
|
||||
|
||||
if (recurrenceType == "Custom")
|
||||
{
|
||||
// Occurrences are explicit -- no rule fields, no generation watermark ever
|
||||
// consulted for this class.
|
||||
facilityClass.OccurrencesGeneratedThrough = DateTime.UtcNow.Date;
|
||||
var result = await _classRepository.CreateFacilityClass(facilityClass);
|
||||
await _uow.SaveChangesAsync(); // persist the id before occurrences reference it
|
||||
|
||||
await AddCustomOccurrencesAsync(facilityClass, data);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
result.Facility = facility;
|
||||
return MapToDto(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!data.ContainsKey("startTimeOfDay")) throw new KeyNotFoundException("startTimeOfDay is required");
|
||||
facilityClass.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!);
|
||||
facilityClass.WeekDaysMask = data.ContainsKey("weekDaysMask") ? data["weekDaysMask"].GetInt32() : 0;
|
||||
facilityClass.MonthDays = data.ContainsKey("monthDays") ? data["monthDays"].GetString() : null;
|
||||
facilityClass.SeriesStartDate = data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["seriesStartDate"].GetString()!).Date
|
||||
: DateTime.UtcNow.Date;
|
||||
facilityClass.SeriesEndDate = data.ContainsKey("seriesEndDate") && data["seriesEndDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["seriesEndDate"].GetString()!).Date
|
||||
: null;
|
||||
|
||||
if (recurrenceType == "Weekly" && facilityClass.WeekDaysMask == 0)
|
||||
throw new ArgumentException("Select at least one day of the week");
|
||||
if (recurrenceType == "Monthly" && string.IsNullOrWhiteSpace(facilityClass.MonthDays))
|
||||
throw new ArgumentException("Select at least one day of the month");
|
||||
|
||||
// Nothing generated yet -- the next GetSessions call does it lazily.
|
||||
facilityClass.OccurrencesGeneratedThrough = facilityClass.SeriesStartDate.AddDays(-1);
|
||||
|
||||
var result = await _classRepository.CreateFacilityClass(facilityClass);
|
||||
await _uow.CommitAsync();
|
||||
result.Facility = facility;
|
||||
return MapToDto(result);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateFacilityClass(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!);
|
||||
|
||||
var existing = await _classRepository.GetFacilityClassById(facilityClassId)
|
||||
?? throw new KeyNotFoundException("Class not found");
|
||||
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
if (data.ContainsKey("isUnlimitedCapacity")) existing.IsUnlimitedCapacity = data["isUnlimitedCapacity"].GetBoolean();
|
||||
if (data.ContainsKey("capacity"))
|
||||
{
|
||||
var capacity = data["capacity"].ValueKind != JsonValueKind.Null ? data["capacity"].GetInt32() : (int?)null;
|
||||
if (!existing.IsUnlimitedCapacity && capacity.HasValue && capacity.Value < 1)
|
||||
throw new ArgumentException("capacity must be at least 1 when set");
|
||||
existing.Capacity = existing.IsUnlimitedCapacity ? null : capacity;
|
||||
}
|
||||
|
||||
// Only these recurrence-rule fields trigger a regeneration -- editing the name,
|
||||
// description, capacity or active flag doesn't touch existing occurrences.
|
||||
var recurrenceKeys = new[] { "recurrenceType", "startTimeOfDay", "durationMinutes", "weekDaysMask", "monthDays", "seriesStartDate", "seriesEndDate" };
|
||||
var recurrenceChanged = recurrenceKeys.Any(data.ContainsKey);
|
||||
|
||||
if (recurrenceChanged && existing.RecurrenceType != "Custom")
|
||||
{
|
||||
if (data.ContainsKey("recurrenceType"))
|
||||
{
|
||||
var recurrenceType = data["recurrenceType"].GetString() ?? existing.RecurrenceType;
|
||||
if (!ValidRecurrenceTypes.Contains(recurrenceType))
|
||||
throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}");
|
||||
existing.RecurrenceType = recurrenceType;
|
||||
}
|
||||
if (data.ContainsKey("startTimeOfDay")) existing.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!);
|
||||
if (data.ContainsKey("durationMinutes"))
|
||||
{
|
||||
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
existing.DurationMinutes = durationMinutes;
|
||||
}
|
||||
if (data.ContainsKey("weekDaysMask")) existing.WeekDaysMask = data["weekDaysMask"].GetInt32();
|
||||
if (data.ContainsKey("monthDays")) existing.MonthDays = data["monthDays"].GetString();
|
||||
if (data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null)
|
||||
existing.SeriesStartDate = DateTime.Parse(data["seriesStartDate"].GetString()!).Date;
|
||||
if (data.ContainsKey("seriesEndDate"))
|
||||
existing.SeriesEndDate = data["seriesEndDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["seriesEndDate"].GetString()!).Date : null;
|
||||
|
||||
if (existing.RecurrenceType == "Weekly" && existing.WeekDaysMask == 0)
|
||||
throw new ArgumentException("Select at least one day of the week");
|
||||
if (existing.RecurrenceType == "Monthly" && string.IsNullOrWhiteSpace(existing.MonthDays))
|
||||
throw new ArgumentException("Select at least one day of the month");
|
||||
|
||||
// Anything past or already booked is left alone -- only future, unbooked
|
||||
// occurrences are cleared so the next read regenerates them under the new rule.
|
||||
await _classRepository.DeleteFutureUnbookedOccurrencesAsync(existing.FacilityClassId, DateTime.UtcNow);
|
||||
existing.OccurrencesGeneratedThrough = existing.SeriesStartDate > DateTime.UtcNow.Date
|
||||
? existing.SeriesStartDate.AddDays(-1)
|
||||
: DateTime.UtcNow.Date.AddDays(-1);
|
||||
}
|
||||
else if (existing.RecurrenceType == "Custom" && data.ContainsKey("addCustomOccurrences"))
|
||||
{
|
||||
await AddCustomOccurrencesAsync(existing, data, "addCustomOccurrences");
|
||||
}
|
||||
|
||||
var result = await _classRepository.UpdateFacilityClass(existing);
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetOccurrences(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!);
|
||||
bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var facilityClass = await _classRepository.GetFacilityClassById(facilityClassId);
|
||||
if (facilityClass == null || facilityClass.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Class not found");
|
||||
|
||||
var occurrences = await _classRepository.GetOccurrencesForClassAsync(facilityClassId, includeCancelled);
|
||||
return occurrences.Select(o => new
|
||||
{
|
||||
facilitySessionId = o.FacilitySessionId,
|
||||
startTime = o.StartTime,
|
||||
endTime = o.EndTime,
|
||||
capacity = o.Capacity,
|
||||
isUnlimitedCapacity = o.IsUnlimitedCapacity,
|
||||
isCancelled = o.IsCancelled,
|
||||
notHeldReason = o.NotHeldReason,
|
||||
notHeldAt = o.NotHeldAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetSchedulingConflicts(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var conflicts = await _classRepository.GetSchedulingConflictsAsync(clientId);
|
||||
|
||||
return conflicts.Select(b => new SchedulingConflictDto
|
||||
{
|
||||
BookingId = b.BookingId,
|
||||
FacilityId = b.FKFacilityId,
|
||||
FacilityName = b.Facility?.Name,
|
||||
GuestName = b.IsGuest ? b.GuestName : null,
|
||||
StartTime = b.StartTime,
|
||||
EndTime = b.EndTime
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task EnsureOccurrencesGeneratedAsync(Guid clientId)
|
||||
{
|
||||
var horizon = DateTime.UtcNow.Date.AddDays(GenerationHorizonDays);
|
||||
var classes = await _classRepository.GetClassesNeedingGenerationAsync(clientId, horizon);
|
||||
if (classes.Count == 0) return;
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
foreach (var facilityClass in classes)
|
||||
await GenerateOccurrencesForClassAsync(facilityClass, horizon);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Two concurrent calendar loads raced to generate the same occurrence -- the
|
||||
// unique (FKFacilityClassId, StartTime) index caught it. Roll back this whole
|
||||
// batch rather than partially applying it; the next read regenerates safely
|
||||
// (watermarks simply won't have advanced this one time).
|
||||
await _uow.RollbackAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOccurrencesForClassAsync(FacilityClass facilityClass, DateTime horizon)
|
||||
{
|
||||
var genFrom = facilityClass.OccurrencesGeneratedThrough > facilityClass.SeriesStartDate
|
||||
? facilityClass.OccurrencesGeneratedThrough.AddDays(1)
|
||||
: facilityClass.SeriesStartDate;
|
||||
var genTo = facilityClass.SeriesEndDate.HasValue && facilityClass.SeriesEndDate.Value < horizon
|
||||
? facilityClass.SeriesEndDate.Value
|
||||
: horizon;
|
||||
|
||||
if (genFrom > genTo)
|
||||
{
|
||||
facilityClass.OccurrencesGeneratedThrough = horizon;
|
||||
return;
|
||||
}
|
||||
|
||||
var existingStartTimes = await _classRepository.GetExistingOccurrenceStartTimesAsync(
|
||||
facilityClass.FacilityClassId,
|
||||
LocalToUtc(genFrom),
|
||||
LocalToUtc(genTo.AddDays(1)));
|
||||
|
||||
var monthDays = !string.IsNullOrWhiteSpace(facilityClass.MonthDays)
|
||||
? facilityClass.MonthDays.Split(',').Select(d => int.Parse(d.Trim())).ToHashSet()
|
||||
: new HashSet<int>();
|
||||
|
||||
var toAdd = new List<FacilitySession>();
|
||||
for (var date = genFrom.Date; date <= genTo.Date; date = date.AddDays(1))
|
||||
{
|
||||
bool runsToday = facilityClass.RecurrenceType switch
|
||||
{
|
||||
"Daily" => true,
|
||||
"Weekly" => (facilityClass.WeekDaysMask & (1 << (int)date.DayOfWeek)) != 0,
|
||||
"Monthly" => monthDays.Contains(date.Day),
|
||||
_ => false
|
||||
};
|
||||
if (!runsToday) continue;
|
||||
|
||||
var utcStart = LocalToUtc(date.Date + facilityClass.StartTimeOfDay);
|
||||
if (existingStartTimes.Contains(utcStart)) continue;
|
||||
|
||||
toAdd.Add(new FacilitySession
|
||||
{
|
||||
FKFacilityId = facilityClass.FKFacilityId,
|
||||
FKClientId = facilityClass.FKClientId,
|
||||
StartTime = utcStart,
|
||||
EndTime = utcStart.AddMinutes(facilityClass.DurationMinutes),
|
||||
Capacity = facilityClass.Capacity,
|
||||
IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity,
|
||||
FKFacilityClassId = facilityClass.FacilityClassId
|
||||
});
|
||||
}
|
||||
|
||||
if (toAdd.Count > 0)
|
||||
await _classRepository.AddOccurrencesAsync(toAdd);
|
||||
|
||||
facilityClass.OccurrencesGeneratedThrough = genTo;
|
||||
}
|
||||
|
||||
private static DateTime LocalToUtc(DateTime localUnspecified)
|
||||
=> TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified), TimeZoneHelper.SriLanka);
|
||||
|
||||
private async Task AddCustomOccurrencesAsync(FacilityClass facilityClass, Dictionary<string, JsonElement> data, string key = "customOccurrences")
|
||||
{
|
||||
if (!data.ContainsKey(key) || data[key].ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
var occurrences = new List<FacilitySession>();
|
||||
foreach (var occEl in data[key].EnumerateArray())
|
||||
{
|
||||
var startTime = DateTime.Parse(occEl.GetProperty("startTime").GetString()!).ToUniversalTime();
|
||||
var durationMinutes = occEl.GetProperty("durationMinutes").GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
|
||||
occurrences.Add(new FacilitySession
|
||||
{
|
||||
FKFacilityId = facilityClass.FKFacilityId,
|
||||
FKClientId = facilityClass.FKClientId,
|
||||
StartTime = startTime,
|
||||
EndTime = startTime.AddMinutes(durationMinutes),
|
||||
Capacity = facilityClass.Capacity,
|
||||
IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity,
|
||||
FKFacilityClassId = facilityClass.FacilityClassId
|
||||
});
|
||||
}
|
||||
|
||||
if (occurrences.Count > 0)
|
||||
await _classRepository.AddOccurrencesAsync(occurrences);
|
||||
}
|
||||
|
||||
private static FacilityClassDto MapToDto(FacilityClass fc) => new()
|
||||
{
|
||||
FacilityClassId = fc.FacilityClassId,
|
||||
FacilityId = fc.FKFacilityId,
|
||||
FacilityName = fc.Facility?.Name,
|
||||
Name = fc.Name,
|
||||
Description = fc.Description,
|
||||
RecurrenceType = fc.RecurrenceType,
|
||||
StartTimeOfDay = fc.RecurrenceType == "Custom" ? null : fc.StartTimeOfDay.ToString("hh\\:mm"),
|
||||
DurationMinutes = fc.DurationMinutes,
|
||||
WeekDaysMask = fc.WeekDaysMask,
|
||||
MonthDays = fc.MonthDays,
|
||||
SeriesStartDate = fc.SeriesStartDate,
|
||||
SeriesEndDate = fc.SeriesEndDate,
|
||||
Capacity = fc.Capacity,
|
||||
IsUnlimitedCapacity = fc.IsUnlimitedCapacity,
|
||||
IsActive = fc.IsActive,
|
||||
CreatedAt = fc.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,12 @@ using Fitness_core_BE.Helpers;
|
||||
|
||||
namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
{
|
||||
public class CustomerPlanService : ICustomerPlanService
|
||||
public class CustomerPlanService(
|
||||
ICustomerPlanRepository customerPlanRepository,
|
||||
IFacilityRepository facilityRepository,
|
||||
IFacilityClassRepository facilityClassRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper) : ICustomerPlanService
|
||||
{
|
||||
private const string ScopeIndividual = "Individual";
|
||||
private const string ScopeGroup = "Group";
|
||||
@@ -20,22 +25,11 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
private const string BillingPrepaid = "Prepaid";
|
||||
private const string BillingPostpaid = "Postpaid";
|
||||
|
||||
private readonly ICustomerPlanRepository _customerPlanRepository;
|
||||
private readonly IPlaceRepo _placeRepo;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public CustomerPlanService(
|
||||
ICustomerPlanRepository customerPlanRepository,
|
||||
IPlaceRepo placeRepo,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_customerPlanRepository = customerPlanRepository;
|
||||
_placeRepo = placeRepo;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository;
|
||||
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
||||
private readonly IFacilityClassRepository _facilityClassRepository = facilityClassRepository;
|
||||
private readonly IUnitOfWork _uow = uow;
|
||||
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||
|
||||
public async Task<object> GetAllCustomerPlans(object payload, HttpContext httpContext)
|
||||
{
|
||||
@@ -58,9 +52,9 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
|
||||
var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId);
|
||||
|
||||
var placeIdsByPlan = await _placeRepo.GetPlaceIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList());
|
||||
var placeIdsByPlan = await _facilityRepository.GetFacilityIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList());
|
||||
var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList();
|
||||
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds);
|
||||
var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(allPlaceIds);
|
||||
|
||||
return plans.Select(p =>
|
||||
{
|
||||
@@ -109,6 +103,16 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
// Only meaningful when facilityClassId is set below (the Classes tab's "Manage
|
||||
// Plan" action) -- never consulted for an ordinary plan, which always grants gym
|
||||
// access regardless of this stored value. Defaults to false for a class plan
|
||||
// (the "sportsman" case: class access only) unless staff opts it on.
|
||||
Guid? facilityClassId = data.ContainsKey("facilityClassId") && data["facilityClassId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["facilityClassId"].GetString()!) : null;
|
||||
var grantsGymAccess = facilityClassId.HasValue
|
||||
? data.ContainsKey("grantsGymAccess") && data["grantsGymAccess"].GetBoolean()
|
||||
: true;
|
||||
|
||||
var customerPlan = new Models.CustomerPlan
|
||||
{
|
||||
FKClientId = clientId,
|
||||
@@ -132,7 +136,8 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
: null,
|
||||
SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone,
|
||||
RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0,
|
||||
BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid
|
||||
BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid,
|
||||
GrantsGymAccess = grantsGymAccess
|
||||
};
|
||||
|
||||
NormalizeAndValidate(customerPlan);
|
||||
@@ -140,9 +145,12 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
if (facilityClassId.HasValue)
|
||||
await _facilityClassRepository.LinkPlanToClassAsync(result.CustomerPlanId, facilityClassId.Value);
|
||||
|
||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||
if (placeIds != null)
|
||||
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
|
||||
await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
@@ -206,7 +214,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
|
||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||
if (placeIds != null)
|
||||
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
|
||||
await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
@@ -400,8 +408,8 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
|
||||
private async Task<CustomerPlanDto> MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName)
|
||||
{
|
||||
var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId);
|
||||
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds);
|
||||
var placeIds = await _facilityRepository.GetFacilityIdsForPlanAsync(plan.CustomerPlanId);
|
||||
var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(placeIds);
|
||||
return MapToDto(plan, clientName, placeIds, placeNames);
|
||||
}
|
||||
|
||||
|
||||
+31
-14
@@ -440,13 +440,27 @@ namespace Fitness_core_BE.Services.CustomerService
|
||||
// member is created without a plan — e.g. a group secondary added later).
|
||||
if (data.ContainsKey("customerPlanId") && data["customerPlanId"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
|
||||
// A class-linked plan (created from the Classes tab's "Manage Plan" action)
|
||||
// stacks freely with other class-only plans and with one gym-access plan --
|
||||
// only a second GYM-ACCESS subscription is blocked. See
|
||||
// CustomerPlan.GrantsGymAccess.
|
||||
var grantsGymAccess = await _customerSubscriptionRepository.PlanGrantsGymAccessAsync(customerPlanId);
|
||||
|
||||
// Guard against duplicate active subscriptions at this gym: callers like
|
||||
// the create-existing-customer and edit-customer flows submit whatever plan
|
||||
// is currently shown on the form, not necessarily a deliberate "assign a new
|
||||
// plan" action, so re-submitting an already-assigned plan must not stack a
|
||||
// second active subscription. Assigning a genuinely different plan belongs to
|
||||
// ChangeMembershipPlan, which explicitly deactivates the old one first.
|
||||
var existingActive = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
|
||||
// second active subscription. Assigning a genuinely different GYM plan
|
||||
// belongs to ChangeMembershipPlan, which explicitly deactivates the old one
|
||||
// first; a genuinely different CLASS plan is just a new, additional
|
||||
// subscription (handled below) -- it's only a duplicate of itself that's
|
||||
// blocked here.
|
||||
var existingActive = grantsGymAccess
|
||||
? await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId)
|
||||
: await _customerSubscriptionRepository.GetActiveSubscriptionForPlanAsync(customerId, customerPlanId);
|
||||
|
||||
if (existingActive != null)
|
||||
{
|
||||
alreadyAssigned = true;
|
||||
@@ -457,15 +471,18 @@ namespace Fitness_core_BE.Services.CustomerService
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not primary anywhere (checked above), but could still be an active
|
||||
// secondary on someone else's group plan -- GetActiveSubscriptionByCustomerId
|
||||
// can't see that (it only matches CustomerSubscription.FKCustomerId, the
|
||||
// primary). One customer, one plan at a time either way.
|
||||
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
|
||||
if (hasOtherActivePlan)
|
||||
throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one.");
|
||||
if (grantsGymAccess)
|
||||
{
|
||||
// Not primary anywhere (checked above), but could still be an active
|
||||
// secondary on someone else's group plan -- GetActiveGymAccessSubscriptionAsync
|
||||
// can't see that (it only matches CustomerSubscription.FKCustomerId, the
|
||||
// primary). One customer, one GYM-ACCESS plan at a time; class-only
|
||||
// plans are unrestricted.
|
||||
var hasOtherActiveGymPlan = await _customerSubscriptionRepository.HasActiveGymAccessPlanAsync(customerId);
|
||||
if (hasOtherActiveGymPlan)
|
||||
throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one.");
|
||||
}
|
||||
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
var onboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate");
|
||||
var chargePayment = DataExtractor.GetOptionalBool(data, "chargePayment") ?? true;
|
||||
|
||||
@@ -1403,7 +1420,7 @@ namespace Fitness_core_BE.Services.CustomerService
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
|
||||
if (subscription == null)
|
||||
return new { hasSubscription = false };
|
||||
|
||||
@@ -1477,7 +1494,7 @@ namespace Fitness_core_BE.Services.CustomerService
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||
|
||||
var customerSubscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
|
||||
var customerSubscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
|
||||
|
||||
if (customerSubscription != null)
|
||||
{
|
||||
@@ -1539,7 +1556,7 @@ namespace Fitness_core_BE.Services.CustomerService
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
|
||||
if (subscription == null)
|
||||
throw new KeyNotFoundException("You don't have an active gym to unfollow.");
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ namespace Fitness_core_BE.Services.CustomerSubscription
|
||||
}
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId);
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
@@ -257,11 +257,15 @@ namespace Fitness_core_BE.Services.CustomerSubscription
|
||||
if (alreadyMember != null)
|
||||
throw new ArgumentException("This customer is already a member of the subscription");
|
||||
|
||||
// One customer, one plan at a time -- catches both "already primary on their own
|
||||
// plan" and "already a secondary on a different group", neither of which the
|
||||
// check above sees (it only looks at this one subscription).
|
||||
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
|
||||
if (hasOtherActivePlan)
|
||||
// One customer, one GYM-ACCESS plan at a time -- catches both "already primary
|
||||
// on their own plan" and "already a secondary on a different group", neither of
|
||||
// which the check above sees (it only looks at this one subscription). Group
|
||||
// plans are ordinary gym plans (no class links), so this is always the
|
||||
// gym-access check in practice; left as such rather than a generic
|
||||
// HasAnyActiveSubscriptionAsync so a class-only subscription elsewhere never
|
||||
// blocks joining a group.
|
||||
var hasOtherActiveGymPlan = await _customerSubscriptionRepository.HasActiveGymAccessPlanAsync(customerId);
|
||||
if (hasOtherActiveGymPlan)
|
||||
throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another.");
|
||||
|
||||
var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember
|
||||
|
||||
+222
-29
@@ -10,15 +10,22 @@ namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class FacilityService : IFacilityService
|
||||
{
|
||||
private static readonly string[] ValidFacilityTypes = { "Gym", "Pool", "Court", "Class" };
|
||||
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
private readonly IClassService _classService;
|
||||
private readonly IBookingService _bookingService;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public FacilityService(IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
||||
public FacilityService(
|
||||
IFacilityRepository facilityRepository,
|
||||
IClassService classService,
|
||||
IBookingService bookingService,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_facilityRepository = facilityRepository;
|
||||
_classService = classService;
|
||||
_bookingService = bookingService;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
@@ -31,7 +38,9 @@ namespace Fitness_core_BE.Services
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive);
|
||||
|
||||
return facilities.Select(MapToDto).ToList();
|
||||
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(facilities.Select(f => f.FacilityId).ToList());
|
||||
|
||||
return facilities.Select(f => MapToDto(f, devicesByFacility.GetValueOrDefault(f.FacilityId))).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreateFacility(object payload, HttpContext httpContext)
|
||||
@@ -41,11 +50,23 @@ namespace Fitness_core_BE.Services
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
if (!data.ContainsKey("facilityType")) throw new KeyNotFoundException("facilityType is required");
|
||||
|
||||
var facilityType = data["facilityType"].GetString() ?? "";
|
||||
if (!ValidFacilityTypes.Contains(facilityType))
|
||||
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
|
||||
var supportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : true;
|
||||
var supportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : false;
|
||||
if (!supportsClasses && !supportsBooking)
|
||||
throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true");
|
||||
|
||||
var defaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1;
|
||||
if (supportsClasses && defaultCapacity < 1)
|
||||
throw new ArgumentException("defaultCapacity must be at least 1");
|
||||
|
||||
var numberOfUnits = data.ContainsKey("numberOfUnits") ? data["numberOfUnits"].GetInt32() : 1;
|
||||
if (supportsBooking && numberOfUnits < 1)
|
||||
throw new ArgumentException("numberOfUnits must be at least 1");
|
||||
|
||||
var pricePerHour = data.ContainsKey("pricePerHour") ? data["pricePerHour"].GetDecimal() : 0m;
|
||||
if (pricePerHour < 0)
|
||||
throw new ArgumentException("pricePerHour cannot be negative");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
@@ -53,18 +74,18 @@ namespace Fitness_core_BE.Services
|
||||
{
|
||||
FKClientId = clientId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
FacilityType = facilityType,
|
||||
SupportsClasses = supportsClasses,
|
||||
SupportsBooking = supportsBooking,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
DefaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1
|
||||
DefaultCapacity = defaultCapacity,
|
||||
NumberOfUnits = numberOfUnits,
|
||||
PricePerHour = pricePerHour
|
||||
};
|
||||
|
||||
if (facility.DefaultCapacity < 1)
|
||||
throw new ArgumentException("defaultCapacity must be at least 1");
|
||||
|
||||
var result = await _facilityRepository.CreateFacility(facility);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
return MapToDto(result, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -86,13 +107,16 @@ namespace Fitness_core_BE.Services
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("facilityType"))
|
||||
{
|
||||
var facilityType = data["facilityType"].GetString() ?? existing.FacilityType;
|
||||
if (!ValidFacilityTypes.Contains(facilityType))
|
||||
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
|
||||
existing.FacilityType = facilityType;
|
||||
}
|
||||
|
||||
// Validate on the merged result, not each incoming field in isolation -- e.g.
|
||||
// flipping SupportsClasses off must still leave SupportsBooking (old or new) true.
|
||||
var newSupportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : existing.SupportsClasses;
|
||||
var newSupportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : existing.SupportsBooking;
|
||||
if (!newSupportsClasses && !newSupportsBooking)
|
||||
throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true");
|
||||
existing.SupportsClasses = newSupportsClasses;
|
||||
existing.SupportsBooking = newSupportsBooking;
|
||||
|
||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||
if (data.ContainsKey("defaultCapacity"))
|
||||
{
|
||||
@@ -100,12 +124,25 @@ namespace Fitness_core_BE.Services
|
||||
if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1");
|
||||
existing.DefaultCapacity = capacity;
|
||||
}
|
||||
if (data.ContainsKey("numberOfUnits"))
|
||||
{
|
||||
var units = data["numberOfUnits"].GetInt32();
|
||||
if (units < 1) throw new ArgumentException("numberOfUnits must be at least 1");
|
||||
existing.NumberOfUnits = units;
|
||||
}
|
||||
if (data.ContainsKey("pricePerHour"))
|
||||
{
|
||||
var price = data["pricePerHour"].GetDecimal();
|
||||
if (price < 0) throw new ArgumentException("pricePerHour cannot be negative");
|
||||
existing.PricePerHour = price;
|
||||
}
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
var result = await _facilityRepository.UpdateFacility(existing);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List<Guid> { result.FacilityId });
|
||||
return MapToDto(result, devicesByFacility.GetValueOrDefault(result.FacilityId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -128,7 +165,7 @@ namespace Fitness_core_BE.Services
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(facility);
|
||||
return MapToDto(facility, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -154,6 +191,9 @@ namespace Fitness_core_BE.Services
|
||||
if (facility == null || facility.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
if (!facility.SupportsClasses)
|
||||
throw new ArgumentException("Sessions can only be created for facilities that support classes");
|
||||
|
||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
|
||||
if (endTime <= startTime)
|
||||
@@ -200,9 +240,15 @@ namespace Fitness_core_BE.Services
|
||||
var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime()
|
||||
: fromUtc.AddDays(7);
|
||||
bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc);
|
||||
|
||||
// The one read path everything (calendar, class-details occurrence list) already
|
||||
// goes through -- lazy generation here means no background job is needed.
|
||||
await _classService.EnsureOccurrencesGeneratedAsync(clientId);
|
||||
|
||||
var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc, includeCancelled);
|
||||
|
||||
var result = new List<FacilitySessionDto>();
|
||||
foreach (var session in sessions)
|
||||
@@ -214,6 +260,13 @@ namespace Fitness_core_BE.Services
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>"Mark not held" -- sets IsCancelled/NotHeldReason/NotHeldAt and cascades to
|
||||
/// every Confirmed booking on the occurrence: cancels it and refunds any subscription
|
||||
/// allowance, through the exact same core BookingService.CancelBooking uses (one refund
|
||||
/// code path, not two). The freed time becomes bookable again automatically -- the
|
||||
/// walk-in overlap guard only checks non-cancelled sessions. Same entry point as the old
|
||||
/// bare-IsCancelled CancelFacilitySession this replaces; also reachable via the
|
||||
/// memberships-gated facility-classes/.../not-held route for the Classes tab.</summary>
|
||||
public async Task<object> CancelFacilitySession(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
@@ -221,11 +274,16 @@ namespace Fitness_core_BE.Services
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
var reason = data.ContainsKey("reason") && data["reason"].ValueKind != JsonValueKind.Null
|
||||
? data["reason"].GetString() : null;
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var session = await _facilityRepository.CancelFacilitySession(facilitySessionId, clientId)
|
||||
var session = await _facilityRepository.MarkFacilitySessionNotHeldAsync(facilitySessionId, clientId, reason)
|
||||
?? throw new KeyNotFoundException("Facility session not found");
|
||||
|
||||
foreach (var booking in session.Bookings.Where(b => b.Status == "Confirmed"))
|
||||
await _bookingService.CancelBookingCore(booking);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(session);
|
||||
}
|
||||
@@ -236,15 +294,145 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
private static FacilityDto MapToDto(Facility facility) => new()
|
||||
// --- Device binding (absorbed from PlaceService.AssignDevice, unchanged logic) ---
|
||||
|
||||
public async Task<object> AssignDevice(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
||||
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
||||
if (facility == null || facility.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null;
|
||||
if (!hasDeviceId)
|
||||
{
|
||||
await _facilityRepository.UnassignDeviceAsync(facilityId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var deviceId = Guid.Parse(data["deviceId"].GetString()!);
|
||||
var device = await _facilityRepository.GetDeviceByIdForClientAsync(deviceId, clientId)
|
||||
?? throw new KeyNotFoundException("Device not found");
|
||||
|
||||
if (await _facilityRepository.IsDeviceAssignedToAnotherFacilityAsync(deviceId, facilityId))
|
||||
throw new ArgumentException("This device is already assigned to another facility.");
|
||||
|
||||
await _facilityRepository.AssignDeviceAsync(facilityId, deviceId);
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List<Guid> { facilityId });
|
||||
return MapToDto(facility, devicesByFacility.GetValueOrDefault(facilityId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Availability (new) ---
|
||||
|
||||
public async Task<object> GetAvailability(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required");
|
||||
if (!data.ContainsKey("durationMinutes")) throw new KeyNotFoundException("durationMinutes is required");
|
||||
|
||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
var endTime = startTime.AddMinutes(durationMinutes);
|
||||
|
||||
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["facilityId"].GetString()!)
|
||||
: null;
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var facilities = facilityId.HasValue
|
||||
? new List<Facility> { await _facilityRepository.GetFacilityById(facilityId.Value) is { } f && f.FKClientId == clientId ? f : throw new KeyNotFoundException("Facility not found") }
|
||||
: await _facilityRepository.GetAllFacilities(clientId, includeInactive: false);
|
||||
|
||||
var result = new List<FacilityAvailabilityDto>();
|
||||
foreach (var facility in facilities)
|
||||
{
|
||||
// True interval overlap, not "session starts inside the window" -- a class that
|
||||
// started earlier and is still running must still count.
|
||||
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facility.FacilityId, startTime, endTime);
|
||||
|
||||
int? bookingTotal = null, bookingFree = null, classTotal = null, classFree = null;
|
||||
bool classIsUnlimited = false;
|
||||
|
||||
if (facility.SupportsBooking)
|
||||
{
|
||||
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facility.FacilityId, startTime, endTime);
|
||||
bookingTotal = facility.NumberOfUnits;
|
||||
// A running class occupies the whole facility, not just a seat.
|
||||
bookingFree = overlappingSessions.Count > 0 ? 0 : Math.Max(0, facility.NumberOfUnits - overlapping);
|
||||
}
|
||||
|
||||
if (facility.SupportsClasses)
|
||||
{
|
||||
// An unlimited-capacity occurrence has no meaningful total -- Capacity stays
|
||||
// null on it either way, so summing would silently under-count by falling
|
||||
// back to the facility default instead of reporting "no cap" at all.
|
||||
if (overlappingSessions.Any(s => s.IsUnlimitedCapacity))
|
||||
{
|
||||
classIsUnlimited = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalCapacity = overlappingSessions.Sum(s => s.Capacity ?? facility.DefaultCapacity);
|
||||
var totalBooked = 0;
|
||||
foreach (var s in overlappingSessions)
|
||||
totalBooked += await _facilityRepository.GetActiveBookingCount(s.FacilitySessionId);
|
||||
|
||||
classTotal = totalCapacity;
|
||||
classFree = Math.Max(0, totalCapacity - totalBooked);
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new FacilityAvailabilityDto
|
||||
{
|
||||
FacilityId = facility.FacilityId,
|
||||
FacilityName = facility.Name,
|
||||
SupportsClasses = facility.SupportsClasses,
|
||||
SupportsBooking = facility.SupportsBooking,
|
||||
BookingTotalUnits = bookingTotal,
|
||||
BookingUnitsFree = bookingFree,
|
||||
ClassTotalCapacity = classTotal,
|
||||
ClassUnitsFree = classFree,
|
||||
ClassIsUnlimitedCapacity = classIsUnlimited,
|
||||
PricePerHour = facility.PricePerHour
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FacilityDto MapToDto(Facility facility, AccessDevice? device) => new()
|
||||
{
|
||||
FacilityId = facility.FacilityId,
|
||||
Name = facility.Name,
|
||||
FacilityType = facility.FacilityType,
|
||||
SupportsClasses = facility.SupportsClasses,
|
||||
SupportsBooking = facility.SupportsBooking,
|
||||
Description = facility.Description,
|
||||
DefaultCapacity = facility.DefaultCapacity,
|
||||
NumberOfUnits = facility.NumberOfUnits,
|
||||
PricePerHour = facility.PricePerHour,
|
||||
IsActive = facility.IsActive,
|
||||
CreatedAt = facility.CreatedAt
|
||||
CreatedAt = facility.CreatedAt,
|
||||
DeviceId = device?.DeviceId,
|
||||
DeviceName = device?.DeviceName
|
||||
};
|
||||
|
||||
private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new()
|
||||
@@ -255,8 +443,13 @@ namespace Fitness_core_BE.Services
|
||||
StartTime = session.StartTime,
|
||||
EndTime = session.EndTime,
|
||||
Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1,
|
||||
IsUnlimitedCapacity = session.IsUnlimitedCapacity,
|
||||
BookedCount = bookedCount,
|
||||
IsCancelled = session.IsCancelled
|
||||
IsCancelled = session.IsCancelled,
|
||||
FacilityClassId = session.FKFacilityClassId,
|
||||
FacilityClassName = session.FacilityClass?.Name,
|
||||
NotHeldReason = session.NotHeldReason,
|
||||
NotHeldAt = session.NotHeldAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
using Fitness_core_BE.DTOs.Inventory;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class InventoryService : IInventoryService
|
||||
{
|
||||
private static readonly string[] ValidReturnCategories =
|
||||
["Expired", "Damaged", "Lost", "SupplierReturn", "CustomerReturn", "Other"];
|
||||
|
||||
private readonly IInventoryRepository _inventoryRepository;
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public InventoryService(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_inventoryRepository = inventoryRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
// --- Products ---
|
||||
|
||||
public async Task<object> GetProducts(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var products = await _inventoryRepository.GetProducts(clientId, includeInactive);
|
||||
|
||||
return (await Task.WhenAll(products.Select(MapToDtoAsync))).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetProductById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var productId = Guid.Parse(data["productId"].GetString()!);
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var product = await _inventoryRepository.GetProductById(productId);
|
||||
if (product == null || product.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Product not found");
|
||||
|
||||
var grnItems = await _inventoryRepository.GetGrnItemsForProduct(productId);
|
||||
var saleItems = await _inventoryRepository.GetSaleItemsForProduct(productId);
|
||||
var returns = await _inventoryRepository.GetStockReturnsForProduct(productId);
|
||||
|
||||
var movements = new List<ProductMovementDto>();
|
||||
movements.AddRange(grnItems.Select(i => new ProductMovementDto
|
||||
{
|
||||
Type = "GRN",
|
||||
Reference = i.Grn?.GrnNumber ?? string.Empty,
|
||||
QuantityChange = i.Quantity,
|
||||
Date = i.Grn?.ReceivedAt ?? DateTime.UtcNow
|
||||
}));
|
||||
movements.AddRange(saleItems.Select(i => new ProductMovementDto
|
||||
{
|
||||
Type = "Sale",
|
||||
Reference = i.Sale?.SaleNumber ?? string.Empty,
|
||||
QuantityChange = -i.Quantity,
|
||||
Date = i.Sale?.SoldAt ?? DateTime.UtcNow
|
||||
}));
|
||||
movements.AddRange(returns.Select(r => new ProductMovementDto
|
||||
{
|
||||
Type = "Return",
|
||||
Reference = r.Category,
|
||||
// CustomerReturn is the one category that adds back to stock -- see
|
||||
// StockReturn's doc comment.
|
||||
QuantityChange = r.Category == "CustomerReturn" ? r.Quantity : -r.Quantity,
|
||||
Date = r.ReturnedAt
|
||||
}));
|
||||
movements = movements.OrderByDescending(m => m.Date).ToList();
|
||||
|
||||
var dto = await MapToDtoAsync(product);
|
||||
return new ProductDetailDto
|
||||
{
|
||||
ProductId = dto.ProductId,
|
||||
Name = dto.Name,
|
||||
Sku = dto.Sku,
|
||||
ImageUrl = dto.ImageUrl,
|
||||
UnitOfMeasure = dto.UnitOfMeasure,
|
||||
CostPrice = dto.CostPrice,
|
||||
SellingPrice = dto.SellingPrice,
|
||||
QuantityOnHand = dto.QuantityOnHand,
|
||||
IsActive = dto.IsActive,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
Movements = movements
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<object> CreateProduct(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
|
||||
var sellingPrice = data.ContainsKey("sellingPrice") ? data["sellingPrice"].GetDecimal() : 0m;
|
||||
if (sellingPrice < 0) throw new ArgumentException("sellingPrice cannot be negative");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var product = new Product
|
||||
{
|
||||
FKClientId = clientId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
UnitOfMeasure = data.ContainsKey("unitOfMeasure") ? data["unitOfMeasure"].GetString() ?? "pcs" : "pcs",
|
||||
SellingPrice = sellingPrice
|
||||
};
|
||||
|
||||
var sequence = await _inventoryRepository.GetNextProductSequence(clientId);
|
||||
product.Sku = $"PRD-{sequence:D4}";
|
||||
|
||||
var result = await _inventoryRepository.CreateProduct(product);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return await MapToDtoAsync(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateProduct(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var productId = Guid.Parse(data["productId"].GetString()!);
|
||||
|
||||
var existing = await _inventoryRepository.GetProductById(productId)
|
||||
?? throw new KeyNotFoundException("Product not found");
|
||||
|
||||
// Sku is system-generated at creation and never editable here.
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("unitOfMeasure")) existing.UnitOfMeasure = data["unitOfMeasure"].GetString() ?? existing.UnitOfMeasure;
|
||||
if (data.ContainsKey("sellingPrice"))
|
||||
{
|
||||
var price = data["sellingPrice"].GetDecimal();
|
||||
if (price < 0) throw new ArgumentException("sellingPrice cannot be negative");
|
||||
existing.SellingPrice = price;
|
||||
}
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
var result = await _inventoryRepository.UpdateProduct(existing);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return await MapToDtoAsync(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UploadProductImage(Guid productId, IFormFile image, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var product = await _inventoryRepository.GetProductById(productId);
|
||||
if (product == null || product.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Product not found");
|
||||
|
||||
if (image == null || image.Length == 0)
|
||||
throw new BadRequestException("No image file provided.");
|
||||
|
||||
string extension = Path.GetExtension(image.FileName).ToLowerInvariant();
|
||||
string[] allowed = { ".jpg", ".jpeg", ".png", ".webp" };
|
||||
if (!allowed.Contains(extension))
|
||||
throw new BadRequestException("Image must be a jpg, png or webp file.");
|
||||
if (image.Length > 5 * 1024 * 1024)
|
||||
throw new BadRequestException("Image too large (max 5MB).");
|
||||
|
||||
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", "product-images", clientId.ToString()));
|
||||
if (!Directory.Exists(uploadsRoot))
|
||||
Directory.CreateDirectory(uploadsRoot);
|
||||
|
||||
string filename = $"{productId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}";
|
||||
string filePath = Path.Combine(uploadsRoot, filename);
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await image.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
product.ImageId = $"/Uploads/product-images/{clientId}/{Uri.EscapeDataString(filename)}";
|
||||
await _uow.CommitAsync();
|
||||
return await MapToDtoAsync(product);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suppliers ---
|
||||
|
||||
public async Task<object> GetSuppliers(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var suppliers = await _inventoryRepository.GetSuppliers(clientId, includeInactive);
|
||||
|
||||
return suppliers.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreateSupplier(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var supplier = new Supplier
|
||||
{
|
||||
FKClientId = clientId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
Phone = data.ContainsKey("phone") && data["phone"].ValueKind != JsonValueKind.Null ? data["phone"].GetString() : null,
|
||||
Email = data.ContainsKey("email") && data["email"].ValueKind != JsonValueKind.Null ? data["email"].GetString() : null,
|
||||
Address = data.ContainsKey("address") && data["address"].ValueKind != JsonValueKind.Null ? data["address"].GetString() : null
|
||||
};
|
||||
|
||||
var result = await _inventoryRepository.CreateSupplier(supplier);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// --- GRN ---
|
||||
|
||||
public async Task<object> CreateGrn(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
// Optional -- some GRNs don't have a supplier on file.
|
||||
Supplier? supplier = null;
|
||||
Guid? supplierId = data.ContainsKey("supplierId") && data["supplierId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["supplierId"].GetString()!)
|
||||
: null;
|
||||
if (supplierId.HasValue)
|
||||
{
|
||||
supplier = await _inventoryRepository.GetSupplierById(supplierId.Value);
|
||||
if (supplier == null || supplier.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Supplier not found");
|
||||
}
|
||||
|
||||
if (!data.ContainsKey("items") || data["items"].ValueKind != JsonValueKind.Array)
|
||||
throw new ArgumentException("At least one item is required");
|
||||
|
||||
var receivedAt = data.ContainsKey("receivedAt") && data["receivedAt"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["receivedAt"].GetString()!).ToUniversalTime()
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var grn = new GoodsReceivedNote
|
||||
{
|
||||
FKClientId = clientId,
|
||||
FKSupplierId = supplierId,
|
||||
ReceivedAt = receivedAt,
|
||||
Notes = data.ContainsKey("notes") && data["notes"].ValueKind != JsonValueKind.Null ? data["notes"].GetString() : null
|
||||
};
|
||||
|
||||
decimal total = 0m;
|
||||
var itemsArray = data["items"].EnumerateArray().ToList();
|
||||
if (itemsArray.Count == 0) throw new ArgumentException("At least one item is required");
|
||||
|
||||
foreach (var itemEl in itemsArray)
|
||||
{
|
||||
var productId = Guid.Parse(itemEl.GetProperty("productId").GetString()!);
|
||||
var quantity = itemEl.GetProperty("quantity").GetDecimal();
|
||||
var unitCost = itemEl.GetProperty("unitCost").GetDecimal();
|
||||
|
||||
if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0");
|
||||
if (unitCost < 0) throw new ArgumentException("UnitCost cannot be negative");
|
||||
|
||||
var product = await _inventoryRepository.GetProductById(productId);
|
||||
if (product == null || product.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Product not found");
|
||||
|
||||
var lineTotal = quantity * unitCost;
|
||||
grn.Items.Add(new GoodsReceivedNoteItem
|
||||
{
|
||||
FKProductId = productId,
|
||||
Quantity = quantity,
|
||||
UnitCost = unitCost,
|
||||
LineTotal = lineTotal
|
||||
});
|
||||
|
||||
product.QuantityOnHand += quantity;
|
||||
product.CostPrice = unitCost;
|
||||
total += lineTotal;
|
||||
}
|
||||
|
||||
grn.TotalAmount = total;
|
||||
var sequence = await _inventoryRepository.GetNextGrnSequence(clientId);
|
||||
grn.GrnNumber = $"GRN-{sequence:D4}";
|
||||
|
||||
var result = await _inventoryRepository.CreateGrn(grn);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
result.Supplier = supplier;
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetGrns(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var grns = await _inventoryRepository.GetGrns(clientId);
|
||||
return grns.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetGrnById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var grnId = Guid.Parse(data["grnId"].GetString()!);
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var grn = await _inventoryRepository.GetGrnById(grnId);
|
||||
if (grn == null || grn.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("GRN not found");
|
||||
|
||||
return MapToDto(grn);
|
||||
}
|
||||
|
||||
// --- Sales ---
|
||||
|
||||
public async Task<object> CreateSale(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null
|
||||
? data["paymentMethod"].GetString() ?? "Cash" : "Cash";
|
||||
|
||||
var customerId = await ResolveOptionalCustomerLinkAsync(data);
|
||||
|
||||
if (!data.ContainsKey("items") || data["items"].ValueKind != JsonValueKind.Array)
|
||||
throw new ArgumentException("At least one item is required");
|
||||
|
||||
var soldAt = data.ContainsKey("soldAt") && data["soldAt"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["soldAt"].GetString()!).ToUniversalTime()
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var sale = new Sale
|
||||
{
|
||||
FKClientId = clientId,
|
||||
SoldAt = soldAt,
|
||||
PaymentMethod = paymentMethod,
|
||||
FKCustomerId = customerId,
|
||||
CustomerName = data.ContainsKey("customerName") && data["customerName"].ValueKind != JsonValueKind.Null
|
||||
? data["customerName"].GetString() : null
|
||||
};
|
||||
|
||||
decimal total = 0m;
|
||||
var itemsArray = data["items"].EnumerateArray().ToList();
|
||||
if (itemsArray.Count == 0) throw new ArgumentException("At least one item is required");
|
||||
|
||||
// Load + validate every line before mutating stock, so a short line rejects the
|
||||
// whole sale rather than partially decrementing earlier lines.
|
||||
var lines = new List<(Product Product, decimal Quantity, decimal UnitPrice)>();
|
||||
foreach (var itemEl in itemsArray)
|
||||
{
|
||||
var productId = Guid.Parse(itemEl.GetProperty("productId").GetString()!);
|
||||
var quantity = itemEl.GetProperty("quantity").GetDecimal();
|
||||
var unitPrice = itemEl.GetProperty("unitPrice").GetDecimal();
|
||||
|
||||
if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0");
|
||||
if (unitPrice < 0) throw new ArgumentException("UnitPrice cannot be negative");
|
||||
|
||||
var product = await _inventoryRepository.GetProductById(productId);
|
||||
if (product == null || product.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Product not found");
|
||||
if (product.QuantityOnHand < quantity)
|
||||
throw new ArgumentException($"Not enough stock for \"{product.Name}\" (have {product.QuantityOnHand}, need {quantity})");
|
||||
|
||||
lines.Add((product, quantity, unitPrice));
|
||||
}
|
||||
|
||||
foreach (var (product, quantity, unitPrice) in lines)
|
||||
{
|
||||
var lineTotal = quantity * unitPrice;
|
||||
sale.Items.Add(new SaleItem
|
||||
{
|
||||
FKProductId = product.ProductId,
|
||||
Quantity = quantity,
|
||||
UnitPrice = unitPrice,
|
||||
LineTotal = lineTotal
|
||||
});
|
||||
|
||||
product.QuantityOnHand -= quantity;
|
||||
total += lineTotal;
|
||||
}
|
||||
|
||||
sale.TotalAmount = total;
|
||||
var sequence = await _inventoryRepository.GetNextSaleSequence(clientId);
|
||||
sale.SaleNumber = $"SALE-{sequence:D4}";
|
||||
|
||||
var result = await _inventoryRepository.CreateSale(sale);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetSales(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var sales = await _inventoryRepository.GetSales(clientId);
|
||||
return sales.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetSaleById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var saleId = Guid.Parse(data["saleId"].GetString()!);
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var sale = await _inventoryRepository.GetSaleById(saleId);
|
||||
if (sale == null || sale.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Sale not found");
|
||||
|
||||
return MapToDto(sale);
|
||||
}
|
||||
|
||||
// --- Returns ---
|
||||
|
||||
public async Task<object> CreateStockReturn(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var productId = Guid.Parse(data["productId"].GetString()!);
|
||||
var quantity = data["quantity"].GetDecimal();
|
||||
if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0");
|
||||
|
||||
var category = data["category"].GetString() ?? "Other";
|
||||
if (!ValidReturnCategories.Contains(category))
|
||||
throw new ArgumentException($"category must be one of: {string.Join(", ", ValidReturnCategories)}");
|
||||
|
||||
var product = await _inventoryRepository.GetProductById(productId);
|
||||
if (product == null || product.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Product not found");
|
||||
|
||||
// CustomerReturn is the one category that adds back to stock -- the item
|
||||
// physically comes back after an earlier sale already removed it, so there's no
|
||||
// "enough stock on hand" requirement (it may well be at 0). Every other category
|
||||
// removes from stock we currently hold, so it does require enough on hand.
|
||||
var addsBackToStock = category == "CustomerReturn";
|
||||
if (!addsBackToStock && product.QuantityOnHand < quantity)
|
||||
throw new ArgumentException($"Not enough stock for \"{product.Name}\" (have {product.QuantityOnHand}, need {quantity})");
|
||||
|
||||
// Optional traceability -- most returns have neither. Only validated for
|
||||
// existence/ownership, not cross-checked against the product/quantity.
|
||||
Guid? saleId = data.ContainsKey("saleId") && data["saleId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["saleId"].GetString()!) : null;
|
||||
Sale? sale = null;
|
||||
if (saleId.HasValue)
|
||||
{
|
||||
sale = await _inventoryRepository.GetSaleById(saleId.Value);
|
||||
if (sale == null || sale.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Sale not found");
|
||||
}
|
||||
|
||||
Guid? grnId = data.ContainsKey("grnId") && data["grnId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["grnId"].GetString()!) : null;
|
||||
GoodsReceivedNote? grn = null;
|
||||
if (grnId.HasValue)
|
||||
{
|
||||
grn = await _inventoryRepository.GetGrnById(grnId.Value);
|
||||
if (grn == null || grn.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("GRN not found");
|
||||
}
|
||||
|
||||
var returnedAt = data.ContainsKey("returnedAt") && data["returnedAt"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["returnedAt"].GetString()!).ToUniversalTime()
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var stockReturn = new StockReturn
|
||||
{
|
||||
FKClientId = clientId,
|
||||
FKProductId = productId,
|
||||
Quantity = quantity,
|
||||
Category = category,
|
||||
FKSaleId = saleId,
|
||||
FKGrnId = grnId,
|
||||
Notes = data.ContainsKey("notes") && data["notes"].ValueKind != JsonValueKind.Null ? data["notes"].GetString() : null,
|
||||
ReturnedAt = returnedAt,
|
||||
Amount = quantity * product.CostPrice
|
||||
};
|
||||
|
||||
product.QuantityOnHand += addsBackToStock ? quantity : -quantity;
|
||||
|
||||
var result = await _inventoryRepository.CreateStockReturn(stockReturn);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
result.Product = product;
|
||||
result.Sale = sale;
|
||||
result.Grn = grn;
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetStockReturns(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var returns = await _inventoryRepository.GetStockReturns(clientId);
|
||||
return returns.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetStockReturnById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var stockReturnId = Guid.Parse(data["stockReturnId"].GetString()!);
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var stockReturn = await _inventoryRepository.GetStockReturnById(stockReturnId);
|
||||
if (stockReturn == null || stockReturn.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Return not found");
|
||||
|
||||
return MapToDto(stockReturn);
|
||||
}
|
||||
|
||||
// --- Stock Tracking (unified ledger, new) ---
|
||||
|
||||
/// <summary>Every GRN line, sale line and return across every product, flattened into one
|
||||
/// shape for the Stock Tracking tab's table. Built from the same three already-tenant-
|
||||
/// scoped reads the GRN/Sales/Returns tabs use -- no new repository queries needed, and
|
||||
/// the dataset is small enough (a single gym's "simple inventory") that filtering happens
|
||||
/// client-side rather than via query params.</summary>
|
||||
public async Task<object> GetStockMovements(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var grns = await _inventoryRepository.GetGrns(clientId);
|
||||
var sales = await _inventoryRepository.GetSales(clientId);
|
||||
var returns = await _inventoryRepository.GetStockReturns(clientId);
|
||||
|
||||
var movements = new List<StockMovementDto>();
|
||||
|
||||
foreach (var g in grns)
|
||||
foreach (var i in g.Items)
|
||||
movements.Add(new StockMovementDto
|
||||
{
|
||||
Type = "GRN",
|
||||
ProductId = i.FKProductId,
|
||||
ProductName = i.Product?.Name ?? string.Empty,
|
||||
Reference = g.GrnNumber,
|
||||
QuantityChange = i.Quantity,
|
||||
Amount = i.LineTotal,
|
||||
Date = g.ReceivedAt
|
||||
});
|
||||
|
||||
foreach (var s in sales)
|
||||
foreach (var i in s.Items)
|
||||
movements.Add(new StockMovementDto
|
||||
{
|
||||
Type = "Sale",
|
||||
ProductId = i.FKProductId,
|
||||
ProductName = i.Product?.Name ?? string.Empty,
|
||||
Reference = s.SaleNumber,
|
||||
QuantityChange = -i.Quantity,
|
||||
Amount = i.LineTotal,
|
||||
Date = s.SoldAt
|
||||
});
|
||||
|
||||
foreach (var r in returns)
|
||||
movements.Add(new StockMovementDto
|
||||
{
|
||||
Type = "Return",
|
||||
ProductId = r.FKProductId,
|
||||
ProductName = r.Product?.Name ?? string.Empty,
|
||||
Reference = r.Sale?.SaleNumber ?? r.Grn?.GrnNumber ?? r.Category,
|
||||
Category = r.Category,
|
||||
// CustomerReturn is the one category that adds back to stock -- see
|
||||
// StockReturn's doc comment.
|
||||
QuantityChange = r.Category == "CustomerReturn" ? r.Quantity : -r.Quantity,
|
||||
Amount = r.Amount,
|
||||
Date = r.ReturnedAt
|
||||
});
|
||||
|
||||
return movements.OrderByDescending(m => m.Date).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Staff optionally picking an existing registered member to attach to a sale
|
||||
/// (search-and-pick, not free-typed) -- purely for identification, mirrors
|
||||
/// BookingService.ResolveOptionalCustomerLinkAsync exactly.</summary>
|
||||
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> data)
|
||||
{
|
||||
if (!data.ContainsKey("customerId") || data["customerId"].ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
_ = await _customerRepository.GetTenantCustomerLinkById(customerId)
|
||||
?? throw new KeyNotFoundException("Customer not found");
|
||||
return customerId;
|
||||
}
|
||||
|
||||
// ImageId is a relative /Uploads path (same convention as ClientBusiness.BusinessLogoId)
|
||||
// with no static-file serving configured for it, so it's inlined as a base64 data URL.
|
||||
private static async Task<ProductDto> MapToDtoAsync(Product p) => new()
|
||||
{
|
||||
ProductId = p.ProductId,
|
||||
Name = p.Name,
|
||||
Sku = p.Sku,
|
||||
ImageUrl = await BusinessLogoHelper.ReadAsDataUrlAsync(p.ImageId),
|
||||
UnitOfMeasure = p.UnitOfMeasure,
|
||||
CostPrice = p.CostPrice,
|
||||
SellingPrice = p.SellingPrice,
|
||||
QuantityOnHand = p.QuantityOnHand,
|
||||
IsActive = p.IsActive,
|
||||
CreatedAt = p.CreatedAt
|
||||
};
|
||||
|
||||
private static SupplierDto MapToDto(Supplier s) => new()
|
||||
{
|
||||
SupplierId = s.SupplierId,
|
||||
Name = s.Name,
|
||||
Phone = s.Phone,
|
||||
Email = s.Email,
|
||||
Address = s.Address,
|
||||
IsActive = s.IsActive
|
||||
};
|
||||
|
||||
private static GoodsReceivedNoteDto MapToDto(GoodsReceivedNote g) => new()
|
||||
{
|
||||
GrnId = g.GrnId,
|
||||
GrnNumber = g.GrnNumber,
|
||||
SupplierId = g.FKSupplierId,
|
||||
SupplierName = g.Supplier?.Name,
|
||||
ReceivedAt = g.ReceivedAt,
|
||||
Notes = g.Notes,
|
||||
TotalAmount = g.TotalAmount,
|
||||
Items = g.Items.Select(i => new GoodsReceivedNoteItemDto
|
||||
{
|
||||
ProductId = i.FKProductId,
|
||||
ProductName = i.Product?.Name ?? string.Empty,
|
||||
Quantity = i.Quantity,
|
||||
UnitCost = i.UnitCost,
|
||||
LineTotal = i.LineTotal
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static SaleDto MapToDto(Sale s) => new()
|
||||
{
|
||||
SaleId = s.SaleId,
|
||||
SaleNumber = s.SaleNumber,
|
||||
SoldAt = s.SoldAt,
|
||||
PaymentMethod = s.PaymentMethod,
|
||||
CustomerId = s.FKCustomerId,
|
||||
CustomerName = s.CustomerName,
|
||||
TotalAmount = s.TotalAmount,
|
||||
Items = s.Items.Select(i => new SaleItemDto
|
||||
{
|
||||
ProductId = i.FKProductId,
|
||||
ProductName = i.Product?.Name ?? string.Empty,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
LineTotal = i.LineTotal
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static StockReturnDto MapToDto(StockReturn r) => new()
|
||||
{
|
||||
StockReturnId = r.StockReturnId,
|
||||
ProductId = r.FKProductId,
|
||||
ProductName = r.Product?.Name ?? string.Empty,
|
||||
Quantity = r.Quantity,
|
||||
Category = r.Category,
|
||||
SaleId = r.FKSaleId,
|
||||
SaleNumber = r.Sale?.SaleNumber,
|
||||
GrnId = r.FKGrnId,
|
||||
GrnNumber = r.Grn?.GrnNumber,
|
||||
Notes = r.Notes,
|
||||
ReturnedAt = r.ReturnedAt,
|
||||
Amount = r.Amount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
using Fitness_core_BE.DTOs.Place;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PlaceService : IPlaceService
|
||||
{
|
||||
private readonly IPlaceRepo _placeRepo;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public PlaceService(IPlaceRepo placeRepo, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_placeRepo = placeRepo;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetAllPlaces(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var places = await _placeRepo.GetAllPlaces(clientId, includeInactive);
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(places.Select(p => p.PlaceId).ToList());
|
||||
|
||||
return places.Select(p => MapToDto(p, devicesByPlace.GetValueOrDefault(p.PlaceId))).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreatePlace(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var place = new Place
|
||||
{
|
||||
FKClientId = clientId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null
|
||||
};
|
||||
|
||||
var result = await _placeRepo.CreatePlace(place);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdatePlace(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required");
|
||||
|
||||
var placeId = Guid.Parse(data["placeId"].GetString()!);
|
||||
var existing = await _placeRepo.GetPlaceById(placeId)
|
||||
?? throw new KeyNotFoundException("Place not found");
|
||||
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
var result = await _placeRepo.UpdatePlace(existing);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { result.PlaceId });
|
||||
return MapToDto(result, devicesByPlace.GetValueOrDefault(result.PlaceId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetPlaceStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var placeId = Guid.Parse(data["placeId"].GetString()!);
|
||||
var isActive = data["isActive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var place = await _placeRepo.SetPlaceActive(placeId, clientId, isActive)
|
||||
?? throw new KeyNotFoundException("Place not found");
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { place.PlaceId });
|
||||
return MapToDto(place, devicesByPlace.GetValueOrDefault(place.PlaceId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> AssignDevice(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required");
|
||||
|
||||
var placeId = Guid.Parse(data["placeId"].GetString()!);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var place = await _placeRepo.GetPlaceById(placeId);
|
||||
if (place == null || place.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Place not found");
|
||||
|
||||
var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null;
|
||||
if (!hasDeviceId)
|
||||
{
|
||||
await _placeRepo.UnassignDeviceAsync(placeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var deviceId = Guid.Parse(data["deviceId"].GetString()!);
|
||||
var device = await _placeRepo.GetDeviceByIdForClientAsync(deviceId, clientId)
|
||||
?? throw new KeyNotFoundException("Device not found");
|
||||
|
||||
if (await _placeRepo.IsDeviceAssignedToAnotherPlaceAsync(deviceId, placeId))
|
||||
throw new ArgumentException("This device is already assigned to another place.");
|
||||
|
||||
await _placeRepo.AssignDeviceAsync(placeId, deviceId);
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { placeId });
|
||||
return MapToDto(place, devicesByPlace.GetValueOrDefault(placeId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlaceDto MapToDto(Place place, AccessDevice? device) => new()
|
||||
{
|
||||
PlaceId = place.PlaceId,
|
||||
Name = place.Name,
|
||||
Description = place.Description,
|
||||
IsActive = place.IsActive,
|
||||
CreatedAt = place.CreatedAt,
|
||||
DeviceId = device?.DeviceId,
|
||||
DeviceName = device?.DeviceName
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Fitness_core_BE.DTOs.RfidTag;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class RfidTagService(IRfidTagRepository rfidTagRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) : IRfidTagService
|
||||
{
|
||||
private static readonly string[] StaffSettableStatuses = ["Available", "Lost", "Retired"];
|
||||
|
||||
private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository;
|
||||
private readonly IUnitOfWork _uow = uow;
|
||||
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||
|
||||
public async Task<object> GetAllTags(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
string? status = data.ContainsKey("status") ? data["status"].GetString() : null;
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var tags = await _rfidTagRepository.GetAllTags(clientId, status);
|
||||
|
||||
return tags.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreateTag(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("tagCode")) throw new KeyNotFoundException("tagCode is required");
|
||||
|
||||
var tagCode = data["tagCode"].GetString() ?? throw new ArgumentException("tagCode is required");
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
if (await _rfidTagRepository.TagCodeExistsAsync(clientId, tagCode))
|
||||
throw new ArgumentException("A tag with this code already exists.");
|
||||
|
||||
var tag = new RfidTag
|
||||
{
|
||||
FKClientId = clientId,
|
||||
TagCode = tagCode,
|
||||
Label = data.ContainsKey("label") ? data["label"].GetString() : null
|
||||
};
|
||||
|
||||
var result = await _rfidTagRepository.CreateTag(tag);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateTag(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("tagId")) throw new KeyNotFoundException("tagId is required");
|
||||
|
||||
var tagId = Guid.Parse(data["tagId"].GetString()!);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var existing = await _rfidTagRepository.GetTagById(tagId, clientId)
|
||||
?? throw new KeyNotFoundException("RFID tag not found");
|
||||
|
||||
if (data.ContainsKey("tagCode"))
|
||||
{
|
||||
var tagCode = data["tagCode"].GetString() ?? existing.TagCode;
|
||||
if (await _rfidTagRepository.TagCodeExistsAsync(clientId, tagCode, tagId))
|
||||
throw new ArgumentException("A tag with this code already exists.");
|
||||
existing.TagCode = tagCode;
|
||||
}
|
||||
if (data.ContainsKey("label")) existing.Label = data["label"].GetString();
|
||||
|
||||
var result = await _rfidTagRepository.UpdateTag(existing);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetTagStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var tagId = Guid.Parse(data["tagId"].GetString()!);
|
||||
var status = data["status"].GetString() ?? throw new ArgumentException("status is required");
|
||||
|
||||
if (!StaffSettableStatuses.Contains(status))
|
||||
throw new ArgumentException($"status must be one of: {string.Join(", ", StaffSettableStatuses)}");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var tag = await _rfidTagRepository.SetStatusAsync(tagId, clientId, status)
|
||||
?? throw new KeyNotFoundException("RFID tag not found");
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(tag);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static RfidTagDto MapToDto(RfidTag tag) => new()
|
||||
{
|
||||
RfidTagId = tag.RfidTagId,
|
||||
TagCode = tag.TagCode,
|
||||
Label = tag.Label,
|
||||
Status = tag.Status,
|
||||
CreatedAt = tag.CreatedAt,
|
||||
UpdatedAt = tag.UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace Fitness_core_BE.Services
|
||||
// SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the
|
||||
// wake-up point shifts.
|
||||
private static readonly TimeSpan RunAtLocal = TimeSpan.FromHours(1);
|
||||
private static readonly TimeZoneInfo SriLankaTimeZone = ResolveSriLankaTimeZone();
|
||||
private static readonly TimeZoneInfo SriLankaTimeZone = Helpers.TimeZoneHelper.SriLanka;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SubscriptionRenewalJob> _logger;
|
||||
@@ -68,29 +68,6 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeZoneInfo ResolveSriLankaTimeZone()
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
|
||||
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
|
||||
// zone is a safe fallback rather than failing the whole job.
|
||||
return TimeZoneInfo.CreateCustomTimeZone(
|
||||
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra
|
||||
// run just because the app happened to restart. Internal + static so it's unit-testable
|
||||
// without spinning up the whole hosted service.
|
||||
|
||||
+127
-18
@@ -64,16 +64,38 @@ namespace Fitness_core_BE
|
||||
public DbSet<CustomerWorkoutExerciseCompletion> CustomerWorkoutExerciseCompletions => Set<CustomerWorkoutExerciseCompletion>();
|
||||
|
||||
// Multi-facility session booking + RFID time-bank cards (pool/court/class booking, one
|
||||
// per club since single-tenant -- see Models/Facility.cs and friends).
|
||||
// per club since single-tenant), and access-zone/device-scoping -- Facility merges what
|
||||
// used to be two separate concepts (bookable capacity + access zone), see Models/Facility.cs.
|
||||
public DbSet<Facility> Facilities => Set<Facility>();
|
||||
public DbSet<FacilitySession> FacilitySessions => Set<FacilitySession>();
|
||||
public DbSet<Booking> Bookings => Set<Booking>();
|
||||
public DbSet<TimeBankCard> TimeBankCards => Set<TimeBankCard>();
|
||||
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
|
||||
public DbSet<CustomerPlanFacility> CustomerPlanFacilities => Set<CustomerPlanFacility>();
|
||||
|
||||
// Physical access zones (Pool Area, Indoor Gym, ...) -- see Models/Place.cs.
|
||||
public DbSet<Place> Places => Set<Place>();
|
||||
public DbSet<CustomerPlanPlace> CustomerPlanPlaces => Set<CustomerPlanPlace>();
|
||||
// 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.
|
||||
public DbSet<RfidTag> RfidTags => Set<RfidTag>();
|
||||
|
||||
// Snapshot receipt created whenever a Booking's payment is recorded -- see
|
||||
// Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only).
|
||||
public DbSet<BookingInvoice> BookingInvoices => Set<BookingInvoice>();
|
||||
|
||||
// Simple inventory: Products, Suppliers, GRN (stock in), Sales (stock out), Returns
|
||||
// (stock write-offs) -- see each model's own doc comment.
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<Supplier> Suppliers => Set<Supplier>();
|
||||
public DbSet<GoodsReceivedNote> GoodsReceivedNotes => Set<GoodsReceivedNote>();
|
||||
public DbSet<GoodsReceivedNoteItem> GoodsReceivedNoteItems => Set<GoodsReceivedNoteItem>();
|
||||
public DbSet<Sale> Sales => Set<Sale>();
|
||||
public DbSet<SaleItem> SaleItems => Set<SaleItem>();
|
||||
public DbSet<StockReturn> StockReturns => Set<StockReturn>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -180,15 +202,24 @@ 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);
|
||||
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
||||
modelBuilder.Entity<Place>().Ignore(p => p.Client);
|
||||
modelBuilder.Entity<Product>().Ignore(p => p.Client);
|
||||
modelBuilder.Entity<Supplier>().Ignore(s => s.Client);
|
||||
modelBuilder.Entity<GoodsReceivedNote>().Ignore(g => g.Client);
|
||||
modelBuilder.Entity<Sale>().Ignore(s => s.Client);
|
||||
modelBuilder.Entity<StockReturn>().Ignore(r => r.Client);
|
||||
|
||||
// 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.
|
||||
// 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 WalkIn bookings -- their own overlap-vs-NumberOfUnits check
|
||||
// lives in FacilityRepository.GetOverlappingWalkInBookingCountAsync instead.
|
||||
modelBuilder.Entity<Booking>()
|
||||
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
|
||||
.IsUnique()
|
||||
@@ -197,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();
|
||||
@@ -241,22 +283,89 @@ namespace Fitness_core_BE
|
||||
// same-context FK anymore (same pattern as every other central-pointing scalar).
|
||||
modelBuilder.Entity<AccessDevice>().Ignore(d => d.Agent);
|
||||
|
||||
// One device per place for now -- see AccessDevice.FKPlaceId comment. Lifting
|
||||
// this to many-devices-per-place later is just dropping this index.
|
||||
// One device per facility for now -- see AccessDevice.FKFacilityId comment. Lifting
|
||||
// this to many-devices-per-facility later is just dropping this index.
|
||||
modelBuilder.Entity<AccessDevice>()
|
||||
.HasIndex(d => d.FKPlaceId)
|
||||
.HasIndex(d => d.FKFacilityId)
|
||||
.IsUnique()
|
||||
.HasFilter("`FKPlaceId` IS NOT NULL");
|
||||
.HasFilter("`FKFacilityId` IS NOT NULL");
|
||||
|
||||
modelBuilder.Entity<CustomerPlanPlace>()
|
||||
.HasIndex(pp => new { pp.FKCustomerPlanId, pp.FKPlaceId })
|
||||
modelBuilder.Entity<CustomerPlanFacility>()
|
||||
.HasIndex(pf => new { pf.FKCustomerPlanId, pf.FKFacilityId })
|
||||
.IsUnique();
|
||||
modelBuilder.Entity<CustomerPlanPlace>()
|
||||
.HasOne(pp => pp.CustomerPlan).WithMany()
|
||||
.HasForeignKey(pp => pp.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<CustomerPlanPlace>()
|
||||
.HasOne(pp => pp.Place).WithMany()
|
||||
.HasForeignKey(pp => pp.FKPlaceId).OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<CustomerPlanFacility>()
|
||||
.HasOne(pf => pf.CustomerPlan).WithMany()
|
||||
.HasForeignKey(pf => pf.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<CustomerPlanFacility>()
|
||||
.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();
|
||||
|
||||
// WalkIn bookings share a facility's NumberOfUnits, checked by counting overlapping
|
||||
// non-cancelled/non-completed bookings at creation/reschedule time (see
|
||||
// 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.
|
||||
modelBuilder.Entity<Booking>()
|
||||
.HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime });
|
||||
|
||||
modelBuilder.Entity<BookingInvoice>()
|
||||
.HasIndex(i => new { i.FKClientId, i.IssuedAt });
|
||||
|
||||
// Inventory: GRN/Sale items cascade with their header; Return/GRN/Sale restrict
|
||||
// deleting a Product/Supplier that already has history (there's no delete endpoint
|
||||
// for either, but Restrict keeps the data model honest either way).
|
||||
modelBuilder.Entity<Product>()
|
||||
.HasIndex(p => new { p.FKClientId, p.Name });
|
||||
modelBuilder.Entity<Supplier>()
|
||||
.HasIndex(s => new { s.FKClientId, s.Name });
|
||||
modelBuilder.Entity<GoodsReceivedNote>()
|
||||
.HasIndex(g => new { g.FKClientId, g.ReceivedAt });
|
||||
modelBuilder.Entity<GoodsReceivedNote>()
|
||||
.HasOne(g => g.Supplier).WithMany()
|
||||
.HasForeignKey(g => g.FKSupplierId).OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<GoodsReceivedNoteItem>()
|
||||
.HasOne(i => i.Grn).WithMany(g => g.Items)
|
||||
.HasForeignKey(i => i.FKGrnId).OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<GoodsReceivedNoteItem>()
|
||||
.HasOne(i => i.Product).WithMany()
|
||||
.HasForeignKey(i => i.FKProductId).OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<Sale>()
|
||||
.HasIndex(s => new { s.FKClientId, s.SoldAt });
|
||||
modelBuilder.Entity<SaleItem>()
|
||||
.HasOne(i => i.Sale).WithMany(s => s.Items)
|
||||
.HasForeignKey(i => i.FKSaleId).OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<SaleItem>()
|
||||
.HasOne(i => i.Product).WithMany()
|
||||
.HasForeignKey(i => i.FKProductId).OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<StockReturn>()
|
||||
.HasIndex(r => new { r.FKClientId, r.ReturnedAt });
|
||||
modelBuilder.Entity<StockReturn>()
|
||||
.HasOne(r => r.Product).WithMany()
|
||||
.HasForeignKey(r => r.FKProductId).OnDelete(DeleteBehavior.Restrict);
|
||||
// Optional traceability links -- most returns have neither.
|
||||
modelBuilder.Entity<StockReturn>()
|
||||
.HasOne(r => r.Sale).WithMany()
|
||||
.HasForeignKey(r => r.FKSaleId).OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<StockReturn>()
|
||||
.HasOne(r => r.Grn).WithMany()
|
||||
.HasForeignKey(r => r.FKGrnId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
||||
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
||||
|
||||
Reference in New Issue
Block a user