start of manual testing
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
#nullable enable
|
||||
namespace Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// The static, deterministic description of the Development <b>lifecycle</b> demo world the
|
||||
/// <see cref="DemoLifecycleSeeder"/> materialises on top of <see cref="DemoWorldDefinitions"/>'s personas:
|
||||
/// which booking scenario exists in which state, the fixed ticket reference codes, and the one extra
|
||||
/// partner-center persona. Kept as plain data so the exact scenario table reads at a glance and stays the
|
||||
/// single source of truth for the runbook / testing plan.
|
||||
/// <para>
|
||||
/// All timestamps are expressed as <b>day/hour offsets from seed time</b> (negative = past) because the
|
||||
/// scenario meanings are relative ("completed inside the 72h dispute window"), not absolute dates.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class DemoLifecycleDefinitions
|
||||
{
|
||||
/// <summary>The partner-center dashboard owner (no admin/nurse role — the portal keys off the center's
|
||||
/// <c>admin_user_id</c>; the customer role only gives the login a sane landing).</summary>
|
||||
public const string PartnerAdminPhone = "09120000030";
|
||||
public const string PartnerAdminUserName = "demo_partner_rastegar";
|
||||
public const string PartnerAdminName = "بهنام";
|
||||
public const string PartnerAdminFamilyName = "رستگار";
|
||||
|
||||
public const string PartnerCenterName = "مرکز پرستاری آرامش";
|
||||
public const string PartnerCenterPermitNo = "MOH-C-1001"; // the natural idempotency key
|
||||
public const string PartnerCenterEnamad = "ENAMAD-DEMO-771";
|
||||
|
||||
/// <summary>The extra 5-session package variant seeded for the multi-session booking scenario
|
||||
/// (PostSurgery × Night so its option-set hash can never collide with the base seeder's variants).</summary>
|
||||
public const string PackageVariantDisplayName = "پکیج ۵ جلسهای مراقبت پس از جراحی";
|
||||
public const long PackageVariantPricePerSession = 900_000;
|
||||
public const int PackageVariantSessionCount = 5;
|
||||
|
||||
// Fixed, human-greppable ticket reference codes — UNIQUE-indexed, so they double as idempotency guards.
|
||||
public const string RefundTicketCard = "TKT-DEMORF01";
|
||||
public const string RefundTicketBnpl = "TKT-DEMORF02";
|
||||
public const string RefundTicketClawback = "TKT-DEMORF03";
|
||||
public const string SupportTicketOpen = "TKT-DEMOSUP1";
|
||||
public const string SupportTicketClosed = "TKT-DEMOSUP2";
|
||||
public const string EmergencyTicket = "TKT-DEMOEMG1";
|
||||
|
||||
/// <summary>Prefix for the fake Shaparak reference codes (filtered-UNIQUE column — each txn appends its
|
||||
/// scenario key).</summary>
|
||||
public const string GatewayRefPrefix = "demo-shaparak-";
|
||||
|
||||
/// <summary>
|
||||
/// The booking scenario table. One row per engagement, keyed by <see cref="Key"/> (stable, greppable).
|
||||
/// Offsets are in days relative to seed time unless noted. A null <see cref="CompletedDaysAgo"/> means the
|
||||
/// booking never completed. The request behind every booking is <c>converted</c>; the request-only
|
||||
/// scenarios (pending/accepted/rejected/expired/cancelled) are seeded separately in the seeder.
|
||||
/// </summary>
|
||||
public static readonly BookingScenario[] Bookings =
|
||||
[
|
||||
// B1 — paid, upcoming, care instructions written; the "what does a fresh confirmed booking look like" case.
|
||||
new("upcoming", Customer: 0, Nurse: 0, VariantIndex: 0, ScheduledInDays: 3,
|
||||
ConfirmedDaysAgo: 1, CompletedDaysAgo: null, Cancelled: false, WithCareInstructions: true),
|
||||
|
||||
// B2 — the multi-session package, mid-engagement today: 2 sessions done, session 3 checked-in now.
|
||||
new("in_progress", Customer: 0, Nurse: 0, VariantIndex: -1 /* the seeded package variant */, ScheduledInDays: 0,
|
||||
ConfirmedDaysAgo: 4, CompletedDaysAgo: null, Cancelled: false, WithCareInstructions: true),
|
||||
|
||||
// B3 — completed yesterday: dispute window still open, review still pending moderation.
|
||||
new("completed_in_window", Customer: 0, Nurse: 0, VariantIndex: 2, ScheduledInDays: -1,
|
||||
ConfirmedDaysAgo: 3, CompletedDaysAgo: 1, Cancelled: false, WithCareInstructions: false),
|
||||
|
||||
// B4 — completed long ago, paid out, carries the published 5★ review.
|
||||
new("completed_paid", Customer: 0, Nurse: 0, VariantIndex: 0, ScheduledInDays: -10,
|
||||
ConfirmedDaysAgo: 12, CompletedDaysAgo: 10, Cancelled: false, WithCareInstructions: false),
|
||||
|
||||
// B5 — completed, window closed, waiting in the draft batch; carries the hidden 1★ review + the
|
||||
// out-of-range EVV check-in that feeds the admin EVV queue.
|
||||
new("completed_eligible", Customer: 1, Nurse: 1, VariantIndex: 0, ScheduledInDays: -5,
|
||||
ConfirmedDaysAgo: 7, CompletedDaysAgo: 5, Cancelled: false, WithCareInstructions: false),
|
||||
|
||||
// B6 — paid via BNPL (settled net-of-fee), then disputed: a processing bnpl_revert refund excludes it
|
||||
// from every payout batch.
|
||||
new("bnpl_settled", Customer: 1, Nurse: 1, VariantIndex: 1, ScheduledInDays: -6,
|
||||
ConfirmedDaysAgo: 8, CompletedDaysAgo: 6, Cancelled: false, WithCareInstructions: false),
|
||||
|
||||
// B7 — cancelled ≥24h ahead (standard_24h, 100%): the succeeded card refund.
|
||||
new("cancelled_refunded", Customer: 0, Nurse: 0, VariantIndex: 2, ScheduledInDays: 2,
|
||||
ConfirmedDaysAgo: 3, CompletedDaysAgo: null, Cancelled: true, WithCareInstructions: false),
|
||||
|
||||
// C8 — completed + paid out, then refunded: the post-payout clawback case.
|
||||
new("clawback", Customer: 0, Nurse: 0, VariantIndex: 1, ScheduledInDays: -11,
|
||||
ConfirmedDaysAgo: 13, CompletedDaysAgo: 11, Cancelled: false, WithCareInstructions: false),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>One booking scenario row. <see cref="Customer"/>/<see cref="Nurse"/> index into
|
||||
/// <see cref="DemoWorldDefinitions.Customers"/>/<see cref="DemoWorldDefinitions.Nurses"/>;
|
||||
/// <see cref="VariantIndex"/> indexes the nurse's variants in creation order (−1 = the seeded 5-session
|
||||
/// package variant).</summary>
|
||||
internal sealed record BookingScenario(
|
||||
string Key,
|
||||
int Customer,
|
||||
int Nurse,
|
||||
int VariantIndex,
|
||||
int ScheduledInDays,
|
||||
int ConfirmedDaysAgo,
|
||||
int? CompletedDaysAgo,
|
||||
bool Cancelled,
|
||||
bool WithCareInstructions);
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Features.Invoices.Commands.IssueInvoice;
|
||||
using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Baya.Domain.Entities.Bnpl;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.PartnerCenters;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
|
||||
internal sealed partial class DemoLifecycleSeeder
|
||||
{
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// BNPL — the settled provider-financed order behind the "bnpl_settled" booking (net-of-fee model).
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureBnplAsync(BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!bookings.ByKey.TryGetValue("bnpl_settled", out var booking))
|
||||
return;
|
||||
|
||||
var txn = bookings.TxnByKey["bnpl_settled"];
|
||||
if (await db.Set<BnplTransaction>().AnyAsync(b => b.PaymentTransactionId == txn.Id, cancellationToken))
|
||||
return;
|
||||
|
||||
var commissionRate = await platformConfig.GetConfig<decimal>("bnpl_provider_commission_rate", cancellationToken);
|
||||
var fee = (long)Math.Round(booking.GrossPriceIrr * commissionRate, MidpointRounding.AwayFromZero);
|
||||
var settledAt = booking.ConfirmedAt ?? now;
|
||||
|
||||
var bnpl = new BnplTransaction
|
||||
{
|
||||
PaymentTransactionId = txn.Id,
|
||||
ProviderCode = BnplProviderCodes.SnappPay,
|
||||
MerchantOfRecord = "platform",
|
||||
EligibilityStatus = "approved",
|
||||
OrderAmountIrr = booking.GrossPriceIrr,
|
||||
InstallmentCount = 4
|
||||
};
|
||||
bnpl.MarkTokenIssued("demo-bnpl-token-1", "demo-bnpl-order-1");
|
||||
bnpl.MarkVerified("demo-bnpl-order-1", "{\"source\":\"demo-lifecycle-seed\",\"step\":\"verify\"}");
|
||||
bnpl.MarkSettled(
|
||||
settledAmountIrr: booking.GrossPriceIrr - fee,
|
||||
commissionIrr: fee,
|
||||
settledAt: settledAt.AddDays(1),
|
||||
callbackPayloadJson: "{\"source\":\"demo-lifecycle-seed\",\"step\":\"settle\"}");
|
||||
db.Set<BnplTransaction>().Add(bnpl);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns bnpl.Id for the ledger source ref
|
||||
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.BnplSettle(
|
||||
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr,
|
||||
booking.NursePayoutAmount, bnplCommissionIrr: fee, bnplTransactionId: bnpl.Id, createdAt: settledAt.AddDays(1)));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Payout batches — dispatched through the REAL commands so eligibility, the first-payout IBAN gate,
|
||||
// clawback netting, and the payout ledger all come from production code, not a reimplementation.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsurePaidPayoutBatchAsync(BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!bookings.ByKey.TryGetValue("completed_paid", out var paidBooking))
|
||||
return;
|
||||
if (await db.Set<NursePayoutBookingLink>().AnyAsync(l => l.BookingId == paidBooking.Id, cancellationToken))
|
||||
return;
|
||||
|
||||
// periodEnd T0−4d keeps the still-in-draft-window bookings (dispute end T0−2d/−3d) out of this batch.
|
||||
var generate = await sender.Send(
|
||||
new GeneratePayoutBatchCommand(Today(now, -21), Today(now, -4)) { SystemInitiated = true },
|
||||
cancellationToken);
|
||||
if (!generate.IsSuccess)
|
||||
{
|
||||
logger.LogWarning("Demo lifecycle: paid payout batch generation failed — skipping the paid-batch scenario.");
|
||||
return;
|
||||
}
|
||||
|
||||
// The seeder is the only writer at boot, so the newest batch is the one just generated.
|
||||
var batchId = await db.Set<NursePayoutBatch>()
|
||||
.OrderByDescending(b => b.Id)
|
||||
.Select(b => b.Id)
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
var execute = await sender.Send(new ExecutePayoutBatchCommand(batchId), cancellationToken);
|
||||
if (!execute.IsSuccess)
|
||||
logger.LogWarning("Demo lifecycle: executing payout batch {BatchId} failed — it stays unprocessed.", batchId);
|
||||
}
|
||||
|
||||
private async Task EnsureDraftPayoutBatchAsync(BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!bookings.ByKey.TryGetValue("completed_eligible", out var eligibleBooking))
|
||||
return;
|
||||
if (await db.Set<NursePayoutBookingLink>().AnyAsync(l => l.BookingId == eligibleBooking.Id, cancellationToken))
|
||||
return;
|
||||
|
||||
// Runs AFTER the refunds: the BNPL-refunded booking is genuinely excluded by the real eligibility
|
||||
// query, and this batch deliberately stays draft so the admin "process" action is demoable.
|
||||
var generate = await sender.Send(
|
||||
new GeneratePayoutBatchCommand(Today(now, -3), Today(now, -1)) { SystemInitiated = true },
|
||||
cancellationToken);
|
||||
if (!generate.IsSuccess)
|
||||
logger.LogWarning("Demo lifecycle: draft payout batch generation failed — skipping the draft-batch scenario.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Refunds — one per channel/fork: succeeded card, processing BNPL revert, post-payout clawback.
|
||||
// Every refund is ticket-linked (b15: refunds.ticket_id is always non-null on the real path).
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureRefundsAsync(
|
||||
PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var admin = world.AdminUserId;
|
||||
|
||||
// 1) Cancelled booking → full (100%) card refund, already succeeded.
|
||||
if (bookings.ByKey.TryGetValue("cancelled_refunded", out var cancelled)
|
||||
&& !await db.Set<Refund>().AnyAsync(r => r.BookingId == cancelled.Id, cancellationToken))
|
||||
{
|
||||
var customer = world.Customers[0];
|
||||
var ticket = await EnsureRefundTicketAsync(
|
||||
DemoLifecycleDefinitions.RefundTicketCard, cancelled.Id, customer.UserId, admin,
|
||||
"استرداد وجه رزرو لغوشده", now.AddDays(-1), cancellationToken);
|
||||
|
||||
var refund = new Refund
|
||||
{
|
||||
PaymentTransactionId = bookings.TxnByKey["cancelled_refunded"].Id,
|
||||
BookingId = cancelled.Id,
|
||||
RequestedByCustomerId = customer.ProfileId,
|
||||
TicketId = ticket.Id,
|
||||
Amount = cancelled.GrossPriceIrr,
|
||||
PlatformFeeRefundedIrr = cancelled.BalinyaarCommissionIrr,
|
||||
NursePayoutRefundedIrr = cancelled.NursePayoutAmount,
|
||||
RefundPercentage = 1.0m,
|
||||
RefundChannel = RefundChannel.PspCard,
|
||||
ReasonCategory = "customer_cancelled",
|
||||
CancellationPolicyCode = cancelled.CancellationPolicyCode,
|
||||
RefundPercentageApplied = cancelled.CancellationRefundPercentage,
|
||||
ApprovedByAdminId = admin
|
||||
};
|
||||
refund.MarkSucceededCard("demo-psp-ref-1", now.AddDays(-1));
|
||||
db.Set<Refund>().Add(refund);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
ticket.RefundId = refund.Id;
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.RefundReversalPrePayout(
|
||||
cancelled.Id, cancelled.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr,
|
||||
refund.Id, now.AddDays(-1)));
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.RefundPayableClearing(
|
||||
cancelled.Id, refund.Amount, refund.Id, now.AddDays(-1)));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2) Completed BNPL booking disputed → 50% refund via bnpl_revert, still processing (the
|
||||
// admin confirm_settlement action stays demoable; the ~10-business-day ETA is surfaced).
|
||||
if (bookings.ByKey.TryGetValue("bnpl_settled", out var bnplBooking)
|
||||
&& !await db.Set<Refund>().AnyAsync(r => r.BookingId == bnplBooking.Id, cancellationToken))
|
||||
{
|
||||
var customer = world.Customers[1];
|
||||
var ticket = await EnsureRefundTicketAsync(
|
||||
DemoLifecycleDefinitions.RefundTicketBnpl, bnplBooking.Id, customer.UserId, admin,
|
||||
"اعتراض به کیفیت خدمت و درخواست استرداد", now.AddDays(-1), cancellationToken);
|
||||
|
||||
var amount = bnplBooking.GrossPriceIrr / 2;
|
||||
var feeLeg = (long)Math.Round(bnplBooking.BalinyaarCommissionIrr * 0.5m, MidpointRounding.AwayFromZero);
|
||||
var etaBusinessDays = await platformConfig.GetConfig<int>("bnpl_refund_eta_business_days", cancellationToken);
|
||||
|
||||
var refund = new Refund
|
||||
{
|
||||
PaymentTransactionId = bookings.TxnByKey["bnpl_settled"].Id,
|
||||
BookingId = bnplBooking.Id,
|
||||
RequestedByCustomerId = customer.ProfileId,
|
||||
TicketId = ticket.Id,
|
||||
Amount = amount,
|
||||
PlatformFeeRefundedIrr = feeLeg,
|
||||
NursePayoutRefundedIrr = amount - feeLeg,
|
||||
RefundPercentage = 0.5m,
|
||||
RefundChannel = RefundChannel.BnplRevert,
|
||||
ReasonCategory = "service_quality",
|
||||
ApprovedByAdminId = admin
|
||||
};
|
||||
refund.MarkProcessing("demo-revert-1", Today(now, etaBusinessDays + 4));
|
||||
db.Set<Refund>().Add(refund);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
ticket.RefundId = refund.Id;
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.RefundReversalPrePayout(
|
||||
bnplBooking.Id, bnplBooking.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr,
|
||||
refund.Id, now.AddDays(-1)));
|
||||
// No clearing leg yet — that posts when the admin confirms the settlement (the demoable action).
|
||||
|
||||
var bnpl = await db.Set<BnplTransaction>()
|
||||
.FirstOrDefaultAsync(b => b.PaymentTransactionId == refund.PaymentTransactionId, cancellationToken);
|
||||
bnpl?.MarkReverted("demo-revert-txn-1", amount, providerCommissionReversedAmount: null,
|
||||
revertedAt: now.AddDays(-1), callbackPayloadJson: "{\"source\":\"demo-lifecycle-seed\",\"step\":\"revert\"}");
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 3) Paid-out booking refunded afterwards → the post-payout clawback fork (IBAN transfers are
|
||||
// irreversible, so the nurse's already-paid share becomes a pending receivable).
|
||||
if (bookings.ByKey.TryGetValue("clawback", out var clawedBooking)
|
||||
&& !await db.Set<Refund>().AnyAsync(r => r.BookingId == clawedBooking.Id, cancellationToken))
|
||||
{
|
||||
var customer = world.Customers[0];
|
||||
var ticket = await EnsureRefundTicketAsync(
|
||||
DemoLifecycleDefinitions.RefundTicketClawback, clawedBooking.Id, customer.UserId, admin,
|
||||
"شکایت پس از پرداخت و درخواست استرداد", now.AddHours(-20), cancellationToken);
|
||||
|
||||
var refund = new Refund
|
||||
{
|
||||
PaymentTransactionId = bookings.TxnByKey["clawback"].Id,
|
||||
BookingId = clawedBooking.Id,
|
||||
RequestedByCustomerId = customer.ProfileId,
|
||||
TicketId = ticket.Id,
|
||||
Amount = clawedBooking.GrossPriceIrr,
|
||||
PlatformFeeRefundedIrr = clawedBooking.BalinyaarCommissionIrr,
|
||||
NursePayoutRefundedIrr = clawedBooking.NursePayoutAmount,
|
||||
RefundPercentage = 1.0m,
|
||||
RefundChannel = RefundChannel.PspCard,
|
||||
ReasonCategory = "service_quality",
|
||||
ApprovedByAdminId = admin
|
||||
};
|
||||
refund.MarkSucceededCard("demo-psp-ref-2", now.AddHours(-20));
|
||||
db.Set<Refund>().Add(refund);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
ticket.RefundId = refund.Id;
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.ClawbackReversalPostPayout(
|
||||
clawedBooking.Id, clawedBooking.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr,
|
||||
refund.Id, now.AddHours(-20)));
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.RefundPayableClearing(
|
||||
clawedBooking.Id, refund.Amount, refund.Id, now.AddHours(-20)));
|
||||
|
||||
var originalPayoutId = await db.Set<NursePayoutBookingLink>()
|
||||
.Where(l => l.BookingId == clawedBooking.Id)
|
||||
.Select(l => (long?)l.PayoutId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
db.Set<NurseClawback>().Add(new NurseClawback
|
||||
{
|
||||
NurseId = clawedBooking.NurseId,
|
||||
BookingId = clawedBooking.Id,
|
||||
RefundId = refund.Id,
|
||||
OriginalPayoutId = originalPayoutId,
|
||||
AmountIrr = refund.NursePayoutRefundedIrr
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Ticket> EnsureRefundTicketAsync(
|
||||
string referenceCode, long bookingId, int customerUserId, int adminUserId,
|
||||
string subject, DateTime openedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.Set<Ticket>()
|
||||
.FirstOrDefaultAsync(t => t.ReferenceCode == referenceCode, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing;
|
||||
|
||||
var ticket = new Ticket
|
||||
{
|
||||
ReferenceCode = referenceCode,
|
||||
Subject = subject,
|
||||
Category = TicketCategory.Refund,
|
||||
BookingId = bookingId,
|
||||
OpenedById = customerUserId,
|
||||
Participants =
|
||||
[
|
||||
new TicketParticipant { UserId = customerUserId, RoleOnTicket = TicketParticipantRole.Customer },
|
||||
new TicketParticipant { UserId = adminUserId, RoleOnTicket = TicketParticipantRole.Admin, AddedById = adminUserId }
|
||||
],
|
||||
Messages =
|
||||
[
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = customerUserId,
|
||||
Body = "سلام، لطفاً وضعیت استرداد وجه این رزرو را بررسی کنید.",
|
||||
SentAt = openedAt
|
||||
},
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = adminUserId,
|
||||
Body = "درخواست شما ثبت شد و در حال بررسی است. نتیجه از همین گفتگو اطلاعرسانی میشود.",
|
||||
SentAt = openedAt.AddMinutes(45)
|
||||
}
|
||||
]
|
||||
};
|
||||
db.Set<Ticket>().Add(ticket);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Partner center — the licensed merchant-of-record sponsor, linked to the second nurse so the MoR
|
||||
// invoice resolution and the partner portal have a real subject.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task<long> EnsurePartnerCenterAsync(PersonaWorld world, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.Set<PartnerCenter>()
|
||||
.FirstOrDefaultAsync(c => c.MohEstablishmentPermitNo == DemoLifecycleDefinitions.PartnerCenterPermitNo, cancellationToken);
|
||||
if (existing is not null)
|
||||
return existing.Id;
|
||||
|
||||
var portalUser = await userManager.GetUserByPhoneNumber(DemoLifecycleDefinitions.PartnerAdminPhone);
|
||||
if (portalUser is null)
|
||||
{
|
||||
portalUser = new User
|
||||
{
|
||||
UserName = DemoLifecycleDefinitions.PartnerAdminUserName,
|
||||
PhoneNumber = DemoLifecycleDefinitions.PartnerAdminPhone,
|
||||
PhoneNumberConfirmed = true,
|
||||
PhoneVerifiedAt = clock.UtcNow,
|
||||
Name = DemoLifecycleDefinitions.PartnerAdminName,
|
||||
FamilyName = DemoLifecycleDefinitions.PartnerAdminFamilyName,
|
||||
Gender = "male",
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var createResult = await userManager.CreateUser(portalUser);
|
||||
if (!createResult.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
$"Demo lifecycle seed failed to create the partner portal user: {string.Join("; ", createResult.Errors.Select(e => e.Description))}");
|
||||
|
||||
// The portal keys off the center's admin_user_id, not a role; the customer role only gives the
|
||||
// login a sane landing screen (there is deliberately no partner role in the vocabulary).
|
||||
var role = await db.Set<Role>().FirstAsync(r => r.Name == RoleNames.Customer, cancellationToken);
|
||||
await userManager.AddUserToRoleAsync(portalUser, role);
|
||||
}
|
||||
|
||||
var center = new PartnerCenter
|
||||
{
|
||||
Name = DemoLifecycleDefinitions.PartnerCenterName,
|
||||
LegalEntityType = "private_center",
|
||||
MohEstablishmentPermitNo = DemoLifecycleDefinitions.PartnerCenterPermitNo,
|
||||
EnamadCode = DemoLifecycleDefinitions.PartnerCenterEnamad,
|
||||
SettlementIban = "IR120170000000555566667777", // encrypted at rest by the EF converter
|
||||
IsMerchantOfRecord = true,
|
||||
CommissionRate = 0.05m,
|
||||
AdminUserId = portalUser.Id
|
||||
};
|
||||
center.Verify(clock.UtcNow.AddDays(-30));
|
||||
db.Set<PartnerCenter>().Add(center);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Karimi becomes the center's sponsored nurse — his bookings' invoices resolve to the center as MoR.
|
||||
world.Nurses[1].Profile.PartnerCenterId = center.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return center.Id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Invoices — dispatched through the real IssueInvoice (sequential number, VAT-on-commission,
|
||||
// merchant-of-record resolution). Idempotent per booking on the real path.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureInvoicesAsync(BookingWorld bookings, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (key, booking) in bookings.ByKey)
|
||||
{
|
||||
var result = await sender.Send(new IssueInvoiceCommand(booking.Id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
logger.LogDebug("Demo lifecycle: invoice for booking scenario '{Key}' not issued (already issued or ineligible).", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpsNotification = Baya.Domain.Entities.Notifications.Notification;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
|
||||
internal sealed partial class DemoLifecycleSeeder
|
||||
{
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Reviews — one per moderation state, plus the recomputed-from-source nurse aggregates.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureReviewsAsync(
|
||||
PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var mohammadi = world.Customers[0];
|
||||
var hosseini = world.Customers[1];
|
||||
var azizi = world.Nurses[0];
|
||||
var karimi = world.Nurses[1];
|
||||
var seededAny = false;
|
||||
|
||||
// Published 5★ with tags — the public social proof on the paid-out booking.
|
||||
if (bookings.ByKey.TryGetValue("completed_paid", out var paid)
|
||||
&& !await db.Set<Review>().AnyAsync(r => r.BookingId == paid.Id, cancellationToken))
|
||||
{
|
||||
var review = new Review
|
||||
{
|
||||
BookingId = paid.Id,
|
||||
CustomerProfileId = mohammadi.ProfileId,
|
||||
NurseProfileId = azizi.Profile.Id,
|
||||
Rating = 5,
|
||||
Body = "بسیار دلسوز و حرفهای. سر وقت رسید و همهچیز را با حوصله توضیح داد."
|
||||
};
|
||||
review.Moderate(ReviewModerationStatus.Published, null, world.AdminUserId, now.AddDays(-6));
|
||||
db.Set<Review>().Add(review);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns review.Id for the tag links
|
||||
|
||||
var tagIds = await db.Set<ReviewTagMaster>()
|
||||
.Where(t => t.Code == ReviewTagCodes.Punctual || t.Code == ReviewTagCodes.Professional)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var tagId in tagIds)
|
||||
db.Set<ReviewTagLink>().Add(new ReviewTagLink { ReviewId = review.Id, ReviewTagMasterId = tagId });
|
||||
seededAny = true;
|
||||
}
|
||||
|
||||
// Pending moderation — populates the admin moderation queue; never publicly visible.
|
||||
if (bookings.ByKey.TryGetValue("completed_in_window", out var inWindow)
|
||||
&& !await db.Set<Review>().AnyAsync(r => r.BookingId == inWindow.Id, cancellationToken))
|
||||
{
|
||||
db.Set<Review>().Add(new Review
|
||||
{
|
||||
BookingId = inWindow.Id,
|
||||
CustomerProfileId = mohammadi.ProfileId,
|
||||
NurseProfileId = azizi.Profile.Id,
|
||||
Rating = 4,
|
||||
Body = "مراقبت خوب بود، فقط کمی دیر رسیدند."
|
||||
});
|
||||
seededAny = true;
|
||||
}
|
||||
|
||||
// Hidden 1★ — moderated away; excluded from the public list and the aggregate, feeds the
|
||||
// low-rating support alert.
|
||||
if (bookings.ByKey.TryGetValue("completed_eligible", out var eligible)
|
||||
&& !await db.Set<Review>().AnyAsync(r => r.BookingId == eligible.Id, cancellationToken))
|
||||
{
|
||||
var review = new Review
|
||||
{
|
||||
BookingId = eligible.Id,
|
||||
CustomerProfileId = hosseini.ProfileId,
|
||||
NurseProfileId = karimi.Profile.Id,
|
||||
Rating = 1,
|
||||
Body = "اصلاً راضی نبودم."
|
||||
};
|
||||
review.Moderate(ReviewModerationStatus.Hidden, "irrelevant_content", world.AdminUserId, now.AddDays(-4));
|
||||
db.Set<Review>().Add(review);
|
||||
seededAny = true;
|
||||
}
|
||||
|
||||
if (seededAny)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Aggregates recomputed from source, mirroring RecomputeNurseRating's published-only rule and the
|
||||
// completed-bookings count — never free-entered numbers.
|
||||
foreach (var nurse in new[] { azizi, karimi })
|
||||
{
|
||||
var published = await db.Set<Review>()
|
||||
.Where(r => r.NurseProfileId == nurse.Profile.Id && r.ModerationStatus == ReviewModerationStatus.Published)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Count = g.Count(), Sum = g.Sum(r => r.Rating) })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
nurse.Profile.SetReviewAggregates(
|
||||
published is null ? 0m : Math.Round((decimal)published.Sum / published.Count, 2, MidpointRounding.AwayFromZero),
|
||||
published?.Count ?? 0);
|
||||
|
||||
var completed = await db.Set<Booking>()
|
||||
.CountAsync(b => b.NurseId == nurse.Profile.Id && b.Status == BookingStatus.Completed, cancellationToken);
|
||||
nurse.Profile.SetCompletedBookings(completed);
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await EnsureSupportAlertsAsync(bookings, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The staff worklist rows real usage would have raised: the low rating, the out-of-tolerance
|
||||
/// EVV check-in, the clawback receivable, and the logged emergency.</summary>
|
||||
private async Task EnsureSupportAlertsAsync(BookingWorld bookings, CancellationToken cancellationToken)
|
||||
{
|
||||
var seededAny = false;
|
||||
|
||||
async Task EnsureAlertAsync(string type, string severity, string entityType, long entityId, long? bookingId, long? reviewId)
|
||||
{
|
||||
if (await db.Set<SupportAlert>().AnyAsync(a => a.Type == type && a.BookingId == bookingId, cancellationToken))
|
||||
return;
|
||||
db.Set<SupportAlert>().Add(new SupportAlert
|
||||
{
|
||||
Type = type,
|
||||
Severity = severity,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId.ToString(),
|
||||
BookingId = bookingId,
|
||||
ReviewId = reviewId
|
||||
});
|
||||
seededAny = true;
|
||||
}
|
||||
|
||||
if (bookings.ByKey.TryGetValue("completed_eligible", out var eligible))
|
||||
{
|
||||
var review = await db.Set<Review>()
|
||||
.Where(r => r.BookingId == eligible.Id)
|
||||
.Select(r => (long?)r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (review is { } reviewId)
|
||||
await EnsureAlertAsync(SupportAlertType.LowRating, SupportAlertSeverity.Medium, "review", reviewId, eligible.Id, reviewId);
|
||||
await EnsureAlertAsync(SupportAlertType.EvvLocationMismatch, SupportAlertSeverity.Medium, "booking", eligible.Id, eligible.Id, null);
|
||||
}
|
||||
|
||||
if (bookings.ByKey.TryGetValue("clawback", out var clawed))
|
||||
await EnsureAlertAsync(SupportAlertType.NurseClawback, SupportAlertSeverity.High, "booking", clawed.Id, clawed.Id, null);
|
||||
|
||||
if (bookings.ByKey.TryGetValue("in_progress", out var inProgress))
|
||||
await EnsureAlertAsync(SupportAlertType.Emergency, SupportAlertSeverity.High, "booking", inProgress.Id, inProgress.Id, null);
|
||||
|
||||
if (seededAny)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Tickets — coordination via the real command; support/emergency threads hand-built (fixed reference
|
||||
// codes double as the idempotency guards). The internal admin note proves the is_internal boundary.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureTicketsAsync(
|
||||
PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (key, booking) in bookings.ByKey)
|
||||
{
|
||||
var result = await sender.Send(new AutoCreateCoordinationTicketCommand(booking.Id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
logger.LogDebug("Demo lifecycle: coordination ticket for '{Key}' not created.", key);
|
||||
}
|
||||
|
||||
var mohammadi = world.Customers[0];
|
||||
var hosseini = world.Customers[1];
|
||||
var admin = world.AdminUserId;
|
||||
|
||||
// Open support thread with an is_internal admin note the user views must never surface.
|
||||
if (!await db.Set<Ticket>().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketOpen, cancellationToken))
|
||||
{
|
||||
var openedAt = clock.UtcNow.AddDays(-2);
|
||||
db.Set<Ticket>().Add(new Ticket
|
||||
{
|
||||
ReferenceCode = DemoLifecycleDefinitions.SupportTicketOpen,
|
||||
Subject = "فاکتور رزرو نمایش داده نمیشود",
|
||||
Category = TicketCategory.Support,
|
||||
OpenedById = mohammadi.UserId,
|
||||
Participants =
|
||||
[
|
||||
new TicketParticipant { UserId = mohammadi.UserId, RoleOnTicket = TicketParticipantRole.Customer },
|
||||
new TicketParticipant { UserId = admin, RoleOnTicket = TicketParticipantRole.Admin, AddedById = admin }
|
||||
],
|
||||
Messages =
|
||||
[
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = mohammadi.UserId,
|
||||
Body = "سلام، فاکتور رزرو اخیرم در بخش کیف پول باز نمیشود.",
|
||||
SentAt = openedAt
|
||||
},
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = admin,
|
||||
IsInternal = true,
|
||||
Body = "یادداشت داخلی: مشکل از سمت صدور فاکتور بود؛ صف مودیان بررسی شود.",
|
||||
SentAt = openedAt.AddHours(1)
|
||||
},
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = admin,
|
||||
Body = "سلام! مشکل بررسی شد و فاکتور شما اکنون قابل مشاهده است. ممنون از صبوریتان.",
|
||||
SentAt = openedAt.AddHours(2)
|
||||
},
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = mohammadi.UserId,
|
||||
Body = "ممنون، حل شد. 🌸",
|
||||
SentAt = openedAt.AddDays(1)
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Closed support thread — the inbox's closed filter and the admin close/reopen actions.
|
||||
if (!await db.Set<Ticket>().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketClosed, cancellationToken))
|
||||
{
|
||||
var openedAt = clock.UtcNow.AddDays(-5);
|
||||
var ticket = new Ticket
|
||||
{
|
||||
ReferenceCode = DemoLifecycleDefinitions.SupportTicketClosed,
|
||||
Subject = "سوال درباره نحوه تغییر شماره تماس",
|
||||
Category = TicketCategory.Support,
|
||||
OpenedById = hosseini.UserId,
|
||||
Participants =
|
||||
[
|
||||
new TicketParticipant { UserId = hosseini.UserId, RoleOnTicket = TicketParticipantRole.Customer },
|
||||
new TicketParticipant { UserId = admin, RoleOnTicket = TicketParticipantRole.Admin, AddedById = admin }
|
||||
],
|
||||
Messages =
|
||||
[
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = hosseini.UserId,
|
||||
Body = "چطور میتوانم شماره موبایل حسابم را عوض کنم؟",
|
||||
SentAt = openedAt
|
||||
},
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = admin,
|
||||
Body = "در حال حاضر تغییر شماره از طریق پشتیبانی انجام میشود؛ مدارک هویتی لازم است.",
|
||||
SentAt = openedAt.AddHours(3)
|
||||
}
|
||||
]
|
||||
};
|
||||
ticket.Close(admin, clock.UtcNow.AddDays(-3));
|
||||
db.Set<Ticket>().Add(ticket);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Emergency aftermath thread (logged by the nurse mid-visit) — the admin queue's red row.
|
||||
if (bookings.ByKey.TryGetValue("in_progress", out var inProgress)
|
||||
&& !await db.Set<Ticket>().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.EmergencyTicket, cancellationToken))
|
||||
{
|
||||
var azizi = world.Nurses[0];
|
||||
db.Set<Ticket>().Add(new Ticket
|
||||
{
|
||||
ReferenceCode = DemoLifecycleDefinitions.EmergencyTicket,
|
||||
Subject = "گزارش تماس اضطراری حین ویزیت",
|
||||
Category = TicketCategory.Emergency,
|
||||
BookingId = inProgress.Id,
|
||||
OpenedById = azizi.UserId,
|
||||
Participants =
|
||||
[
|
||||
new TicketParticipant { UserId = azizi.UserId, RoleOnTicket = TicketParticipantRole.Nurse },
|
||||
new TicketParticipant { UserId = admin, RoleOnTicket = TicketParticipantRole.Admin, AddedById = admin }
|
||||
],
|
||||
Messages =
|
||||
[
|
||||
new TicketMessage
|
||||
{
|
||||
SenderId = azizi.UserId,
|
||||
Body = "حین ویزیت افت فشار ناگهانی داشتیم؛ با اورژانس تماس گرفتم و وضعیت پایدار شد. خانواده در جریان است.",
|
||||
SentAt = clock.UtcNow.AddHours(-1)
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Patient care records — the encrypted, patient-scoped longitudinal history (handler-side encryption
|
||||
// by design: no EF converter on body_encrypted).
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureCareRecordsAsync(
|
||||
PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var seededAny = false;
|
||||
|
||||
async Task EnsureRecordAsync(long patientId, long? bookingId, long nurseProfileId, string body, string? tasksJson, DateTime recordedAt)
|
||||
{
|
||||
if (await db.Set<PatientCareRecord>().AnyAsync(
|
||||
r => r.PatientId == patientId && r.BookingId == bookingId, cancellationToken))
|
||||
return;
|
||||
db.Set<PatientCareRecord>().Add(new PatientCareRecord
|
||||
{
|
||||
PatientId = patientId,
|
||||
BookingId = bookingId,
|
||||
NurseProfileId = nurseProfileId,
|
||||
BodyEncrypted = encryptor.Encrypt(body),
|
||||
TaskResultsJson = tasksJson,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
seededAny = true;
|
||||
}
|
||||
|
||||
var aziziProfileId = world.Nurses[0].Profile.Id;
|
||||
var karimiProfileId = world.Nurses[1].Profile.Id;
|
||||
var patientHasan = world.Customers[0].PatientIds[0];
|
||||
var patientAmirali = world.Customers[1].PatientIds[0];
|
||||
|
||||
if (bookings.ByKey.TryGetValue("completed_paid", out var paid))
|
||||
await EnsureRecordAsync(
|
||||
patientHasan, paid.Id, aziziProfileId,
|
||||
"علائم حیاتی پایدار. قند خون ناشتا ۱۱۰. داروها طبق برنامه مصرف شد؛ اشتها خوب بود.",
|
||||
"[{\"label\":\"کنترل قند خون\",\"done\":true},{\"label\":\"دادن داروهای صبح\",\"done\":true}]",
|
||||
now.AddDays(-10).AddHours(4));
|
||||
|
||||
if (bookings.ByKey.TryGetValue("completed_in_window", out var inWindow))
|
||||
await EnsureRecordAsync(
|
||||
patientHasan, inWindow.Id, aziziProfileId,
|
||||
"فشار خون ۱۴ روی ۹. کمی بیقراری عصر داشتند که با پیادهروی کوتاه بهتر شد. زخم پا رو به بهبود است.",
|
||||
"[{\"label\":\"پانسمان زخم پا\",\"done\":true},{\"label\":\"کنترل فشار خون\",\"done\":true},{\"label\":\"پیادهروی عصر\",\"done\":false}]",
|
||||
now.AddDays(-1).AddHours(4));
|
||||
|
||||
if (bookings.ByKey.TryGetValue("bnpl_settled", out var bnplBooking))
|
||||
await EnsureRecordAsync(
|
||||
patientAmirali, bnplBooking.Id, karimiProfileId,
|
||||
"وضعیت عمومی نوزاد خوب. تغذیه منظم انجام شد و دمای بدن طبیعی بود.",
|
||||
"[{\"label\":\"پایش دمای بدن\",\"done\":true}]",
|
||||
now.AddDays(-6).AddHours(3));
|
||||
|
||||
if (seededAny)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Mid-pipeline verification for the unverified nurse — feeds the nurse journey (B3–B6) and the admin
|
||||
// verification queue. is_verified is deliberately never touched (status != approved).
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureVerificationPipelineAsync(PersonaWorld world, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var ahmadi = world.Nurses[2];
|
||||
var verification = await db.Set<NurseVerification>()
|
||||
.FirstOrDefaultAsync(v => v.NurseId == ahmadi.Profile.Id, cancellationToken);
|
||||
if (verification is null)
|
||||
return;
|
||||
if (await db.Set<VerificationStep>().AnyAsync(s => s.NurseVerificationId == verification.Id, cancellationToken))
|
||||
return;
|
||||
|
||||
verification.Status = VerificationStatus.InReview;
|
||||
verification.SubmittedAt = clock.UtcNow.AddDays(-3);
|
||||
|
||||
var stepTypes = await db.Set<VerificationStepType>()
|
||||
.Where(t => t.IsActive)
|
||||
.ToDictionaryAsync(t => t.Code, cancellationToken);
|
||||
|
||||
VerificationStep AddStep(string code, VerificationStepStatus status, bool automated, string? responseJson)
|
||||
{
|
||||
var step = new VerificationStep
|
||||
{
|
||||
NurseVerificationId = verification.Id,
|
||||
StepTypeId = stepTypes[code].Id,
|
||||
Status = status,
|
||||
IsAutomated = automated,
|
||||
ExternalResponseJson = responseJson,
|
||||
StartedAt = clock.UtcNow.AddDays(-3),
|
||||
CompletedAt = status is VerificationStepStatus.Passed ? clock.UtcNow.AddDays(-3).AddMinutes(5) : null
|
||||
};
|
||||
db.Set<VerificationStep>().Add(step);
|
||||
return step;
|
||||
}
|
||||
|
||||
AddStep(VerificationStepTypeCodes.IdentityKyc, VerificationStepStatus.Passed, automated: true,
|
||||
"{\"source\":\"demo-lifecycle-seed\",\"match\":true,\"confidence\":0.97}");
|
||||
AddStep(VerificationStepTypeCodes.ShahkarMatch, VerificationStepStatus.Passed, automated: true,
|
||||
"{\"source\":\"demo-lifecycle-seed\",\"simOwnerMatch\":true}");
|
||||
var licenseStep = AddStep(VerificationStepTypeCodes.MohCompetencyLicense, VerificationStepStatus.InReview, automated: false, null);
|
||||
AddStep(VerificationStepTypeCodes.CriminalRecord, VerificationStepStatus.Pending, automated: false, null);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns step ids for the document FK
|
||||
|
||||
// The uploaded evidence file's metadata. The object-storage bytes deliberately don't exist — the
|
||||
// admin viewer's signed-URL fetch will 404, which is acceptable for a dev DB (never fake bytes).
|
||||
db.Set<VerificationDocument>().Add(new VerificationDocument
|
||||
{
|
||||
StepId = licenseStep.Id,
|
||||
ObjectStorageKey = "verification/demo/ahmadi-moh-license.jpg",
|
||||
IntegrityHash = "demo-integrity-hash",
|
||||
ContentType = "image/jpeg",
|
||||
FileSizeBytes = 245_000,
|
||||
OriginalFileName = "moh-license.jpg",
|
||||
UploadedByUserId = ahmadi.UserId
|
||||
});
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Notifications — direct ops rows with backdated CreatedAt, copying the REAL handlers' type/DataJson
|
||||
// contract so the front-end deep-links resolve. (The real emitters write English copy; mirrored as-is.)
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task EnsureNotificationsAsync(
|
||||
PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var azizi = world.Nurses[0];
|
||||
var karimi = world.Nurses[1];
|
||||
var mohammadi = world.Customers[0];
|
||||
|
||||
if (await db.Set<OpsNotification>().AnyAsync(
|
||||
n => n.UserId == azizi.UserId && n.Type == "booking_request_received", cancellationToken))
|
||||
return;
|
||||
|
||||
var pendingRequestId = await db.Set<BookingRequest>()
|
||||
.Where(r => r.Status == BookingRequestStatus.PendingNurseResponse && r.NurseId == azizi.Profile.Id)
|
||||
.Select(r => (long?)r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var acceptedRequest = await db.Set<BookingRequest>()
|
||||
.Where(r => r.Status == BookingRequestStatus.AcceptedAwaitingPayment && r.CustomerId == mohammadi.ProfileId)
|
||||
.Select(r => new { r.Id, r.PaymentDeadlineAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var expiredRequestId = await db.Set<BookingRequest>()
|
||||
.Where(r => r.Status == BookingRequestStatus.ExpiredNoResponse && r.NurseId == karimi.Profile.Id)
|
||||
.Select(r => (long?)r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var publishedReviewId = bookings.ByKey.TryGetValue("completed_paid", out var paid)
|
||||
? await db.Set<Review>().Where(r => r.BookingId == paid.Id).Select(r => (long?)r.Id).FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
var upcomingId = bookings.ByKey.TryGetValue("upcoming", out var upcoming) ? upcoming.Id : (long?)null;
|
||||
|
||||
void Add(int userId, string type, string title, string body, string dataJson, DateTime createdAt, bool read)
|
||||
=> db.Set<OpsNotification>().Add(new OpsNotification
|
||||
{
|
||||
UserId = userId,
|
||||
Type = type,
|
||||
Title = title,
|
||||
Body = body,
|
||||
DataJson = dataJson,
|
||||
IsRead = read,
|
||||
ReadAt = read ? new DateTimeOffset(createdAt.AddMinutes(30), TimeSpan.Zero) : null,
|
||||
CreatedAt = new DateTimeOffset(createdAt, TimeSpan.Zero)
|
||||
});
|
||||
|
||||
if (pendingRequestId is { } pendingId)
|
||||
Add(azizi.UserId, "booking_request_received", "New booking request",
|
||||
"You have a new booking request awaiting your response.",
|
||||
$"{{\"booking_request_id\":{pendingId}}}", now.AddHours(-2), read: false);
|
||||
|
||||
if (upcomingId is { } bookingId)
|
||||
{
|
||||
Add(mohammadi.UserId, "booking_confirmed", "Booking confirmed",
|
||||
"Your payment was captured and your booking is confirmed.",
|
||||
$"{{\"booking_id\":{bookingId}}}", now.AddDays(-1), read: true);
|
||||
Add(azizi.UserId, "booking_confirmed_nurse", "New confirmed booking",
|
||||
"A booking has been confirmed and paid. The care instructions and schedule are now available.",
|
||||
$"{{\"booking_id\":{bookingId}}}", now.AddDays(-1), read: true);
|
||||
}
|
||||
|
||||
if (acceptedRequest is not null)
|
||||
Add(mohammadi.UserId, "booking_request_accepted", "Booking request accepted",
|
||||
"The nurse accepted your request. Pay within the payment window to confirm your booking.",
|
||||
$"{{\"booking_request_id\":{acceptedRequest.Id},\"payment_deadline_at\":\"{acceptedRequest.PaymentDeadlineAt:O}\"}}",
|
||||
now.AddHours(-1), read: false);
|
||||
|
||||
if (publishedReviewId is { } reviewId)
|
||||
Add(mohammadi.UserId, "review_moderated", "Your review was updated",
|
||||
"Your review is now published.",
|
||||
$"{{\"reviewId\":{reviewId},\"status\":\"published\"}}", now.AddDays(-6), read: true);
|
||||
|
||||
if (expiredRequestId is { } expiredId)
|
||||
Add(karimi.UserId, "booking_request_received", "New booking request",
|
||||
"You have a new booking request awaiting your response.",
|
||||
$"{{\"booking_request_id\":{expiredId}}}", now.AddDays(-3), read: true);
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Features.Bookings;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// Development-only seeder that layers a full <b>lifecycle</b> demo world on top of the
|
||||
/// <see cref="DemoWorldSeeder"/> personas: booking requests in every status, bookings in every reachable
|
||||
/// state (upcoming, mid-engagement with EVV, completed inside/past the dispute window, BNPL-settled,
|
||||
/// cancelled-with-refund, clawed-back), the balanced payment ledger behind each, refunds on every channel,
|
||||
/// a paid and a draft payout batch, moderated reviews, tickets (including an admin-internal note),
|
||||
/// notifications, patient care records, a partner center, and a mid-pipeline verification case.
|
||||
/// <para>
|
||||
/// Every state is reached through the entities' <b>guarded transition methods</b> and the real money
|
||||
/// helpers (<see cref="BookingFactory"/>, <see cref="LedgerPosting"/>), never by raw status assignment, so
|
||||
/// the seeded world satisfies the same invariants real usage produces. Payout batches and coordination
|
||||
/// tickets are produced by dispatching the <b>real Application commands</b>. Timestamps that carry business
|
||||
/// meaning are backdated explicitly (they are method arguments/settable by design); <c>CreatedAt</c> audit
|
||||
/// stamps stay at seed time, which no flow depends on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotent per scenario: each aggregate is guarded on a natural key (the request's
|
||||
/// customer/nurse/variant/date tuple, a ticket's fixed reference code, the partner permit number…), so
|
||||
/// re-running on an already-seeded DB is a no-op. Callers must gate this on <c>IsDevelopment()</c> and run
|
||||
/// it <b>after</b> <see cref="DemoWorldSeeder"/> and the payment-gateway seed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed partial class DemoLifecycleSeeder(
|
||||
ApplicationDbContext db,
|
||||
IAppUserManager userManager,
|
||||
ISender sender,
|
||||
IPlatformConfig platformConfig,
|
||||
IVariantSnapshotSerializer variantSerializer,
|
||||
ISearchIndexMaintainer searchIndex,
|
||||
IFieldEncryptor encryptor,
|
||||
IDateTimeProvider clock,
|
||||
ILogger<DemoLifecycleSeeder> logger)
|
||||
{
|
||||
public async Task SeedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var world = await LoadWorldAsync(cancellationToken);
|
||||
if (world is null)
|
||||
{
|
||||
logger.LogWarning("Demo lifecycle seed skipped — the base demo world (personas/variants) is not present.");
|
||||
return;
|
||||
}
|
||||
|
||||
var gateway = await db.Set<PaymentGateway>()
|
||||
.FirstOrDefaultAsync(g => g.Type == PaymentGatewayType.Standard, cancellationToken);
|
||||
if (gateway is null)
|
||||
{
|
||||
logger.LogWarning("Demo lifecycle seed skipped — no standard payment gateway row (run SeedPaymentGatewaysAsync first).");
|
||||
return;
|
||||
}
|
||||
|
||||
var now = clock.UtcNow.UtcDateTime;
|
||||
|
||||
var packageVariantId = await EnsurePackageVariantAsync(world, cancellationToken);
|
||||
var newRequests = await EnsureRequestScenariosAsync(world, now, cancellationToken);
|
||||
var bookings = await EnsureBookingScenariosAsync(world, packageVariantId, gateway.Id, now, cancellationToken);
|
||||
|
||||
var centerId = await EnsurePartnerCenterAsync(world, cancellationToken);
|
||||
await EnsureBnplAsync(bookings, now, cancellationToken);
|
||||
|
||||
// Order is load-bearing (the world must be *reachable*, not merely constraint-satisfying):
|
||||
// the paid batch runs BEFORE the refunds exist (so the clawback booking was genuinely paid out first),
|
||||
// and the draft batch runs AFTER them (so the BNPL-refunded booking is genuinely excluded).
|
||||
await EnsurePaidPayoutBatchAsync(bookings, now, cancellationToken);
|
||||
await EnsureRefundsAsync(world, bookings, now, cancellationToken);
|
||||
await EnsureDraftPayoutBatchAsync(bookings, now, cancellationToken);
|
||||
|
||||
await EnsureReviewsAsync(world, bookings, now, cancellationToken);
|
||||
await EnsureInvoicesAsync(bookings, cancellationToken);
|
||||
await EnsureTicketsAsync(world, bookings, now, cancellationToken);
|
||||
await EnsureCareRecordsAsync(world, bookings, now, cancellationToken);
|
||||
await EnsureVerificationPipelineAsync(world, now, cancellationToken);
|
||||
await EnsureNotificationsAsync(world, bookings, now, cancellationToken);
|
||||
|
||||
// Re-derive the search projection last so the seeded rating/completed aggregates reach the index.
|
||||
var rebuild = await searchIndex.RebuildAsync(cancellationToken);
|
||||
|
||||
if (newRequests == 0 && bookings.CreatedCount == 0)
|
||||
logger.LogInformation(
|
||||
"Demo lifecycle already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.",
|
||||
rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
else
|
||||
logger.LogInformation(
|
||||
"Demo lifecycle seeded: {Requests} request scenario(s), {Bookings} booking scenario(s), partner center #{Center}. " +
|
||||
"Search index: {Nurses} nurses, {Rows} rows.",
|
||||
newRequests, bookings.CreatedCount, centerId, rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// World loading — resolve the base-seeder personas into ids. Null when the base world isn't there.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
internal sealed record CustomerWorld(
|
||||
int UserId, long ProfileId, IReadOnlyList<long> PatientIds, CustomerAddress PrimaryAddress);
|
||||
|
||||
internal sealed record NurseWorld(
|
||||
int UserId, NurseProfile Profile, IReadOnlyList<NurseServiceVariant> Variants, string Gender);
|
||||
|
||||
internal sealed record PersonaWorld(
|
||||
IReadOnlyList<CustomerWorld> Customers, IReadOnlyList<NurseWorld> Nurses, int AdminUserId);
|
||||
|
||||
private async Task<PersonaWorld?> LoadWorldAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var customers = new List<CustomerWorld>();
|
||||
foreach (var persona in DemoWorldDefinitions.Customers)
|
||||
{
|
||||
var user = await userManager.GetUserByPhoneNumber(persona.Phone);
|
||||
if (user is null)
|
||||
return null;
|
||||
|
||||
var profile = await db.Set<CustomerProfile>()
|
||||
.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
|
||||
if (profile is null)
|
||||
return null;
|
||||
|
||||
var patients = await db.Set<Patient>()
|
||||
.Where(p => p.CustomerId == profile.Id)
|
||||
.OrderBy(p => p.Id)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var address = await db.Set<CustomerAddress>()
|
||||
.FirstOrDefaultAsync(a => a.CustomerId == profile.Id && a.IsPrimary, cancellationToken);
|
||||
if (patients.Count == 0 || address is null)
|
||||
return null;
|
||||
|
||||
customers.Add(new CustomerWorld(user.Id, profile.Id, patients, address));
|
||||
}
|
||||
|
||||
var nurses = new List<NurseWorld>();
|
||||
foreach (var persona in DemoWorldDefinitions.Nurses)
|
||||
{
|
||||
var user = await userManager.GetUserByPhoneNumber(persona.Phone);
|
||||
if (user is null)
|
||||
return null;
|
||||
|
||||
// Tracked on purpose: the reviews step mutates the aggregates through the guarded setters.
|
||||
var profile = await db.Set<NurseProfile>()
|
||||
.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
|
||||
if (profile is null)
|
||||
return null;
|
||||
|
||||
var variants = await db.Set<NurseServiceVariant>()
|
||||
.Include(v => v.Options)
|
||||
.Where(v => v.NurseId == profile.Id)
|
||||
.OrderBy(v => v.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
nurses.Add(new NurseWorld(user.Id, profile, variants, persona.Gender));
|
||||
}
|
||||
|
||||
var admin = await userManager.GetUserByPhoneNumber(DemoWorldDefinitions.Admins[0].Phone);
|
||||
if (admin is null)
|
||||
return null;
|
||||
|
||||
return new PersonaWorld(customers, nurses, admin.Id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// The 5-session package variant (the multi-session engagement source).
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task<long> EnsurePackageVariantAsync(PersonaWorld world, CancellationToken cancellationToken)
|
||||
{
|
||||
var azizi = world.Nurses[0];
|
||||
|
||||
var shiftGroup = await db.Set<ServiceOptionGroup>()
|
||||
.Include(g => g.Values)
|
||||
.FirstAsync(g => g.ServiceCategoryId == null && g.NameEn == DemoWorldDefinitions.ShiftGroupNameEn, cancellationToken);
|
||||
var nightValueId = shiftGroup.Values.First(v => v.NameEn == DemoWorldDefinitions.ShiftNight).Id;
|
||||
|
||||
// Guarded on the duplicate-listing UNIQUE (nurse × category × option-set hash), not the display
|
||||
// name: the DbContext save hook normalizes Persian digits/ZWNJ in every stored string, so a Persian
|
||||
// literal never round-trips equal to what was persisted.
|
||||
var optionSetHash = OptionSetHash.Compute([(shiftGroup.Id, nightValueId)]);
|
||||
var existing = await db.Set<NurseServiceVariant>()
|
||||
.Where(v => v.NurseId == azizi.Profile.Id
|
||||
&& v.ServiceCategoryId == DemoWorldDefinitions.PostSurgery
|
||||
&& v.OptionSetHash == optionSetHash)
|
||||
.Select(v => v.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing != 0)
|
||||
return existing;
|
||||
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = azizi.Profile.Id,
|
||||
ServiceCategoryId = DemoWorldDefinitions.PostSurgery,
|
||||
Price = DemoLifecycleDefinitions.PackageVariantPricePerSession,
|
||||
PriceUnit = PriceUnits.PerSession,
|
||||
SessionCount = DemoLifecycleDefinitions.PackageVariantSessionCount,
|
||||
DisplayName = DemoLifecycleDefinitions.PackageVariantDisplayName,
|
||||
OptionSetHash = optionSetHash,
|
||||
IsActive = true,
|
||||
Options = new List<NurseServiceVariantOption>
|
||||
{
|
||||
new() { OptionGroupId = shiftGroup.Id, OptionValueId = nightValueId }
|
||||
}
|
||||
};
|
||||
|
||||
db.Set<NurseServiceVariant>().Add(variant);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return variant.Id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Request-only scenarios: pending / accepted-awaiting-payment / rejected / expired / cancelled.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task<int> EnsureRequestScenariosAsync(PersonaWorld world, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var mohammadi = world.Customers[0];
|
||||
var hosseini = world.Customers[1];
|
||||
var azizi = world.Nurses[0];
|
||||
var karimi = world.Nurses[1];
|
||||
|
||||
var created = 0;
|
||||
|
||||
// Pending: the nurse inbox's live countdown + the customer's C5 tracker. Deadline must be in the
|
||||
// future or the 1-minute booking_request_expiry job sweeps it before anyone can look at it.
|
||||
created += await EnsureRequestAsync(
|
||||
mohammadi, azizi, azizi.Variants[1].Id, mohammadi.PatientIds[0],
|
||||
requestedDate: Today(now, 5), timeStart: new TimeOnly(9, 0), timeEnd: new TimeOnly(13, 0),
|
||||
notes: "پدرم به کمک برای جابهجایی و مصرف دارو نیاز دارد.",
|
||||
deadline: now.AddHours(24), configure: null, cancellationToken);
|
||||
|
||||
// Accepted, awaiting payment: the checkout entry point. The real handler computes a 30-minute
|
||||
// window; the demo freezes 24h so the scenario survives longer than half an hour of testing.
|
||||
created += await EnsureRequestAsync(
|
||||
mohammadi, azizi, azizi.Variants[0].Id, mohammadi.PatientIds[0],
|
||||
requestedDate: Today(now, 7), timeStart: new TimeOnly(8, 0), timeEnd: new TimeOnly(8, 0),
|
||||
notes: "مراقبت شبانهروزی برای دوران نقاهت.",
|
||||
deadline: now.AddHours(24),
|
||||
configure: r => r.Accept(paymentDeadlineAt: now.AddHours(24)), cancellationToken);
|
||||
|
||||
// Rejected with a schedule-conflict reason (the C5 re-request off-ramp keys off this wording).
|
||||
created += await EnsureRequestAsync(
|
||||
mohammadi, azizi, azizi.Variants[2].Id, mohammadi.PatientIds[1],
|
||||
requestedDate: Today(now, 1), timeStart: new TimeOnly(10, 0), timeEnd: new TimeOnly(18, 0),
|
||||
notes: "مراقبت روزانه بعد از عمل زانو.",
|
||||
deadline: now.AddHours(24),
|
||||
configure: r => r.Reject("زمان درخواستی با برنامه من تداخل دارد."), cancellationToken);
|
||||
|
||||
// Expired: no nurse response before the (backdated) deadline.
|
||||
created += await EnsureRequestAsync(
|
||||
hosseini, karimi, karimi.Variants[0].Id, hosseini.PatientIds[0],
|
||||
requestedDate: Today(now, 1), timeStart: new TimeOnly(9, 0), timeEnd: new TimeOnly(17, 0),
|
||||
notes: "مراقبت روزانه در منزل.",
|
||||
deadline: now.AddDays(-2),
|
||||
configure: r => r.ExpireNoResponse(), cancellationToken);
|
||||
|
||||
// Cancelled by the customer before any response.
|
||||
created += await EnsureRequestAsync(
|
||||
hosseini, karimi, karimi.Variants[1].Id, hosseini.PatientIds[0],
|
||||
requestedDate: Today(now, 4), timeStart: new TimeOnly(8, 0), timeEnd: new TimeOnly(8, 0),
|
||||
notes: "لطفاً نادیده بگیرید — برنامه تغییر کرد.",
|
||||
deadline: now.AddHours(24),
|
||||
configure: r => r.CancelByCustomer(), cancellationToken);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> EnsureRequestAsync(
|
||||
CustomerWorld customer,
|
||||
NurseWorld nurse,
|
||||
long variantId,
|
||||
long patientId,
|
||||
DateOnly requestedDate,
|
||||
TimeOnly timeStart,
|
||||
TimeOnly timeEnd,
|
||||
string notes,
|
||||
DateTime deadline,
|
||||
Action<BookingRequest>? configure,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await RequestExistsAsync(customer.ProfileId, nurse.Profile.Id, variantId, requestedDate, cancellationToken))
|
||||
return 0;
|
||||
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = customer.ProfileId,
|
||||
NurseId = nurse.Profile.Id,
|
||||
PatientId = patientId,
|
||||
VariantId = variantId,
|
||||
CustomerAddressId = customer.PrimaryAddress.Id,
|
||||
RequiredCaregiverGender = nurse.Gender,
|
||||
RequestedDate = requestedDate,
|
||||
RequestedTimeStart = timeStart,
|
||||
RequestedTimeEnd = timeEnd,
|
||||
CustomerNotes = notes,
|
||||
NurseResponseDeadlineAt = deadline
|
||||
};
|
||||
configure?.Invoke(request);
|
||||
|
||||
db.Set<BookingRequest>().Add(request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private Task<bool> RequestExistsAsync(
|
||||
long customerId, long nurseId, long variantId, DateOnly requestedDate, CancellationToken cancellationToken)
|
||||
=> db.Set<BookingRequest>().AnyAsync(
|
||||
r => r.CustomerId == customerId && r.NurseId == nurseId
|
||||
&& r.VariantId == variantId && r.RequestedDate == requestedDate,
|
||||
cancellationToken);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Booking scenarios — request → (real factory) booking → sessions/EVV → payment txn → balanced ledger.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>The seeded (or found) bookings by scenario key, plus how many were newly created.</summary>
|
||||
internal sealed class BookingWorld
|
||||
{
|
||||
public Dictionary<string, BookingEntity> ByKey { get; } = [];
|
||||
public Dictionary<string, PaymentTransaction> TxnByKey { get; } = [];
|
||||
public int CreatedCount { get; set; }
|
||||
}
|
||||
|
||||
private async Task<BookingWorld> EnsureBookingScenariosAsync(
|
||||
PersonaWorld world, long packageVariantId, long gatewayId, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new BookingWorld();
|
||||
var sequence = 0;
|
||||
|
||||
foreach (var scenario in DemoLifecycleDefinitions.Bookings)
|
||||
{
|
||||
sequence++;
|
||||
var customer = world.Customers[scenario.Customer];
|
||||
var nurse = world.Nurses[scenario.Nurse];
|
||||
var variant = scenario.VariantIndex >= 0
|
||||
? nurse.Variants[scenario.VariantIndex]
|
||||
: await db.Set<NurseServiceVariant>().Include(v => v.Options)
|
||||
.FirstAsync(v => v.Id == packageVariantId, cancellationToken);
|
||||
|
||||
var requestedDate = Today(now, scenario.ScheduledInDays);
|
||||
|
||||
var existing = await db.Set<BookingRequest>()
|
||||
.Where(r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id
|
||||
&& r.VariantId == variant.Id && r.RequestedDate == requestedDate)
|
||||
.Select(r => r.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing != 0)
|
||||
{
|
||||
var found = await db.Set<BookingEntity>().Include(b => b.Sessions)
|
||||
.FirstAsync(b => b.BookingRequestId == existing, cancellationToken);
|
||||
var foundTxn = await db.Set<PaymentTransaction>()
|
||||
.FirstAsync(t => t.BookingId == found.Id && t.Status == PaymentTransactionStatus.Succeeded, cancellationToken);
|
||||
result.ByKey[scenario.Key] = found;
|
||||
result.TxnByKey[scenario.Key] = foundTxn;
|
||||
continue;
|
||||
}
|
||||
|
||||
var confirmedAt = now.AddDays(-scenario.ConfirmedDaysAgo);
|
||||
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = customer.ProfileId,
|
||||
NurseId = nurse.Profile.Id,
|
||||
PatientId = customer.PatientIds[0],
|
||||
VariantId = variant.Id,
|
||||
CustomerAddressId = customer.PrimaryAddress.Id,
|
||||
RequiredCaregiverGender = nurse.Gender,
|
||||
RequestedDate = requestedDate,
|
||||
RequestedTimeStart = new TimeOnly(9, 0),
|
||||
RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "هماهنگی از طریق پیام انجام شد.",
|
||||
NurseResponseDeadlineAt = confirmedAt
|
||||
};
|
||||
request.Accept(paymentDeadlineAt: confirmedAt);
|
||||
db.Set<BookingRequest>().Add(request);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK
|
||||
|
||||
var booking = BookingFactory.Create(
|
||||
await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken),
|
||||
rate: await GetFeeRateAsync(cancellationToken),
|
||||
now: confirmedAt,
|
||||
pspFeeAmount: null,
|
||||
variantSerializer);
|
||||
request.MarkConverted();
|
||||
|
||||
ApplyLifecycleState(booking, scenario, now);
|
||||
db.Set<BookingEntity>().Add(booking);
|
||||
await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs
|
||||
|
||||
SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now);
|
||||
|
||||
if (scenario.WithCareInstructions)
|
||||
db.Set<BookingCareInstruction>().Add(BuildCareInstructions(booking.Id));
|
||||
|
||||
// The txn commits first so the ledger legs can reference its real id (append-only source refs).
|
||||
var txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence);
|
||||
db.Set<PaymentTransaction>().Add(txn);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Card bookings post the balanced capture group now; the BNPL booking posts BnplSettle in the
|
||||
// money step (its group references the bnpl_transactions row, which doesn't exist yet).
|
||||
if (scenario.Key != "bnpl_settled")
|
||||
{
|
||||
db.Set<LedgerEntry>().AddRange(LedgerPosting.CardCapture(
|
||||
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr,
|
||||
booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
result.ByKey[scenario.Key] = booking;
|
||||
result.TxnByKey[scenario.Key] = txn;
|
||||
result.CreatedCount++;
|
||||
}
|
||||
|
||||
// One deliberately-failed extra payment attempt so the wallet's payment history has a failed row.
|
||||
await EnsureFailedAttemptAsync(world, gatewayId, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Walks the booking + its sessions along the real transition edges for the scenario.</summary>
|
||||
private static void ApplyLifecycleState(BookingEntity booking, BookingScenario scenario, DateTime now)
|
||||
{
|
||||
if (scenario.Cancelled)
|
||||
{
|
||||
// ≥ 24h before start → the standard_24h tier, 100% refundable (only un-started sessions).
|
||||
booking.TransitionTo(BookingStatus.Cancelled, now.AddDays(-1), CancellationActor.Customer, "برنامه سفر تغییر کرد.");
|
||||
booking.RecordCancellationSnapshot(CancellationPolicyCode.Standard24h, 100m, booking.GrossPriceIrr);
|
||||
foreach (var session in booking.Sessions)
|
||||
session.TransitionTo(BookingSessionStatus.Cancelled);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario.CompletedDaysAgo is { } daysAgo)
|
||||
{
|
||||
var completedAt = now.AddDays(-daysAgo);
|
||||
booking.TransitionTo(BookingStatus.InProgress, completedAt.AddHours(-4));
|
||||
booking.TransitionTo(BookingStatus.Completed, completedAt);
|
||||
booking.SetDisputeWindow(completedAt.AddHours(72));
|
||||
|
||||
foreach (var session in booking.Sessions)
|
||||
{
|
||||
session.ScheduledDate = DateOnly.FromDateTime(completedAt);
|
||||
session.TransitionTo(BookingSessionStatus.InProgress);
|
||||
session.TransitionTo(BookingSessionStatus.Completed);
|
||||
session.SetPayoutEligible(completedAt.AddHours(72));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario.Key == "in_progress")
|
||||
{
|
||||
// The 5-session package mid-engagement: 2 done, session 3 running right now, 4–5 still ahead.
|
||||
booking.TransitionTo(BookingStatus.InProgress, now.AddDays(-2));
|
||||
|
||||
var sessions = booking.Sessions.OrderBy(s => s.SessionIndex).ToList();
|
||||
for (var i = 0; i < sessions.Count; i++)
|
||||
{
|
||||
sessions[i].ScheduledDate = Today(now, i - 2);
|
||||
switch (i)
|
||||
{
|
||||
case < 2:
|
||||
sessions[i].TransitionTo(BookingSessionStatus.InProgress);
|
||||
sessions[i].TransitionTo(BookingSessionStatus.Completed);
|
||||
sessions[i].SetPayoutEligible(now.AddDays(i - 2).AddHours(72));
|
||||
break;
|
||||
case 2:
|
||||
sessions[i].TransitionTo(BookingSessionStatus.InProgress);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// "upcoming" stays exactly as the factory built it: confirmed, all sessions scheduled.
|
||||
}
|
||||
|
||||
/// <summary>EVV rows for every non-scheduled session. The draft-batch booking's check-in is deliberately
|
||||
/// out of tolerance so the admin EVV queue has a flagged row to review.</summary>
|
||||
private void SeedVisitVerifications(
|
||||
BookingEntity booking, BookingScenario scenario, CustomerAddress address, DateTime now)
|
||||
{
|
||||
var outOfRange = scenario.Key == "completed_eligible";
|
||||
|
||||
foreach (var session in booking.Sessions)
|
||||
{
|
||||
if (session.Status is not (BookingSessionStatus.InProgress or BookingSessionStatus.Completed))
|
||||
continue;
|
||||
|
||||
var visitDay = session.ScheduledDate.ToDateTime(session.ScheduledTimeStart, DateTimeKind.Utc);
|
||||
var evv = new VisitVerification
|
||||
{
|
||||
BookingSessionId = session.Id,
|
||||
CheckInAt = session.Status == BookingSessionStatus.InProgress ? now.AddHours(-1) : visitDay,
|
||||
CheckInLat = address.Latitude + (outOfRange ? 0.0110m : 0.0003m),
|
||||
CheckInLng = address.Longitude,
|
||||
CheckInAddressMatch = !outOfRange,
|
||||
CheckInDistanceMeters = outOfRange ? 1220m : 38m
|
||||
};
|
||||
evv.MarkCheckedIn();
|
||||
|
||||
if (session.Status == BookingSessionStatus.Completed)
|
||||
{
|
||||
evv.CheckOutAt = visitDay.AddHours(4);
|
||||
evv.CheckOutLat = evv.CheckInLat;
|
||||
evv.CheckOutLng = evv.CheckInLng;
|
||||
evv.MarkCompleted();
|
||||
}
|
||||
|
||||
db.Set<VisitVerification>().Add(evv);
|
||||
}
|
||||
}
|
||||
|
||||
private BookingCareInstruction BuildCareInstructions(long bookingId) => new()
|
||||
{
|
||||
BookingId = bookingId,
|
||||
CurrentConditions = "فشار خون بالا، دیابت نوع دو تحت کنترل.",
|
||||
Medications = "متفورمین ۵۰۰ (صبح و شب)، لوزارتان ۲۵ (صبح).",
|
||||
Allergies = "پنیسیلین.",
|
||||
SpecialInstructions = "اندازهگیری قند خون پیش از هر وعده و ثبت آن.",
|
||||
EmergencyContactName = "بهرام محمدی",
|
||||
EmergencyContactPhone = "09121110010"
|
||||
};
|
||||
|
||||
private static PaymentTransaction BuildSucceededTransaction(
|
||||
BookingEntity booking,
|
||||
BookingRequest request,
|
||||
long customerId,
|
||||
long gatewayId,
|
||||
BookingScenario scenario,
|
||||
int sequence)
|
||||
{
|
||||
var txn = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = request.Id,
|
||||
CustomerId = customerId,
|
||||
GatewayId = gatewayId,
|
||||
Amount = booking.GrossPriceIrr,
|
||||
GatewayTransactionId = $"demo-txn-{sequence:D4}",
|
||||
GatewayReferenceCode = $"{DemoLifecycleDefinitions.GatewayRefPrefix}{sequence:D4}",
|
||||
IsInstallment = scenario.Key == "bnpl_settled"
|
||||
};
|
||||
txn.MarkSucceeded(booking.Id, "100", "{\"source\":\"demo-lifecycle-seed\"}");
|
||||
return txn;
|
||||
}
|
||||
|
||||
private async Task<BookingConversionSource> BuildConversionSourceAsync(
|
||||
BookingRequest request,
|
||||
CustomerWorld customer,
|
||||
NurseWorld nurse,
|
||||
NurseServiceVariant variant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var patientName = await db.Set<Patient>()
|
||||
.Where(p => p.Id == request.PatientId)
|
||||
.Select(p => p.DisplayName)
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
return new BookingConversionSource(
|
||||
request.Id,
|
||||
request.Status,
|
||||
customer.ProfileId,
|
||||
customer.UserId,
|
||||
nurse.Profile.Id,
|
||||
nurse.UserId,
|
||||
request.PatientId,
|
||||
patientName,
|
||||
variant.Id,
|
||||
customer.PrimaryAddress.Id,
|
||||
request.RequestedDate,
|
||||
request.RequestedTimeStart,
|
||||
request.RequestedTimeEnd,
|
||||
await BuildVariantSnapshotAsync(variant, cancellationToken),
|
||||
await BuildAddressSnapshotAsync(customer.PrimaryAddress, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<VariantSnapshot> BuildVariantSnapshotAsync(
|
||||
NurseServiceVariant variant, CancellationToken cancellationToken)
|
||||
{
|
||||
var category = await db.Set<ServiceCategory>()
|
||||
.FirstAsync(c => c.Id == variant.ServiceCategoryId, cancellationToken);
|
||||
|
||||
var options = new List<VariantOptionDto>();
|
||||
foreach (var option in variant.Options)
|
||||
{
|
||||
var group = await db.Set<ServiceOptionGroup>().FirstAsync(g => g.Id == option.OptionGroupId, cancellationToken);
|
||||
var value = await db.Set<ServiceOptionValue>().FirstAsync(v => v.Id == option.OptionValueId, cancellationToken);
|
||||
options.Add(new VariantOptionDto(group.Id, group.NameFa, group.NameEn, value.Id, value.NameFa, value.NameEn));
|
||||
}
|
||||
|
||||
return new VariantSnapshot(
|
||||
variant.Id, variant.ServiceCategoryId, category.NameFa, category.NameEn,
|
||||
variant.Price, variant.PriceUnit, variant.SessionCount, variant.DisplayName, options);
|
||||
}
|
||||
|
||||
private async Task<AddressSnapshot> BuildAddressSnapshotAsync(
|
||||
CustomerAddress address, CancellationToken cancellationToken)
|
||||
{
|
||||
var city = await db.Set<City>().FirstAsync(c => c.Id == address.CityId, cancellationToken);
|
||||
var district = address.DistrictId is { } districtId
|
||||
? await db.Set<District>().FirstAsync(d => d.Id == districtId, cancellationToken)
|
||||
: null;
|
||||
|
||||
return new AddressSnapshot(
|
||||
address.Id, address.Title, city.Id, city.NameFa, city.NameEn,
|
||||
district?.Id, district?.NameFa, district?.NameEn,
|
||||
address.AddressLine, address.PostalCode, address.RecipientName, address.RecipientPhone,
|
||||
address.Latitude, address.Longitude);
|
||||
}
|
||||
|
||||
/// <summary>A failed card attempt against the accepted-awaiting-payment request — the wallet history's
|
||||
/// failed row. Guarded on its fixed gateway reference.</summary>
|
||||
private async Task EnsureFailedAttemptAsync(PersonaWorld world, long gatewayId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string reference = DemoLifecycleDefinitions.GatewayRefPrefix + "fail";
|
||||
if (await db.Set<PaymentTransaction>().AnyAsync(t => t.GatewayReferenceCode == reference, cancellationToken))
|
||||
return;
|
||||
|
||||
var mohammadi = world.Customers[0];
|
||||
var accepted = await db.Set<BookingRequest>()
|
||||
.Where(r => r.CustomerId == mohammadi.ProfileId && r.Status == BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
.Select(r => new { r.Id, r.Variant.Price })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (accepted is null)
|
||||
return;
|
||||
|
||||
var txn = new PaymentTransaction
|
||||
{
|
||||
BookingRequestId = accepted.Id,
|
||||
CustomerId = mohammadi.ProfileId,
|
||||
GatewayId = gatewayId,
|
||||
Amount = accepted.Price,
|
||||
GatewayTransactionId = "demo-txn-fail",
|
||||
GatewayReferenceCode = reference
|
||||
};
|
||||
txn.MarkFailed("51", "{\"error\":\"insufficient_funds\",\"source\":\"demo-lifecycle-seed\"}");
|
||||
db.Set<PaymentTransaction>().Add(txn);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private decimal? _feeRate;
|
||||
|
||||
/// <summary>The snapshotted commission rate — read through the same typed config the real handlers use.</summary>
|
||||
private async ValueTask<decimal> GetFeeRateAsync(CancellationToken cancellationToken)
|
||||
=> _feeRate ??= await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
|
||||
|
||||
private static DateOnly Today(DateTime now, int offsetDays) => DateOnly.FromDateTime(now.AddDays(offsetDays));
|
||||
}
|
||||
Reference in New Issue
Block a user