blocker phase end
This commit is contained in:
+9
-3
@@ -56,9 +56,15 @@ Effort is a rough size, not a schedule: **S** = small/contained, **M** = a real
|
|||||||
this feature specifically. *(Effort: L)*
|
this feature specifically. *(Effort: L)*
|
||||||
|
|
||||||
### Booking lifecycle
|
### Booking lifecycle
|
||||||
- **A booking whose remaining visits get automatically marked "missed" can get stuck forever** and never
|
- ~~**A booking whose remaining visits get automatically marked "missed" can get stuck forever** and never
|
||||||
reach a state where the nurse can actually be paid for the visits she did complete. The "today's visits"
|
reach a state where the nurse can actually be paid for the visits she did complete.~~ **Fixed (phase 13)**
|
||||||
list nurses see is also unfiltered — it shows a nurse's entire history, not just today. *(Effort: M)*
|
for the case that matters for payout: a booking with at least one completed visit and the rest auto-missed
|
||||||
|
now correctly completes and opens its dispute window. **Left deliberately open:** a booking where *every*
|
||||||
|
visit gets auto-missed (nurse never showed for any of it) still gets stuck — paying that nurse in full needs
|
||||||
|
a payout-engine change, so it was deferred rather than guessed at; see `mvp/fix-plan.md`'s phase 13
|
||||||
|
follow-up.
|
||||||
|
- ~~The "today's visits" list nurses see is also unfiltered — it shows a nurse's entire history, not just
|
||||||
|
today.~~ **Fixed (phase 13).** *(Effort: M)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+35
-19
@@ -28,7 +28,7 @@ whatever order you prefer.
|
|||||||
| 10 | [search-dedup-and-trust](blocker-phases/10-search-dedup-and-trust.md) | Search isn't de-duplicated; trust info hardcoded | pairs with 09 | ✅ Done |
|
| 10 | [search-dedup-and-trust](blocker-phases/10-search-dedup-and-trust.md) | Search isn't de-duplicated; trust info hardcoded | pairs with 09 | ✅ Done |
|
||||||
| 11 | [nurse-payouts](blocker-phases/11-nurse-payouts.md) | Nurse pay/payouts are fake, no "process" action | benefits from 01 | — |
|
| 11 | [nurse-payouts](blocker-phases/11-nurse-payouts.md) | Nurse pay/payouts are fake, no "process" action | benefits from 01 | — |
|
||||||
| 12 | [patient-records](blocker-phases/12-patient-records.md) | Patient records & visit notes are fake demo data | needs a product decision first | 🟡 Partial (see follow-up below) |
|
| 12 | [patient-records](blocker-phases/12-patient-records.md) | Patient records & visit notes are fake demo data | needs a product decision first | 🟡 Partial (see follow-up below) |
|
||||||
| 13 | [booking-lifecycle](blocker-phases/13-booking-lifecycle.md) | Stuck bookings; "today's visits" unfiltered | pairs with 04 | — |
|
| 13 | [booking-lifecycle](blocker-phases/13-booking-lifecycle.md) | Stuck bookings; "today's visits" unfiltered | pairs with 04 | 🟡 Partial (see follow-up below) |
|
||||||
| 14 | [partner-center](blocker-phases/14-partner-center.md) | Partner/business-center accounts are fake | benefits from 01 | — |
|
| 14 | [partner-center](blocker-phases/14-partner-center.md) | Partner/business-center accounts are fake | benefits from 01 | — |
|
||||||
| 15 | [debug-mode-production](blocker-phases/15-debug-mode-production.md) | Turn off dev mode on the live site (§B.2) | do last, deliberately | — |
|
| 15 | [debug-mode-production](blocker-phases/15-debug-mode-production.md) | Turn off dev mode on the live site (§B.2) | do last, deliberately | — |
|
||||||
|
|
||||||
@@ -47,23 +47,38 @@ whatever order you prefer.
|
|||||||
Building the missing preview/retry/reject endpoints needs real design (retry semantics re-executing a
|
Building the missing preview/retry/reject endpoints needs real design (retry semantics re-executing a
|
||||||
channel call, what "reject" reverses) that isn't specified anywhere — filed as its own future phase, not
|
channel call, what "reject" reverses) that isn't specified anywhere — filed as its own future phase, not
|
||||||
guessed here.
|
guessed here.
|
||||||
- **Same timezone bug as 04, lower severity, not fixed.** Phase 04 fixed `BookingRequest.PaymentDeadlineAt`/
|
- **Same timezone bug as 04, lower severity — now fixed, folded into phase 13.** Phase 04 fixed
|
||||||
`NurseResponseDeadlineAt` — a `DateTime` (not `DateTimeOffset`) read back from SQL Server's `datetime2`
|
`BookingRequest.PaymentDeadlineAt`/`NurseResponseDeadlineAt` — a `DateTime` (not `DateTimeOffset`) read back
|
||||||
loses its `Kind` tag (comes back `Unspecified`), so JSON serialization drops the trailing `Z` and a client
|
from SQL Server's `datetime2` loses its `Kind` tag (comes back `Unspecified`), so JSON serialization drops
|
||||||
`Date.parse()` misreads it as local time. A grep for every other bare `DateTime`/`DateTime?` entity property
|
the trailing `Z` and a client `Date.parse()` misreads it as local time. The same grep found four more real
|
||||||
found four more real instances, all on `Booking` (`server/src/Core/Baya.Domain/Entities/Booking/Booking.cs`):
|
instances, all on `Booking`: `DisputeWindowEndsAt`, `ConfirmedAt`, `CancelledAt`, `CompletedAt`
|
||||||
`DisputeWindowEndsAt` (:95, rendered via `formatShamsiDate` in
|
(`server/src/Core/Baya.Domain/Entities/Booking/Booking.cs:73,74,91,95`). Applied the existing
|
||||||
`client/src/components/booking/BookingDetailView/BookingDetailView.tsx:444`), and `ConfirmedAt`/
|
`UtcDateTimeConverter` to all four in `BookingConfig.cs`. No migration needed (same column type, only the
|
||||||
`CancelledAt`/`CompletedAt` (:73,74,91), booking timeline timestamps shown to the client. Lower severity than
|
in-memory `Kind` tag changes on read).
|
||||||
04 — nothing here drives a live countdown, so the failure mode is "can show the wrong calendar day near a
|
|
||||||
Tehran (UTC+3:30) midnight boundary," not "actively expires while still showing time left." (Everything
|
- **Phase 13 closed both items except one deliberately-deferred edge case.**
|
||||||
else with a bare `DateTime` — payout batches, webhook events, ASP.NET Identity tables — is internal/audit-only
|
- **13a (stuck partial-missed bookings) fixed.** Extracted the `allSettled` check `CheckOutVisitCommand`
|
||||||
and never reaches a client, so it's excluded.) Fix is the same pattern phase 04 used: apply the existing
|
already ran after a real check-out into `Booking.IsAllSessionsSettled()`
|
||||||
`UtcDateTimeConverter` (`server/src/Infrastructure/Baya.Infrastructure.Persistence/ValueConversion/
|
(`server/src/Core/Baya.Domain/Entities/Booking/Booking.cs`) and called it from
|
||||||
UtcDateTimeConverter.cs`) to these four properties in `BookingConfig.cs`. No migration needed. Deliberately
|
`DetectNoShowSessionsCommand.Handler.cs` after the no-show sweep marks sessions `Missed`, re-checking every
|
||||||
left undone — pick up as its own small phase, or fold into whatever eventually addresses 13b's timezone
|
booking touched in that batch. A booking with one completed session and the rest auto-missed now correctly
|
||||||
decision (booking-lifecycle's "today's visits" fix), since both are the same missing
|
reaches `Completed` and opens its dispute window (so the nurse's completed-session payout becomes eligible)
|
||||||
`Asia/Tehran`/UTC-boundary discipline.
|
instead of staying stuck at `InProgress` until an admin manually rescues it.
|
||||||
|
**Still open, on purpose — the zero-completed-sessions case (every session auto-missed straight from
|
||||||
|
`Confirmed`/`InProgress`).** Asked whether that should still reach `Completed` with the nurse paid in full
|
||||||
|
(today's payout query is booking-level only — `PayoutRepository.EligibleBookingsQuery` pays the whole
|
||||||
|
`NursePayoutAmount` off `Status`+`DisputeWindowEndsAt`, with no per-session proration, so this would pay a
|
||||||
|
nurse who did zero visits), reach `Completed` with the payout fields zeroed (a bigger deviation from the
|
||||||
|
"money snapshot, never mutate" convention), or use a new terminal status with no payout path at all. The
|
||||||
|
answer was to leave it deferred rather than pick one now — it stays stuck exactly as before, rescuable only
|
||||||
|
via the admin `TransitionBookingStatusCommand` (`InProgress → Completed`). Revisit once the payout query
|
||||||
|
itself is made session-aware, or once there's a concrete need to close these out.
|
||||||
|
- **13b ("today's visits" unfiltered) fixed.** `ListSessionsForNurseQueryHandler` now defaults
|
||||||
|
`request.Date` to "today" when null, mirroring the mock (`mockApi.ts:438`). "Today" needed a timezone
|
||||||
|
decision the codebase had never made (no `Asia/Tehran`-aware date logic existed anywhere): added
|
||||||
|
`TehranClock` (`server/src/Core/Baya.Application/Contracts/Common/TehranClock.cs`), a fixed UTC+03:30
|
||||||
|
offset (Iran abolished DST in 2022, so no tzdata/`TimeZoneInfo` lookup is needed) — a technical default,
|
||||||
|
not a business-rule guess, so it wasn't flagged for a decision the way 13a was.
|
||||||
|
|
||||||
- **Phase 12 closed everything except the medication/routine schema decision, which stays deliberately
|
- **Phase 12 closed everything except the medication/routine schema decision, which stays deliberately
|
||||||
unresolved.** Fixed: the silent data-wipe (`patients/[id]/record/page.tsx`'s `EditableTabs.save` now always
|
unresolved.** Fixed: the silent data-wipe (`patients/[id]/record/page.tsx`'s `EditableTabs.save` now always
|
||||||
@@ -100,6 +115,7 @@ whatever order you prefer.
|
|||||||
`super_admin`/`finance` accounts.
|
`super_admin`/`finance` accounts.
|
||||||
2. **Small, contained, no dependencies:** 03, 04, 05, 06.
|
2. **Small, contained, no dependencies:** 03, 04, 05, 06.
|
||||||
3. **De-mock passes, each roughly self-contained:** 08, 09+10 together, 11.
|
3. **De-mock passes, each roughly self-contained:** 08, 09+10 together, 11.
|
||||||
4. **Needs a product decision before coding:** 12 (medication/routine schema), 13a (all-missed edge case).
|
4. **Needs a product decision before coding:** 12 (medication/routine schema), 13a's all-missed edge case
|
||||||
|
(deferred on purpose — see follow-up above).
|
||||||
5. **14 (partner center)** — the largest single phase, mostly new server surface.
|
5. **14 (partner center)** — the largest single phase, mostly new server surface.
|
||||||
6. **15 (debug mode)** — on its own, right before any real user is let near the site.
|
6. **15 (debug mode)** — on its own, right before any real user is let near the site.
|
||||||
|
|||||||
@@ -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)
|
IDateTimeProvider dateTimeProvider)
|
||||||
: IRequestHandler<CheckOutVisitCommand, OperationResult<VisitVerificationDto>>
|
: 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)
|
public async ValueTask<OperationResult<VisitVerificationDto>> Handle(CheckOutVisitCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
if (currentUser.UserId is not { } userId)
|
||||||
@@ -62,8 +58,7 @@ internal sealed class CheckOutVisitCommandHandler(
|
|||||||
session.SetPayoutEligible(now.AddHours(disputeWindowHours));
|
session.SetPayoutEligible(now.AddHours(disputeWindowHours));
|
||||||
|
|
||||||
// When every session is settled, the booking completes and its dispute window opens.
|
// When every session is settled, the booking completes and its dispute window opens.
|
||||||
var allSettled = booking.Sessions.All(s => TerminalSessionStatuses.Contains(s.Status));
|
if (booking.IsAllSessionsSettled() && booking.CanTransitionTo(BookingStatus.Completed))
|
||||||
if (allSettled && booking.CanTransitionTo(BookingStatus.Completed))
|
|
||||||
{
|
{
|
||||||
booking.TransitionTo(BookingStatus.Completed, now);
|
booking.TransitionTo(BookingStatus.Completed, now);
|
||||||
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
|
booking.SetDisputeWindow(now.AddHours(disputeWindowHours));
|
||||||
|
|||||||
+16
@@ -49,6 +49,22 @@ internal sealed class DetectNoShowSessionsCommandHandler(
|
|||||||
|
|
||||||
await unitOfWork.CommitAsync();
|
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)
|
foreach (var session in missed)
|
||||||
{
|
{
|
||||||
await supportAlerts.RaiseAsync(
|
await supportAlerts.RaiseAsync(
|
||||||
|
|||||||
+3
-2
@@ -8,11 +8,12 @@ using Mediator;
|
|||||||
|
|
||||||
namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse;
|
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>>>
|
: IRequestHandler<ListSessionsForNurseQuery, OperationResult<PagedResult<BookingSessionListItemDto>>>
|
||||||
{
|
{
|
||||||
public async ValueTask<OperationResult<PagedResult<BookingSessionListItemDto>>> Handle(ListSessionsForNurseQuery request, CancellationToken cancellationToken)
|
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)
|
if (currentUser.UserId is not { } userId)
|
||||||
return OperationResult<PagedResult<BookingSessionListItemDto>>.UnauthorizedResult("Not authenticated.");
|
return OperationResult<PagedResult<BookingSessionListItemDto>>.UnauthorizedResult("Not authenticated.");
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser,
|
|||||||
if (nurseId is not { } nid)
|
if (nurseId is not { } nid)
|
||||||
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(new PagedResult<BookingSessionListItemDto>([], 0, page, pageSize));
|
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);
|
return OperationResult<PagedResult<BookingSessionListItemDto>>.SuccessResult(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ public class Booking : BaseEntity<long>
|
|||||||
|
|
||||||
public bool CanTransitionTo(string target) => BookingTransitions.CanTransition(Status, target);
|
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>
|
/// <summary>
|
||||||
/// Applies a status change through the allowed-transition guard and stamps the matching lifecycle
|
/// 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
|
/// 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.Booking;
|
||||||
using Baya.Domain.Entities.Catalog;
|
using Baya.Domain.Entities.Catalog;
|
||||||
using Baya.Domain.Entities.Identity;
|
using Baya.Domain.Entities.Identity;
|
||||||
|
using Baya.Infrastructure.Persistence.ValueConversion;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
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.CancelledBy).HasMaxLength(20);
|
||||||
builder.Property(b => b.CancellationPolicyCode).HasMaxLength(50);
|
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.CustomerId, b.Status });
|
||||||
builder.HasIndex(b => new { b.NurseId, b.Status });
|
builder.HasIndex(b => new { b.NurseId, b.Status });
|
||||||
// b13 selects payout-eligible bookings through the dispute-window close.
|
// b13 selects payout-eligible bookings through the dispute-window close.
|
||||||
|
|||||||
Reference in New Issue
Block a user