blocker phase end
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Iran runs a fixed UTC+03:30 offset (DST abolished 2022) — no <c>Asia/Tehran</c> tzdata lookup needed or
|
||||
/// available. The single place "today" means Tehran-local rather than a UTC day boundary, until another call
|
||||
/// site needs the same decision (mvp/blocker-phases/13b, mvp/blocker-phases/04).
|
||||
/// </summary>
|
||||
public static class TehranClock
|
||||
{
|
||||
private static readonly TimeSpan Offset = TimeSpan.FromMinutes(210);
|
||||
|
||||
public static DateOnly Today(DateTimeOffset utcNow) => DateOnly.FromDateTime(utcNow.ToOffset(Offset).DateTime);
|
||||
}
|
||||
+1
-6
@@ -17,10 +17,6 @@ internal sealed class CheckOutVisitCommandHandler(
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<CheckOutVisitCommand, OperationResult<VisitVerificationDto>>
|
||||
{
|
||||
// A session is "settled" for booking-completion purposes when it can no longer become in_progress.
|
||||
private static readonly string[] TerminalSessionStatuses =
|
||||
[BookingSessionStatus.Completed, BookingSessionStatus.Missed, BookingSessionStatus.Cancelled];
|
||||
|
||||
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(CheckOutVisitCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
@@ -62,8 +58,7 @@ internal sealed class CheckOutVisitCommandHandler(
|
||||
session.SetPayoutEligible(now.AddHours(disputeWindowHours));
|
||||
|
||||
// When every session is settled, the booking completes and its dispute window opens.
|
||||
var allSettled = booking.Sessions.All(s => TerminalSessionStatuses.Contains(s.Status));
|
||||
if (allSettled && booking.CanTransitionTo(BookingStatus.Completed))
|
||||
if (booking.IsAllSessionsSettled() && booking.CanTransitionTo(BookingStatus.Completed))
|
||||
{
|
||||
booking.TransitionTo(BookingStatus.Completed, now);
|
||||
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
|
||||
|
||||
+16
@@ -49,6 +49,22 @@ internal sealed class DetectNoShowSessionsCommandHandler(
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// A booking with one already-completed session and the rest auto-missed here would otherwise be
|
||||
// stuck at InProgress forever — re-run the same allSettled gate CheckOutVisit applies after a real
|
||||
// check-out (mvp/blocker-phases/13a).
|
||||
var disputeWindowHours = await platformConfig.GetConfig<int>("dispute_window_hours", cancellationToken);
|
||||
foreach (var bookingId in missed.Select(s => s.BookingId).Distinct())
|
||||
{
|
||||
var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(bookingId, cancellationToken);
|
||||
if (booking is null || !booking.IsAllSessionsSettled() || !booking.CanTransitionTo(BookingStatus.Completed))
|
||||
continue;
|
||||
|
||||
booking.TransitionTo(BookingStatus.Completed, now);
|
||||
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
foreach (var session in missed)
|
||||
{
|
||||
await supportAlerts.RaiseAsync(
|
||||
|
||||
+3
-2
@@ -8,11 +8,12 @@ using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
|
||||
|
||||
internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<ListSessionsForNurseQuery, OperationResult<PagedResult<BookingSessionListItemDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<BookingSessionListItemDto>>> Handle(ListSessionsForNurseQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var date = request.Date ?? TehranClock.Today(dateTimeProvider.UtcNow);
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<BookingSessionListItemDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
@@ -26,7 +27,7 @@ internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser,
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(new PagedResult<BookingSessionListItemDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.BookingRepository.ListSessionsForNurseAsync(nid, request.Date, page, pageSize, cancellationToken);
|
||||
var result = await unitOfWork.BookingRepository.ListSessionsForNurseAsync(nid, date, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,14 @@ public class Booking : BaseEntity<long>
|
||||
|
||||
public bool CanTransitionTo(string target) => BookingTransitions.CanTransition(Status, target);
|
||||
|
||||
private static readonly string[] TerminalSessionStatuses =
|
||||
[BookingSessionStatus.Completed, BookingSessionStatus.Missed, BookingSessionStatus.Cancelled];
|
||||
|
||||
/// <summary>True once every session reached a terminal state (completed/missed/cancelled) — the gate for
|
||||
/// the InProgress → Completed transition. Checked both after a real check-out and after the no-show sweep
|
||||
/// marks sessions Missed, so a mixed completed+missed booking doesn't get stuck (mvp/blocker-phases/13).</summary>
|
||||
public bool IsAllSessionsSettled() => Sessions.All(s => TerminalSessionStatuses.Contains(s.Status));
|
||||
|
||||
/// <summary>
|
||||
/// Applies a status change through the allowed-transition guard and stamps the matching lifecycle
|
||||
/// timestamp. Callers pre-check with <see cref="CanTransitionTo"/> and return a clean conflict; reaching
|
||||
|
||||
+10
@@ -1,6 +1,7 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence.ValueConversion;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -29,6 +30,15 @@ internal sealed class BookingConfig : IEntityTypeConfiguration<Booking>
|
||||
builder.Property(b => b.CancelledBy).HasMaxLength(20);
|
||||
builder.Property(b => b.CancellationPolicyCode).HasMaxLength(50);
|
||||
|
||||
// datetime2 loses Kind on read (comes back Unspecified); re-tag as Utc so JSON serialization keeps
|
||||
// the trailing Z and clients don't misparse these timeline instants as local time
|
||||
// (mvp/blocker-phases/04's follow-up, folded in here since this phase already touches Booking timezone
|
||||
// handling).
|
||||
builder.Property(b => b.ConfirmedAt).HasConversion(new UtcDateTimeConverter());
|
||||
builder.Property(b => b.CancelledAt).HasConversion(new UtcDateTimeConverter());
|
||||
builder.Property(b => b.CompletedAt).HasConversion(new UtcDateTimeConverter());
|
||||
builder.Property(b => b.DisputeWindowEndsAt).HasConversion(new UtcDateTimeConverter());
|
||||
|
||||
builder.HasIndex(b => new { b.CustomerId, b.Status });
|
||||
builder.HasIndex(b => new { b.NurseId, b.Status });
|
||||
// b13 selects payout-eligible bookings through the dispute-window close.
|
||||
|
||||
Reference in New Issue
Block a user