blocker phase end

This commit is contained in:
hamid
2026-08-02 23:42:12 +03:30
parent 184b202f00
commit 10d160358f
8 changed files with 95 additions and 30 deletions
@@ -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);
}
@@ -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));
@@ -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(
@@ -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
@@ -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.