feat: Enhance booking functionality with invoice management and dashboard summary
- Added customerId to guest booking requests for better identification. - Implemented new endpoints for managing booking invoices and dashboard summaries. - Created BookingInvoice and BookingDashboardSummary DTOs to support new features. - Updated Booking and BookingInvoice models to include payment method and timestamps. - Enhanced BookingService to handle invoice creation upon payment confirmation. - Modified BookingRepository to support CRUD operations for BookingInvoice. - Updated TenantDbContext to include BookingInvoice DbSet and relevant configurations. - Improved error handling and validation for booking completion and payment processes.
This commit is contained in:
@@ -65,7 +65,8 @@ namespace Fitness_core_BE.Controllers
|
||||
{
|
||||
facilitySessionId = request.FacilitySessionId,
|
||||
guestName = request.GuestName,
|
||||
guestPhone = request.GuestPhone
|
||||
guestPhone = request.GuestPhone,
|
||||
customerId = request.CustomerId
|
||||
}, HttpContext));
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -79,7 +80,8 @@ namespace Fitness_core_BE.Controllers
|
||||
guestName = request.GuestName,
|
||||
guestPhone = request.GuestPhone,
|
||||
partySize = request.PartySize,
|
||||
pricePerHour = request.PricePerHour
|
||||
pricePerHour = request.PricePerHour,
|
||||
customerId = request.CustomerId
|
||||
}, HttpContext));
|
||||
|
||||
// The actual "copy the link and share it" page -- BookingDto.ShareUrl points here.
|
||||
@@ -135,7 +137,12 @@ namespace Fitness_core_BE.Controllers
|
||||
[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]
|
||||
@@ -180,7 +187,44 @@ namespace Fitness_core_BE.Controllers
|
||||
[TenantResolution]
|
||||
[RequiresPermission("bookings")]
|
||||
[HttpPost("api/client/bookings/{id:guid}/complete")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> CompleteBooking(Guid id)
|
||||
=> Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext));
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
|
||||
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; }
|
||||
|
||||
@@ -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,11 @@ 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 CreateAdhocBookingRequest
|
||||
@@ -57,6 +62,10 @@ namespace Fitness_core_BE.DTOs.Booking
|
||||
/// 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
|
||||
@@ -67,6 +76,20 @@ 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
|
||||
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,31 @@ namespace Fitness_core_BE.Interfaces
|
||||
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 instead of a background job -- see Booking.Status doc comment. ---
|
||||
// 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>Every CheckedIn booking for this client whose EndTime has passed and hasn't
|
||||
/// been checked out yet, with its RfidTag eagerly loaded so the caller can complete +
|
||||
/// release the tag on the same tracked graph in one SaveChanges.</summary>
|
||||
Task<List<Booking>> GetOverdueCheckedInBookingsAsync(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
|
||||
|
||||
@@ -43,8 +43,10 @@ namespace Fitness_core_BE.Interfaces.Services
|
||||
/// <summary>Staff manually flags a Confirmed, never-arrived booking.</summary>
|
||||
Task<object> MarkNoShow(object payload, HttpContext httpContext);
|
||||
|
||||
/// <summary>Staff manually ends a CheckedIn booking early (before the lazy sweep would
|
||||
/// catch it), releasing its RFID tag if any.</summary>
|
||||
/// <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);
|
||||
@@ -52,5 +54,16 @@ namespace Fitness_core_BE.Interfaces.Services
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -86,24 +86,34 @@ 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" | "Completed". NoShow and
|
||||
/// Completed are reached either by explicit staff action (MarkNoShow/Complete) or lazily,
|
||||
/// swept by BookingService.SweepOverdueBookingsAsync at the top of every check-in call
|
||||
/// (not a background job -- overdue bookings are reconciled the next time any check-in
|
||||
/// happens for this client, across every facility).</summary>
|
||||
/// <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, whether via the manual Complete
|
||||
/// action or the lazy overdue-sweep -- the actual moment it was reconciled, not
|
||||
/// necessarily EndTime.</summary>
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,6 @@ namespace Fitness_core_BE.Repos
|
||||
.Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Status, "NoShow"));
|
||||
|
||||
public Task<List<Booking>> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now)
|
||||
=> _context.Bookings
|
||||
.Include(b => b.RfidTag)
|
||||
.Where(b => b.FKClientId == clientId && b.Status == "CheckedIn" && b.EndTime < now)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now)
|
||||
{
|
||||
var rows = await _context.RfidTags
|
||||
@@ -96,5 +90,52 @@ namespace Fitness_core_BE.Repos
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/CreateGuestAdhocBooking) -- 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)
|
||||
{
|
||||
|
||||
@@ -82,8 +82,32 @@ 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);
|
||||
|
||||
memberPayments.AddRange(platformInvoices);
|
||||
memberPayments.AddRange(staffPayments);
|
||||
memberPayments.AddRange(bookingPayments);
|
||||
return memberPayments;
|
||||
}
|
||||
|
||||
|
||||
+142
-17
@@ -14,6 +14,7 @@ namespace Fitness_core_BE.Services
|
||||
IFacilityRepository facilityRepository,
|
||||
IRfidTagRepository rfidTagRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper) : IBookingService
|
||||
{
|
||||
@@ -21,6 +22,7 @@ namespace Fitness_core_BE.Services
|
||||
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;
|
||||
|
||||
@@ -113,6 +115,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");
|
||||
@@ -135,6 +138,7 @@ namespace Fitness_core_BE.Services
|
||||
EndTime = session.EndTime,
|
||||
FKClientId = session.FKClientId,
|
||||
IsGuest = true,
|
||||
FKCustomerId = customerId,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
@@ -207,6 +211,7 @@ namespace Fitness_core_BE.Services
|
||||
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;
|
||||
|
||||
@@ -220,6 +225,7 @@ namespace Fitness_core_BE.Services
|
||||
PartySize = partySize,
|
||||
FKClientId = facility.FKClientId,
|
||||
IsGuest = true,
|
||||
FKCustomerId = customerId,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
@@ -283,6 +289,22 @@ namespace Fitness_core_BE.Services
|
||||
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/CreateGuestAdhocBooking. 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)
|
||||
@@ -293,6 +315,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");
|
||||
@@ -301,6 +325,9 @@ 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, includeOtp: false);
|
||||
@@ -370,29 +397,40 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lazily reconciles everything a background job would otherwise have swept:
|
||||
/// Confirmed bookings whose window passed with no check-in become NoShow; CheckedIn
|
||||
/// bookings whose window passed become Completed and release their tag. Runs at the top
|
||||
/// of every check-in, across every facility for this client (not scoped to whichever
|
||||
/// facility is being checked into right now), so the whole roster stays accurate as long
|
||||
/// as check-ins keep happening anywhere in the club.</summary>
|
||||
/// <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.
|
||||
}
|
||||
|
||||
var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now);
|
||||
foreach (var booking in overdueCheckedIn)
|
||||
/// <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)
|
||||
{
|
||||
booking.Status = "Completed";
|
||||
booking.CheckedOutAt = now;
|
||||
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
|
||||
await _bookingRepository.CreateBookingInvoice(new BookingInvoice
|
||||
{
|
||||
booking.RfidTag.Status = "Available";
|
||||
booking.RfidTag.UpdatedAt = now;
|
||||
booking.RfidReturnedAt = now;
|
||||
}
|
||||
}
|
||||
// Caller's _uow.CommitAsync() persists these tracked changes alongside its own.
|
||||
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)
|
||||
@@ -572,6 +610,9 @@ namespace Fitness_core_BE.Services
|
||||
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()!);
|
||||
|
||||
@@ -580,6 +621,28 @@ namespace Fitness_core_BE.Services
|
||||
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;
|
||||
@@ -655,6 +718,48 @@ namespace Fitness_core_BE.Services
|
||||
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();
|
||||
|
||||
// Built from the request itself (scheme/host), not a config value, so the link is
|
||||
@@ -663,6 +768,9 @@ 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 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,
|
||||
@@ -685,11 +793,28 @@ namespace Fitness_core_BE.Services
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ namespace Fitness_core_BE
|
||||
// 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>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -184,6 +188,7 @@ namespace Fitness_core_BE
|
||||
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
|
||||
modelBuilder.Entity<FacilitySession>().Ignore(s => s.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);
|
||||
|
||||
@@ -278,6 +283,9 @@ namespace Fitness_core_BE
|
||||
modelBuilder.Entity<Booking>()
|
||||
.HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime });
|
||||
|
||||
modelBuilder.Entity<BookingInvoice>()
|
||||
.HasIndex(i => new { i.FKClientId, i.IssuedAt });
|
||||
|
||||
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
||||
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user