From 2e5c69eba79eda0fe0b646be2651ed5dc8e2e831 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 04:32:08 +0000 Subject: [PATCH 01/11] refactor(sales): draw and restore egg stock through an IEggStock port Confirm and void reach egg lots through Egg Operations' IEggStock port. LockForSaleAsync runs the same single FOR UPDATE; the reservation plans over the locked lots (the FIFO planner moves to Egg Operations) and draws one planned quantity with its Sale movement. RestoreAsync locks, restores and writes the Void movements. The port never saves. Commerce's grade reads go through IEggGradeLookup.GetAsync, which runs the same GetByIdAsync query. EggLotLockOrderTests gains a two-grade confirm case: before it, locking per grade in line order left every test green. Refs #854 --- ...kworkFeatureServiceCollectionExtensions.cs | 1 + .../CreateProduct/CreateProductHandler.cs | 4 +- .../UpdateProduct/UpdateProductHandler.cs | 4 +- .../Features/EggGrades/EggGradeLookup.cs | 10 +++ .../Features/EggGrades/IEggGradeLookup.cs | 5 ++ .../Features/EggLots/EggStock.cs | 69 +++++++++++++++++++ .../Features/EggLots/IEggStock.cs | 31 +++++++++ .../SaleAllocationPlanner.cs | 20 +++--- .../Features/Eggs/EggOperationsModule.cs | 10 +-- .../Sales/AddOrderItem/AddOrderItemHandler.cs | 4 +- .../Sales/ConfirmSale/ConfirmSaleHandler.cs | 56 +++++---------- .../Sales/VoidSale/VoidSaleHandler.cs | 33 ++------- .../EggLotLockOrderTests.cs | 40 +++++++++++ .../Architecture/Data/coupling-matrix.md | 2 +- .../Architecture/Data/module-ledger.json | 10 +-- .../Eggs/EggOperationsModuleTests.cs | 7 ++ .../Sales/SaleAllocationPlannerTests.cs | 32 +++------ 17 files changed, 224 insertions(+), 114 deletions(-) create mode 100644 src/Cluckwork.Application/Features/EggLots/EggStock.cs create mode 100644 src/Cluckwork.Application/Features/EggLots/IEggStock.cs rename src/Cluckwork.Application/Features/{Sales/ConfirmSale => EggLots}/SaleAllocationPlanner.cs (74%) diff --git a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs index 9b5fd52bb..e7faf2ea9 100644 --- a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs +++ b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs @@ -359,6 +359,7 @@ private static void AddHandlers(IServiceCollection services) Cluckwork.Application.Features.Eggs.IEggOperationsModule, Cluckwork.Application.Features.Eggs.EggOperationsModule>(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped< Cluckwork.Application.Features.Users.AssignFlock.AssignFlockHandler>(); diff --git a/src/Cluckwork.Application/Features/Catalog/CreateProduct/CreateProductHandler.cs b/src/Cluckwork.Application/Features/Catalog/CreateProduct/CreateProductHandler.cs index 8bcda8724..805ef228b 100644 --- a/src/Cluckwork.Application/Features/Catalog/CreateProduct/CreateProductHandler.cs +++ b/src/Cluckwork.Application/Features/Catalog/CreateProduct/CreateProductHandler.cs @@ -9,7 +9,7 @@ namespace Cluckwork.Application.Features.Catalog.CreateProduct; public sealed class CreateProductHandler( IProductRepository products, - IEggGradeRepository grades, + IEggGradeLookup grades, IAccountRepository accounts, IUnitOfWork unitOfWork, IAuditWriter audit) @@ -25,7 +25,7 @@ public async Task> HandleAsync( // Validator guarantees Egg + a grade id; the grade must exist and be // active — mapping a product to a retired bucket would sell from it. - var grade = await grades.GetByIdAsync(command.EggGradeId!.Value, ct); + var grade = await grades.GetAsync(command.EggGradeId!.Value, ct); if (grade is null) return Result.Failure(Error.Validation( "Product.UnknownGrade", "The egg grade does not exist.")); diff --git a/src/Cluckwork.Application/Features/Catalog/UpdateProduct/UpdateProductHandler.cs b/src/Cluckwork.Application/Features/Catalog/UpdateProduct/UpdateProductHandler.cs index fd68dd9d5..bbc7f5c72 100644 --- a/src/Cluckwork.Application/Features/Catalog/UpdateProduct/UpdateProductHandler.cs +++ b/src/Cluckwork.Application/Features/Catalog/UpdateProduct/UpdateProductHandler.cs @@ -8,7 +8,7 @@ namespace Cluckwork.Application.Features.Catalog.UpdateProduct; public sealed class UpdateProductHandler( IProductRepository products, - IEggGradeRepository grades, + IEggGradeLookup grades, IAccountRepository accounts, IUnitOfWork unitOfWork, IAuditWriter audit) @@ -23,7 +23,7 @@ public async Task HandleAsync(UpdateProductCommand command, Cancellation return Result.Failure(Error.Conflict( "Product.DuplicateName", $"A product named '{command.Name.Trim()}' already exists.")); - var grade = await grades.GetByIdAsync(command.EggGradeId!.Value, ct); + var grade = await grades.GetAsync(command.EggGradeId!.Value, ct); if (grade is null) return Result.Failure(Error.Validation( "Product.UnknownGrade", "The egg grade does not exist.")); diff --git a/src/Cluckwork.Application/Features/EggGrades/EggGradeLookup.cs b/src/Cluckwork.Application/Features/EggGrades/EggGradeLookup.cs index 0af8141ba..9779b9909 100644 --- a/src/Cluckwork.Application/Features/EggGrades/EggGradeLookup.cs +++ b/src/Cluckwork.Application/Features/EggGrades/EggGradeLookup.cs @@ -1,3 +1,6 @@ +using Cluckwork.Application.Features.Eggs; +using Cluckwork.Domain.Eggs; + namespace Cluckwork.Application.Features.EggGrades; public sealed class EggGradeLookup(IEggGradeRepository grades) : IEggGradeLookup @@ -5,4 +8,11 @@ public sealed class EggGradeLookup(IEggGradeRepository grades) : IEggGradeLookup public Task> GetDisplayNamesAsync( IReadOnlyCollection gradeIds, CancellationToken ct) => grades.GetDisplayNamesAsync(gradeIds, ct); + + public async Task GetAsync(Guid id, CancellationToken ct) => + await grades.GetByIdAsync(id, ct) is { } grade ? ToDetails(grade) : null; + + internal static EggGradeDetails ToDetails(EggGrade g) => + new(g.Id, g.FarmId, g.Name, g.GradeType, g.SortOrder, g.IsSaleable, + g.DailyEntryKind, g.Active, g.LowStockFloor); } diff --git a/src/Cluckwork.Application/Features/EggGrades/IEggGradeLookup.cs b/src/Cluckwork.Application/Features/EggGrades/IEggGradeLookup.cs index 9eef45914..e1c89a6e0 100644 --- a/src/Cluckwork.Application/Features/EggGrades/IEggGradeLookup.cs +++ b/src/Cluckwork.Application/Features/EggGrades/IEggGradeLookup.cs @@ -1,3 +1,5 @@ +using Cluckwork.Application.Features.Eggs; + namespace Cluckwork.Application.Features.EggGrades; // #853: Egg Operations' grade read port for peer modules and adapters. @@ -6,4 +8,7 @@ public interface IEggGradeLookup // A missing key is a grade outside the tenant or never created. Task> GetDisplayNamesAsync( IReadOnlyCollection gradeIds, CancellationToken ct); + + // Null for a grade outside the tenant or never created. + Task GetAsync(Guid id, CancellationToken ct); } diff --git a/src/Cluckwork.Application/Features/EggLots/EggStock.cs b/src/Cluckwork.Application/Features/EggLots/EggStock.cs new file mode 100644 index 000000000..0997ce018 --- /dev/null +++ b/src/Cluckwork.Application/Features/EggLots/EggStock.cs @@ -0,0 +1,69 @@ +using Cluckwork.Application.Features.Eggs; +using Cluckwork.Domain.Eggs; + +namespace Cluckwork.Application.Features.EggLots; + +public sealed class EggStock(IEggLotRepository lots, IEggInventoryMovementRepository movements) : IEggStock +{ + public async Task LockForSaleAsync( + Guid accountId, IReadOnlyList eggGradeIds, DateOnly saleDate, CancellationToken ct) => + new Reservation(accountId, saleDate, + await lots.GetAvailableFifoLockedAsync(accountId, eggGradeIds, saleDate, ct), movements); + + public async Task RestoreAsync( + Guid accountId, IReadOnlyDictionary quantityByLot, + string referenceType, Guid referenceId, string reason, CancellationToken ct) + { + var lockedLots = await lots.GetByIdsLockedAsync(accountId, quantityByLot.Keys.ToList(), ct); + if (lockedLots.Count != quantityByLot.Count) + return Result.Failure(Error.Domain( + "EggLot.AllocationSourceMissing", + "One or more source egg lots for this order no longer exist.")); + + foreach (var lot in lockedLots) + { + var restore = lot.Restore(quantityByLot[lot.Id]); + if (restore.IsFailure) return restore; + + // Ledger row (#101): the returned eggs re-enter as an explicit + // Void movement, same transaction as the restore. + await movements.AddAsync(EggInventoryMovement.Create( + Guid.NewGuid(), accountId, lot.Id, EggMovementType.Void, + quantityByLot[lot.Id], referenceType, referenceId, reason: reason), ct); + } + return Result.Success(); + } + + private sealed class Reservation( + Guid accountId, DateOnly saleDate, IReadOnlyList lockedLots, + IEggInventoryMovementRepository movements) : IEggStockReservation + { + private readonly Dictionary _lotsById = lockedLots.ToDictionary(l => l.Id); + + public SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet? fromFlocks = null) => + SaleAllocationPlanner.Plan(lines, + fromFlocks is null ? lockedLots : lockedLots.Where(l => fromFlocks.Contains(l.FlockId)).ToList()); + + public async Task DrawAsync( + PlannedEggLotDraw draw, string referenceType, Guid referenceId, CancellationToken ct) + { + // The lot is one of the SAME locked instances the plan read, so a + // refusal here contradicts the plan: an invariant violation, never + // a 422. + var lot = _lotsById[draw.EggLotId]; + var allocated = lot.Allocate(draw.Quantity, saleDate); + if (allocated.IsFailure) + throw new InvalidOperationException( + $"Sale allocation plan contradicted EggLot.Allocate for lot {lot.Id}: " + + allocated.Error.Description); + + // Ledger row (#101): the draw leaves the lot as an explicit Sale + // movement. It references the caller's allocation, not the order, + // so two same-grade lines drawing from one lot stay distinguishable + // (codex #102). + await movements.AddAsync(EggInventoryMovement.Create( + Guid.NewGuid(), accountId, lot.Id, EggMovementType.Sale, + -draw.Quantity, referenceType, referenceId), ct); + } + } +} diff --git a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs new file mode 100644 index 000000000..0f484ba39 --- /dev/null +++ b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs @@ -0,0 +1,31 @@ +namespace Cluckwork.Application.Features.EggLots; + +// #854: Egg Operations' stock port for Commerce. It locks, plans and moves egg +// lots inside the caller's transaction and never saves, like IMortalityLedger, +// so a confirm or a void commits its lot, ledger and order rows together. +public interface IEggStock +{ + // ONE FOR UPDATE over every available lot of these grades, farm-wide, in + // (ProductionDate, Id) order (#612). Call it once per confirm. + Task LockForSaleAsync( + Guid accountId, IReadOnlyList eggGradeIds, DateOnly saleDate, CancellationToken ct); + + // Locks the lots by id in (ProductionDate, Id) order, returns each lot's + // quantity and writes one Void movement per lot. + Task RestoreAsync( + Guid accountId, IReadOnlyDictionary quantityByLot, + string referenceType, Guid referenceId, string reason, CancellationToken ct); +} + +// The lots one LockForSaleAsync call locked. +public interface IEggStockReservation +{ + // Pure: plans over the locked lots, or only those from fromFlocks, and + // plans the same way every time, so a caller can try assigned flocks first + // and farm-wide second without a second lock. + SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet? fromFlocks = null); + + // Takes one planned draw from its locked lot and writes the Sale movement. + // A draw the lot refuses means the plan was wrong, so it throws. + Task DrawAsync(PlannedEggLotDraw draw, string referenceType, Guid referenceId, CancellationToken ct); +} diff --git a/src/Cluckwork.Application/Features/Sales/ConfirmSale/SaleAllocationPlanner.cs b/src/Cluckwork.Application/Features/EggLots/SaleAllocationPlanner.cs similarity index 74% rename from src/Cluckwork.Application/Features/Sales/ConfirmSale/SaleAllocationPlanner.cs rename to src/Cluckwork.Application/Features/EggLots/SaleAllocationPlanner.cs index 8a00a714a..c7c644aab 100644 --- a/src/Cluckwork.Application/Features/Sales/ConfirmSale/SaleAllocationPlanner.cs +++ b/src/Cluckwork.Application/Features/EggLots/SaleAllocationPlanner.cs @@ -1,9 +1,11 @@ using Cluckwork.Domain.Eggs; -using Cluckwork.Domain.Sales; -namespace Cluckwork.Application.Features.Sales.ConfirmSale; +namespace Cluckwork.Application.Features.EggLots; -public sealed record PlannedEggLotDraw(Guid SalesOrderItemId, Guid EggLotId, int Quantity); +// One line of a sale: the caller's line id, its grade and the eggs it needs. +public sealed record SaleDemandLine(Guid LineId, Guid EggGradeId, int Quantity); + +public sealed record PlannedEggLotDraw(Guid LineId, Guid EggLotId, int Quantity); // #612 — whether the plan covered the whole order, and — when it did not — // which grade ran short and by how much. The CALLER turns that into an Error: @@ -25,13 +27,13 @@ public static SaleAllocationPlan Short(Guid eggGradeId, int remaining) => // #612 — pure whole-order FIFO planner (spec §10.9.1): reads // EggLot.QuantityAvailable off the given candidate list and never mutates a -// lot or the order. The same immutable input plans identically every time, -// so a caller can try a NARROWER candidate subset first and the SAME full -// locked list second without a second lock or query. +// lot. The same immutable input plans identically every time, so a caller can +// try a NARROWER candidate subset first and the SAME full locked list second +// without a second lock or query. public static class SaleAllocationPlanner { public static SaleAllocationPlan Plan( - IReadOnlyList items, IReadOnlyList candidateLots) + IReadOnlyList items, IReadOnlyList candidateLots) { var draws = new List(); // A copy, never the lots' own field — repeated grades across items @@ -40,14 +42,14 @@ public static SaleAllocationPlan Plan( foreach (var item in items) { - var remaining = item.QuantityBase; + var remaining = item.Quantity; foreach (var lot in candidateLots.Where(l => l.EggGradeId == item.EggGradeId)) { if (remaining <= 0) break; var available = remainingByLot[lot.Id]; if (available <= 0) continue; var take = Math.Min(remaining, available); - draws.Add(new PlannedEggLotDraw(item.Id, lot.Id, take)); + draws.Add(new PlannedEggLotDraw(item.LineId, lot.Id, take)); remainingByLot[lot.Id] = available - take; remaining -= take; } diff --git a/src/Cluckwork.Application/Features/Eggs/EggOperationsModule.cs b/src/Cluckwork.Application/Features/Eggs/EggOperationsModule.cs index b6ae94d1c..aba06a718 100644 --- a/src/Cluckwork.Application/Features/Eggs/EggOperationsModule.cs +++ b/src/Cluckwork.Application/Features/Eggs/EggOperationsModule.cs @@ -54,13 +54,13 @@ public Task LockSubmittedEntriesAsync(DateOnly before, int b lockDueEntries.HandleAsync(before, batchSize, ct); public async Task> ListActiveGradesAsync(Guid? farmId, CancellationToken ct) => - (await grades.ListActiveAsync(farmId, ct)).Select(ToDetails).ToList(); + (await grades.ListActiveAsync(farmId, ct)).Select(EggGradeLookup.ToDetails).ToList(); public async Task> ListAllGradesAsync(CancellationToken ct) => - (await grades.ListAllAsync(ct)).Select(ToDetails).ToList(); + (await grades.ListAllAsync(ct)).Select(EggGradeLookup.ToDetails).ToList(); public async Task GetGradeAsync(Guid id, CancellationToken ct) => - await grades.GetByIdAsync(id, ct) is { } grade ? ToDetails(grade) : null; + await grades.GetByIdAsync(id, ct) is { } grade ? EggGradeLookup.ToDetails(grade) : null; public Task> CreateGradeAsync(CreateEggGradeCommand command, Guid accountId, CancellationToken ct) => createGrade.HandleAsync(command, accountId, ct); @@ -98,8 +98,4 @@ private static DailyEntryDetails ToDetails(DailyEntry e) => e.CrackedGradeId, e.DirtyGradeId, e.Grades.Select(g => new GradeQuantityDto(g.EggGradeId, g.Quantity)).ToList(), e.Version, e.AdjustReason, e.VoidReason, e.LockedAtUtc, e.AdjustedFromJson); - - private static EggGradeDetails ToDetails(EggGrade g) => - new(g.Id, g.FarmId, g.Name, g.GradeType, g.SortOrder, g.IsSaleable, - g.DailyEntryKind, g.Active, g.LowStockFloor); } diff --git a/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs b/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs index 539934a57..bc764588b 100644 --- a/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs @@ -11,7 +11,7 @@ namespace Cluckwork.Application.Features.Sales.AddOrderItem; public sealed class AddOrderItemHandler( ISalesOrderRepository orders, IProductRepository products, - IEggGradeRepository eggGrades, + IEggGradeLookup eggGrades, IEggUnitConversionRepository conversions, IAuditWriter audit, IUnitOfWork unitOfWork) @@ -35,7 +35,7 @@ public async Task> HandleAsync( // The grade snapshot comes from the product's CURRENT mapping — the // line keeps it even if the mapping is re-pointed later. var mapping = await products.GetMappingAsync(product.Id, ct); - var grade = mapping is null ? null : await eggGrades.GetByIdAsync(mapping.EggGradeId, ct); + var grade = mapping is null ? null : await eggGrades.GetAsync(mapping.EggGradeId, ct); if (grade is null || !grade.Active || !grade.IsSaleable) return Result.Failure(Error.Validation( "SalesOrder.UnknownGrade", diff --git a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs index d54b8a4fa..f768e18ff 100644 --- a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs @@ -3,11 +3,9 @@ using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.EggGrades; using Cluckwork.Application.Features.EggLots; -using Cluckwork.Application.Features.Eggs; using Cluckwork.Application.Features.Users; using Cluckwork.Domain.Accounts; using Cluckwork.Domain.Common; -using Cluckwork.Domain.Eggs; using Cluckwork.Domain.Sales; using Microsoft.Extensions.Logging; @@ -16,10 +14,9 @@ namespace Cluckwork.Application.Features.Sales.ConfirmSale; public sealed class ConfirmSaleHandler( IAccountRepository accounts, ISalesOrderRepository salesOrders, - IEggLotRepository eggLots, - IEggGradeRepository eggGrades, + IEggStock eggStock, + IEggGradeLookup eggGrades, ISalesOrderAllocationRepository allocations, - IEggInventoryMovementRepository eggMovements, IUserRoleAssignmentRepository assignments, IIdentityProvider identity, IAuditWriter audit, @@ -188,11 +185,10 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => && !Roles.MayExceedDiscountCeiling(role.Value) && order.FindCeilingBreach(ceiling) is { } breach) { - // The grade name resolves through the already-injected - // repository, the same way the insufficient-stock message below - // does. The domain decides WHO breaches; this composes the + // The grade name resolves through Egg Operations' grade lookup, + // the same way the insufficient-stock message below does. The domain decides WHO breaches; this composes the // refusal, because SalesOrderItem carries an EggGradeId only. - var gradeName = (await eggGrades.GetByIdAsync(breach.EggGradeId, transactionCt))?.Name + var gradeName = (await eggGrades.GetAsync(breach.EggGradeId, transactionCt))?.Name ?? breach.EggGradeId.ToString(); failure = Result.Failure( CeilingRefusal(breach, ceiling, gradeName)); @@ -214,11 +210,13 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => // 7 — ONE farm-wide FIFO lock statement for every grade on the // order, ordered (ProductionDate, Id). Never filtered by flock in - // SQL and never called twice (#612 — EggLotRepository keeps this - // shape on purpose). + // SQL and never called twice (#612 — IEggStock keeps this shape on + // purpose). var gradeIds = order.Items.Select(i => i.EggGradeId).Distinct().ToList(); - var lockedLots = await eggLots.GetAvailableFifoLockedAsync( + var stock = await eggStock.LockForSaleAsync( accountId, gradeIds, allocationDate, transactionCt); + var demand = order.Items + .Select(i => new SaleDemandLine(i.Id, i.EggGradeId, i.QuantityBase)).ToList(); // 8/9 — plan in memory, never mutating a lot or the order. var useAssignedFirst = isRestrictedWorker @@ -227,8 +225,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => SaleAllocationPlan plan; if (useAssignedFirst) { - var assignedLots = lockedLots.Where(l => assignedFlockIds!.Contains(l.FlockId)).ToList(); - var assignedPlan = SaleAllocationPlanner.Plan(order.Items, assignedLots); + var assignedPlan = stock.Plan(demand, assignedFlockIds); if (assignedPlan.IsComplete) { plan = assignedPlan; @@ -237,7 +234,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => { // Retry against the SAME already-locked farm-wide rows — // no second query, no new lock. - var farmWidePlan = SaleAllocationPlanner.Plan(order.Items, lockedLots); + var farmWidePlan = stock.Plan(demand); if (farmWidePlan.IsComplete) { // Farm-wide would have covered it, but a restricted @@ -260,7 +257,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => } else { - plan = SaleAllocationPlanner.Plan(order.Items, lockedLots); + plan = stock.Plan(demand); if (!plan.IsComplete) { if (isRestrictedWorker) @@ -277,7 +274,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => // Unchanged behavior for every non-restricted caller // (elevated roles, farm-wide policy, or an unrestricted // Worker): today's specific grade/quantity message. - var gradeName = (await eggGrades.GetByIdAsync(plan.ShortEggGradeId!.Value, transactionCt))?.Name + var gradeName = (await eggGrades.GetAsync(plan.ShortEggGradeId!.Value, transactionCt))?.Name ?? plan.ShortEggGradeId.Value.ToString(); failure = Result.Failure(Error.Domain( "EggLot.InsufficientStock", @@ -286,32 +283,15 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => } } - // 10 — apply ONLY the successful plan. Every lot in a draw is one - // of the SAME locked instances the plan was computed from, so - // Allocate() re-validating quantity here cannot disagree with the - // plan — a contradiction is an invariant violation, not a normal - // domain failure, and must not be swallowed into a 422. - var lotsById = lockedLots.ToDictionary(l => l.Id); + // 10 — apply ONLY the successful plan. The draw's Sale movement + // references the ALLOCATION, so its id exists first. var allocationRows = new List(); foreach (var draw in plan.Draws) { - var lot = lotsById[draw.EggLotId]; - var allocateResult = lot.Allocate(draw.Quantity, allocationDate); - if (allocateResult.IsFailure) - throw new InvalidOperationException( - $"Sale allocation plan contradicted EggLot.Allocate for lot {lot.Id}: " + - allocateResult.Error.Description); - var allocation = SalesOrderAllocation.Create( - accountId, order.Id, draw.SalesOrderItemId, draw.EggLotId, draw.Quantity); + accountId, order.Id, draw.LineId, draw.EggLotId, draw.Quantity); allocationRows.Add(allocation); - // Ledger row (#101): the draw leaves the lot as an explicit - // Sale movement, same transaction. References the ALLOCATION, - // not the order — two same-grade lines drawing from one lot - // stay distinguishable (codex #102). - await eggMovements.AddAsync(EggInventoryMovement.Create( - Guid.NewGuid(), accountId, lot.Id, EggMovementType.Sale, - -draw.Quantity, nameof(SalesOrderAllocation), allocation.Id), transactionCt); + await stock.DrawAsync(draw, nameof(SalesOrderAllocation), allocation.Id, transactionCt); } var confirmResult = order.Confirm(discountReasonCode, command.DiscountReasonNote); diff --git a/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs index c77adbb18..88e8ae7f7 100644 --- a/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs @@ -1,8 +1,6 @@ using Cluckwork.Application.Common; using Cluckwork.Application.Features.EggLots; -using Cluckwork.Application.Features.Eggs; using Cluckwork.Domain.Common; -using Cluckwork.Domain.Eggs; using Cluckwork.Domain.Sales; using Microsoft.Extensions.Logging; @@ -20,10 +18,9 @@ namespace Cluckwork.Application.Features.Sales.VoidSale; public sealed class VoidSaleHandler( ISalesOrderRepository salesOrders, ISalesOrderAllocationRepository allocations, - IEggLotRepository eggLots, + IEggStock eggStock, IPaymentRepository payments, IUnitOfWork unitOfWork, - IEggInventoryMovementRepository eggMovements, IClock clock, IAuditWriter audit, ILogger logger) @@ -89,34 +86,14 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => var perLot = rows .GroupBy(r => r.EggLotId) .ToDictionary(g => g.Key, g => g.Sum(r => r.Quantity)); - var lockedLots = await eggLots.GetByIdsLockedAsync( - accountId, perLot.Keys.ToList(), transactionCt); - - if (lockedLots.Count != perLot.Count) + var restored = await eggStock.RestoreAsync( + accountId, perLot, nameof(SalesOrder), order.Id, command.Reason, transactionCt); + if (restored.IsFailure) { - outcome = Result.Failure(Error.Domain( - "EggLot.AllocationSourceMissing", - "One or more source egg lots for this order no longer exist.")); + outcome = Result.Failure(restored.Error); return false; } - foreach (var lot in lockedLots) - { - var restore = lot.Restore(perLot[lot.Id]); - if (restore.IsFailure) - { - outcome = Result.Failure(restore.Error); - return false; - } - - // Ledger row (#101): the returned eggs re-enter as an explicit - // Void movement, same transaction as the restore. - await eggMovements.AddAsync(EggInventoryMovement.Create( - Guid.NewGuid(), accountId, lot.Id, EggMovementType.Void, - perLot[lot.Id], nameof(SalesOrder), order.Id, - reason: command.Reason), transactionCt); - } - var releasedAt = clock.UtcNow; foreach (var row in rows) row.MarkReleased(releasedAt); diff --git a/tests/Cluckwork.Api.IntegrationTests/EggLotLockOrderTests.cs b/tests/Cluckwork.Api.IntegrationTests/EggLotLockOrderTests.cs index 7f87e2df6..cbd7c7acb 100644 --- a/tests/Cluckwork.Api.IntegrationTests/EggLotLockOrderTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/EggLotLockOrderTests.cs @@ -21,6 +21,7 @@ namespace Cluckwork.Api.IntegrationTests; [Collection(IntegrationCollection.Name)] public sealed class EggLotLockOrderTests(CluckworkWebApplicationFactory factory) { + private sealed record Created(Guid Id); private sealed record EntryVersion(int Version); private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow.Date); @@ -56,6 +57,37 @@ await AssertLockedInOrderAsync(accountId, earlierLot, laterLot, new { reason = "Confirmed by mistake" })); } + // The first line's grade holds the later lot. The one farm-wide statement + // still locks the other grade's earlier lot first, where a lock per grade + // in line order would park on the first grade and never reach it (#854). + // Each line is its own request, so the lines keep the order they were added in. + [Fact] + public async Task ConfirmSale_TwoGrades_HoldsTheOtherGradesEarlierLotWhileWaitingOnTheLater() + { + var farmId = Guid.NewGuid(); + var (client, accountId, grades) = await SetupAsync(farmId, "Large", "Medium"); + var (low, high) = await IdsInDatabaseOrderAsync(accountId); + var (earlierLot, laterLot) = (high, low); + await factory.SeedEggLotAsync(accountId, grades["Large"], 30, productionDate: Today, lotId: laterLot); + await factory.SeedEggLotAsync(accountId, grades["Medium"], 40, + productionDate: Today.AddDays(-5), lotId: earlierLot); + var customer = await client.PostWithKeyAsync("/api/v1/customers", Guid.NewGuid().ToString(), + new { name = "Two Grade Buyer", phone = "555-0100" }); + var created = await client.PostWithKeyAsync("/api/v1/sales", Guid.NewGuid().ToString(), + new { customerId = (await customer.Content.ReadFromJsonAsync())!.Id, orderDate = Today }); + var order = (await created.Content.ReadFromJsonAsync())!.Id; + foreach (var grade in new[] { "Large", "Medium" }) + { + var productId = await factory.SeedProductAsync(accountId, farmId, grades[grade], defaultPriceMinorUnits: 100); + (await client.PostWithKeyAsync($"/api/v1/sales/{order}/items", Guid.NewGuid().ToString(), + new { productId, quantity = 10 })).EnsureSuccessStatusCode(); + } + Assert.Equal([grades["Large"], grades["Medium"]], await LineGradesAsync(accountId, order)); + + await AssertLockedInOrderAsync(accountId, earlierLot, laterLot, + () => client.PostWithKeyAsync($"/api/v1/sales/{order}/confirm", Guid.NewGuid().ToString())); + } + // One entry's lots share a production date, so Id alone orders them. The // entry and its lots are seeded directly so the test picks their Ids. [Fact] @@ -135,6 +167,14 @@ await factory.SeedEggLotAsync(accountId, gradeId, 40, return aFirst ? (a, b) : (b, a); }); + // The order a confirm reads an order's lines in. + private Task> LineGradesAsync(Guid accountId, Guid orderId) => + factory.WithTenantScopeAsync(accountId, db => db.SalesOrderItems + .Where(i => i.SalesOrderId == orderId) + .OrderBy(i => i.CreatedAtUtc).ThenBy(i => EF.Property(i, "Sequence")) + .Select(i => i.EggGradeId) + .ToListAsync()); + private Task<(int Earlier, int Later)> AvailableAsync(Guid accountId, Guid earlierLot, Guid laterLot) => factory.WithTenantScopeAsync(accountId, async db => ( (await db.EggLots.SingleAsync(l => l.Id == earlierLot)).QuantityAvailable, diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md b/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md index fd946931f..38eca3c8a 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md @@ -8,7 +8,7 @@ | Farm | — | — | — | — | R (5) | — | — | — | P | | FlockManagement | — | R (1) | — | — | — | — | — | — | P | | EggOperations | — | R (2) | W (7) | — | — | — | — | — | P | -| Commerce | R (1) | R (4) | — | W (8) fk:3 | — | — | — | — | P | +| Commerce | R (1) | R (4) | — | W (5) fk:3 | — | — | — | — | P | | GeneralInventory | — | R (3) | R (3) fk:3 | R (2) fk:2 | — | — | — | — | P | | Finance | — | R (3) | R (2) fk:1 | — | — | — | — | — | P | | Insights | — | R (1) | R (2) | R (2) | R (2) | R (1) | R (2) | — | P | diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json index 36457d140..307bcc9df 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json @@ -38,6 +38,11 @@ "contract": [ "Cluckwork.Application.Features.Eggs.IEggOperationsModule", "Cluckwork.Application.Features.EggGrades.IEggGradeLookup", + "Cluckwork.Application.Features.EggLots.IEggStock", + "Cluckwork.Application.Features.EggLots.IEggStockReservation", + "Cluckwork.Application.Features.EggLots.SaleDemandLine", + "Cluckwork.Application.Features.EggLots.PlannedEggLotDraw", + "Cluckwork.Application.Features.EggLots.SaleAllocationPlan", "Cluckwork.Application.Features.DailyEntries.IDailyEntryLookup", "Cluckwork.Application.Features.Eggs.DailyEntryDetails", "Cluckwork.Application.Features.Eggs.EggGradeDetails", @@ -175,15 +180,12 @@ }, { "from": "Commerce", "to": "EggOperations", "kind": "W", - "reason": "FIFO egg stock. ConfirmSaleHandler and VoidSaleHandler draw from and return to Domain.Eggs.EggLot through Egg Operations' IEggLotRepository and IEggInventoryMovementRepository inside the confirm and void transactions, and SaleAllocationPlanner plans those draws over EggLot. Products and order lines map to grades through IEggGradeRepository in CreateProductHandler, UpdateProductHandler and AddOrderItemHandler. Design 3.4 row Commerce -> Egg Ops = W.", + "reason": "FIFO egg stock. ConfirmSaleHandler and VoidSaleHandler lock, draw from and return egg lots through Egg Operations' IEggStock port, which adds the lot changes and Sale or Void movements to the confirm and void transactions and never saves (#854); Commerce sees plans and lot ids, never Domain.Eggs.EggLot. Products, order lines and confirm refusals read grades through IEggGradeLookup in CreateProductHandler, UpdateProductHandler, AddOrderItemHandler and ConfirmSaleHandler. Design 3.4 row Commerce -> Egg Ops = W.", "symbols": [ "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductHandler", "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductHandler", "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemHandler", "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler", - "Cluckwork.Application.Features.Sales.ConfirmSale.PlannedEggLotDraw", - "Cluckwork.Application.Features.Sales.ConfirmSale.SaleAllocationPlan", - "Cluckwork.Application.Features.Sales.ConfirmSale.SaleAllocationPlanner", "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleHandler" ] }, diff --git a/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs b/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs index bfc8f6158..147be2935 100644 --- a/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs +++ b/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs @@ -159,6 +159,13 @@ public async Task GradeLookup_ForwardsTheIds() Assert.Equal([$"names {CrackedId},{LargeId}"], _grades.Calls); } + [Fact] + public async Task GradeLookup_GetCopiesEveryField() + { + Assert.Equal(ExpectedCracked, await new EggGradeLookup(_grades).GetAsync(CrackedId, default)); + Assert.Null(await new EggGradeLookup(_grades).GetAsync(Guid.NewGuid(), default)); + } + [Fact] public async Task DailyEntryLookup_ForwardsTheNaturalKeyAndReturnsTheId() { diff --git a/tests/Cluckwork.Application.Tests/Sales/SaleAllocationPlannerTests.cs b/tests/Cluckwork.Application.Tests/Sales/SaleAllocationPlannerTests.cs index f72569f1f..c8464659f 100644 --- a/tests/Cluckwork.Application.Tests/Sales/SaleAllocationPlannerTests.cs +++ b/tests/Cluckwork.Application.Tests/Sales/SaleAllocationPlannerTests.cs @@ -1,8 +1,5 @@ -using Cluckwork.Application.Features.Sales.ConfirmSale; -using Cluckwork.Domain.Catalog; -using Cluckwork.Domain.Common; +using Cluckwork.Application.Features.EggLots; using Cluckwork.Domain.Eggs; -using Cluckwork.Domain.Sales; namespace Cluckwork.Application.Tests.Sales; @@ -15,15 +12,8 @@ public sealed class SaleAllocationPlannerTests private static readonly Guid GradeA = Guid.NewGuid(); private static readonly Guid GradeB = Guid.NewGuid(); - private static SalesOrder OrderWith(params (Guid GradeId, int Quantity)[] lines) - { - var order = SalesOrder.Create( - Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "SO-PLAN", Today, "USD"); - foreach (var (gradeId, quantity) in lines) - order.AddItem(Guid.NewGuid(), ProductType.Egg, gradeId, ProductUnit.Egg, 1, quantity, Money.Zero("USD"), - null, ListPriceBasis.ProductUnpriced); - return order; - } + private static IReadOnlyList OrderWith(params (Guid GradeId, int Quantity)[] lines) => + lines.Select(l => new SaleDemandLine(Guid.NewGuid(), l.GradeId, l.Quantity)).ToList(); private static EggLot Lot(Guid gradeId, int quantity, DateOnly? productionDate = null, Guid? flockId = null) => EggLot.Create( @@ -37,7 +27,7 @@ public void WholeOrderFits_ProducesOneDrawPerLot_InFifoOrder() var newer = Lot(GradeA, 10, Today.AddDays(-1)); var order = OrderWith((GradeA, 12)); - var plan = SaleAllocationPlanner.Plan(order.Items, [older, newer]); + var plan = SaleAllocationPlanner.Plan(order, [older, newer]); Assert.True(plan.IsComplete); Assert.Equal(2, plan.Draws.Count); @@ -53,7 +43,7 @@ public void RepeatedGrade_AcrossTwoLines_SharesTheSameLotWithoutDoubleCounting() var lot = Lot(GradeA, 15); var order = OrderWith((GradeA, 10), (GradeA, 5)); - var plan = SaleAllocationPlanner.Plan(order.Items, [lot]); + var plan = SaleAllocationPlanner.Plan(order, [lot]); Assert.True(plan.IsComplete); Assert.Equal(2, plan.Draws.Count); @@ -68,7 +58,7 @@ public void DifferentGrades_DrawFromTheirOwnLotsOnly() var lotB = Lot(GradeB, 10); var order = OrderWith((GradeA, 6), (GradeB, 4)); - var plan = SaleAllocationPlanner.Plan(order.Items, [lotA, lotB]); + var plan = SaleAllocationPlanner.Plan(order, [lotA, lotB]); Assert.True(plan.IsComplete); Assert.Contains(plan.Draws, d => d.EggLotId == lotA.Id && d.Quantity == 6); @@ -81,7 +71,7 @@ public void InsufficientStock_ReportsTheShortGradeAndRemainder_AndProducesNoDraw var lot = Lot(GradeA, 3); var order = OrderWith((GradeA, 10)); - var plan = SaleAllocationPlanner.Plan(order.Items, [lot]); + var plan = SaleAllocationPlanner.Plan(order, [lot]); Assert.False(plan.IsComplete); Assert.Equal(GradeA, plan.ShortEggGradeId); @@ -96,7 +86,7 @@ public void CandidateLots_AreNeverMutated() var before = lot.QuantityAvailable; var order = OrderWith((GradeA, 6)); - SaleAllocationPlanner.Plan(order.Items, [lot]); + SaleAllocationPlanner.Plan(order, [lot]); Assert.Equal(before, lot.QuantityAvailable); } @@ -110,8 +100,8 @@ public void SameInputs_PlanTwice_ProducesTheIdenticalPlan() var lot = Lot(GradeA, 10); var order = OrderWith((GradeA, 6)); - var first = SaleAllocationPlanner.Plan(order.Items, [lot]); - var second = SaleAllocationPlanner.Plan(order.Items, [lot]); + var first = SaleAllocationPlanner.Plan(order, [lot]); + var second = SaleAllocationPlanner.Plan(order, [lot]); Assert.Equal(first.Draws, second.Draws); } @@ -120,7 +110,7 @@ public void SameInputs_PlanTwice_ProducesTheIdenticalPlan() public void EmptyCandidateList_IsInsufficient_ForAnyNonZeroLine() { var order = OrderWith((GradeA, 1)); - var plan = SaleAllocationPlanner.Plan(order.Items, []); + var plan = SaleAllocationPlanner.Plan(order, []); Assert.False(plan.IsComplete); Assert.Equal(GradeA, plan.ShortEggGradeId); Assert.Equal(1, plan.ShortRemaining); From 1f245c880f0e60db137d147998785f6d52e723ab Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 04:38:50 +0000 Subject: [PATCH 02/11] refactor(farm): ask each owning module whether it holds farm-currency amounts Farm's CurrencyBoundRowProbe no longer reads Commerce, Finance and General Inventory tables itself. Each repository that owns a probed table implements Farm's ICurrencyBoundRowSource with the probe's query for that table, and the probe asks them in registration order, which is the order it read them in before. Both of the probe's compatibility-exception rows (deleteWhen #854) are gone. Refs #854 --- ...kworkFeatureServiceCollectionExtensions.cs | 15 ++++++- .../Accounts/CurrencyBoundRowProbe.cs | 18 ++++++++ .../Accounts/ICurrencyBoundRowSource.cs | 9 ++++ src/Cluckwork.Domain/Sales/SalesOrder.cs | 3 +- .../Repositories/CatalogRepositories.cs | 11 ++++- .../Repositories/CurrencyBoundRowProbe.cs | 44 ------------------- .../Repositories/ExpenseRepositories.cs | 7 ++- .../Repositories/FeedUsageRepository.cs | 8 +++- .../Repositories/InventoryItemRepository.cs | 9 +++- .../Repositories/InventoryLotRepository.cs | 9 +++- .../Repositories/PaymentRepository.cs | 7 ++- .../Repositories/SalesOrderRepository.cs | 7 ++- .../Architecture/Data/module-ledger.json | 16 ------- 13 files changed, 94 insertions(+), 69 deletions(-) create mode 100644 src/Cluckwork.Application/Features/Accounts/CurrencyBoundRowProbe.cs create mode 100644 src/Cluckwork.Application/Features/Accounts/ICurrencyBoundRowSource.cs delete mode 100644 src/Cluckwork.Infrastructure/Repositories/CurrencyBoundRowProbe.cs diff --git a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs index e7faf2ea9..d0b0dfa88 100644 --- a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs +++ b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs @@ -138,7 +138,20 @@ private static void AddPortsAndRepositories(IServiceCollection services) services.AddScoped(); services.AddScoped< Cluckwork.Application.Features.Accounts.ICurrencyBoundRowProbe, - CurrencyBoundRowProbe>(); + Cluckwork.Application.Features.Accounts.CurrencyBoundRowProbe>(); + // #854: the probe asks these in this order and stops at the first yes; + // the three spec §4.6 names come first. Payments and FeedUsages cannot + // be tested alone: a payment exists only against a sales order and a + // feed usage only against the lot it drew from, so the source before + // each always answers first. They stay because the rule is that a row + // carrying an amount locks the currency, and these carry one. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped< Cluckwork.Application.Features.Accounts.IFarmLogoRepository, FarmLogoRepository>(); diff --git a/src/Cluckwork.Application/Features/Accounts/CurrencyBoundRowProbe.cs b/src/Cluckwork.Application/Features/Accounts/CurrencyBoundRowProbe.cs new file mode 100644 index 000000000..c4c7084d5 --- /dev/null +++ b/src/Cluckwork.Application/Features/Accounts/CurrencyBoundRowProbe.cs @@ -0,0 +1,18 @@ +namespace Cluckwork.Application.Features.Accounts; + +// #854: everything that has snapshotted the farm currency, asked of the +// modules that own it, scoped to the current tenant by their query filters. +// Sources answer in registration order and the first yes ends the probe, so +// on a working farm the first one usually answers. +public sealed class CurrencyBoundRowProbe(IEnumerable sources) : ICurrencyBoundRowProbe +{ + public async Task AnyAsync(CancellationToken ct = default) + { + foreach (var source in sources) + { + if (await source.AnyAsync(ct)) + return true; + } + return false; + } +} diff --git a/src/Cluckwork.Application/Features/Accounts/ICurrencyBoundRowSource.cs b/src/Cluckwork.Application/Features/Accounts/ICurrencyBoundRowSource.cs new file mode 100644 index 000000000..8f1b20262 --- /dev/null +++ b/src/Cluckwork.Application/Features/Accounts/ICurrencyBoundRowSource.cs @@ -0,0 +1,9 @@ +namespace Cluckwork.Application.Features.Accounts; + +// #854: one table that may hold an amount in the farm currency (spec §4.6). +// The module that owns the table implements it, so Farm asks without reading +// another module's tables. A table that starts holding amounts registers one. +public interface ICurrencyBoundRowSource +{ + Task AnyAsync(CancellationToken ct); +} diff --git a/src/Cluckwork.Domain/Sales/SalesOrder.cs b/src/Cluckwork.Domain/Sales/SalesOrder.cs index 5380c7355..dc0f442cf 100644 --- a/src/Cluckwork.Domain/Sales/SalesOrder.cs +++ b/src/Cluckwork.Domain/Sales/SalesOrder.cs @@ -453,7 +453,8 @@ public sealed class SalesOrderItem : Entity, IMutableRecord /// Admin-only CSV export that already carried it. That /// denomination check is what makes a bare long? honest, and it /// backstops a state the #123 currency lock makes unreachable today — a - /// priced product locks the farm currency (CurrencyBoundRowProbe.cs:24). + /// priced product locks the farm currency (ProductRepository's + /// ICurrencyBoundRowSource read). /// public long? ListUnitPriceMinorUnits { get; private set; } /// diff --git a/src/Cluckwork.Infrastructure/Repositories/CatalogRepositories.cs b/src/Cluckwork.Infrastructure/Repositories/CatalogRepositories.cs index 5f7d241b8..b67de20d9 100644 --- a/src/Cluckwork.Infrastructure/Repositories/CatalogRepositories.cs +++ b/src/Cluckwork.Infrastructure/Repositories/CatalogRepositories.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Catalog; using Cluckwork.Domain.Catalog; using Cluckwork.Infrastructure.Persistence; @@ -5,8 +6,16 @@ namespace Cluckwork.Infrastructure.Repositories; -public sealed class ProductRepository(AppDbContext db) : IProductRepository +public sealed class ProductRepository(AppDbContext db) : IProductRepository, + ICurrencyBoundRowSource { + // A priced product: an order line taking the default re-labels the raw + // integer with the ORDER's currency. An unpriced product carries a currency + // column too, but nothing reads it as an amount, so it cannot be misread and + // does not lock the farm out of a currency it has not started trading in. + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.Products.AnyAsync(p => p.DefaultPriceMinorUnits != null, ct); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.Products.FirstOrDefaultAsync(p => p.Id == id, ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/CurrencyBoundRowProbe.cs b/src/Cluckwork.Infrastructure/Repositories/CurrencyBoundRowProbe.cs deleted file mode 100644 index b70db5ed6..000000000 --- a/src/Cluckwork.Infrastructure/Repositories/CurrencyBoundRowProbe.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Cluckwork.Application.Features.Accounts; -using Cluckwork.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace Cluckwork.Infrastructure.Repositories; - -// Everything that has snapshotted the farm currency, scoped to the current -// tenant by the query filters. Short-circuits: on a working farm the first -// probe usually answers it. -// -// Priced products count (see ICurrencyBoundRowProbe). An unpriced product -// carries a currency column too, but nothing reads it as an amount, so it -// cannot be misread and does not lock the farm out of a currency it has not -// started trading in. -public sealed class CurrencyBoundRowProbe(AppDbContext db) : ICurrencyBoundRowProbe -{ - public async Task AnyAsync(CancellationToken ct = default) => - // The three §4.6 names. - await db.SalesOrders.AnyAsync(ct) - || await db.Payments.AnyAsync(ct) - || await db.Expenses.AnyAsync(ct) - // A priced product: an order line taking the default re-labels the raw - // integer with the ORDER's currency. - || await db.Products.AnyAsync(p => p.DefaultPriceMinorUnits != null, ct) - // Feed money. Every lot stores a unit cost and every usage an estimated - // cost; an item's default cost is what a purchase falls back to when no - // cost is given, which would stamp a new lot in the old currency after - // a change. Costs from two denominations then get summed as though they - // were one. - || await db.InventoryLots.AnyAsync(ct) - || await db.FeedUsages.AnyAsync(ct) - || await db.InventoryItems.AnyAsync(i => i.DefaultUnitCost != null, ct); - - // Two of these seven cannot be tested in isolation, and it is worth saying - // why rather than leaving them looking like coverage gaps (adversarial - // review of #159 counted them as such). - // - // A payment only exists against a sales order, and a feed usage only exists - // against a lot it drew from — so the SalesOrders and InventoryLots probes - // above always answer first, and no fixture can reach a farm that has one - // without the other. They stay because "implied by another row today" is a - // property of the current write paths, not of the rule: the rule is that a - // row carrying an amount locks the currency, and these carry one. -} diff --git a/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs b/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs index 43987a964..c987f64d6 100644 --- a/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs +++ b/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Expenses; using Cluckwork.Domain.Expenses; using Cluckwork.Infrastructure.Persistence; @@ -43,8 +44,12 @@ public async Task AddAsync(ExpenseCategory entity, CancellationToken ct = defaul public void Remove(ExpenseCategory entity) => db.ExpenseCategories.Remove(entity); } -public sealed class ExpenseRepository(AppDbContext db) : IExpenseRepository +public sealed class ExpenseRepository(AppDbContext db) : IExpenseRepository, + ICurrencyBoundRowSource { + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.Expenses.AnyAsync(ct); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.Expenses.FirstOrDefaultAsync(e => e.Id == id, ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs b/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs index 423823a1b..e1553da02 100644 --- a/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Inventory; using Cluckwork.Domain.Inventory; using Cluckwork.Infrastructure.Persistence; @@ -5,8 +6,13 @@ namespace Cluckwork.Infrastructure.Repositories; -public sealed class FeedUsageRepository(AppDbContext db) : IFeedUsageRepository +public sealed class FeedUsageRepository(AppDbContext db) : IFeedUsageRepository, + ICurrencyBoundRowSource { + // Every usage stores an estimated cost. + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.FeedUsages.AnyAsync(ct); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.FeedUsages.FirstOrDefaultAsync(u => u.Id == id, ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/InventoryItemRepository.cs b/src/Cluckwork.Infrastructure/Repositories/InventoryItemRepository.cs index 701b40c30..6c0985a2f 100644 --- a/src/Cluckwork.Infrastructure/Repositories/InventoryItemRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/InventoryItemRepository.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Inventory; using Cluckwork.Domain.Inventory; using Cluckwork.Infrastructure.Persistence; @@ -5,8 +6,14 @@ namespace Cluckwork.Infrastructure.Repositories; -public sealed class InventoryItemRepository(AppDbContext db) : IInventoryItemRepository +public sealed class InventoryItemRepository(AppDbContext db) : IInventoryItemRepository, + ICurrencyBoundRowSource { + // An item's default cost is what a purchase falls back to when no cost is + // given, which would stamp a new lot in the old currency after a change. + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.InventoryItems.AnyAsync(i => i.DefaultUnitCost != null, ct); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.InventoryItems.FirstOrDefaultAsync(i => i.Id == id, ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs b/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs index b03641f26..206a2fe0f 100644 --- a/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Inventory; using Cluckwork.Domain.Inventory; using Cluckwork.Infrastructure.Persistence; @@ -5,8 +6,14 @@ namespace Cluckwork.Infrastructure.Repositories; -public sealed class InventoryLotRepository(AppDbContext db) : IInventoryLotRepository +public sealed class InventoryLotRepository(AppDbContext db) : IInventoryLotRepository, + ICurrencyBoundRowSource { + // Every lot stores a unit cost; costs from two denominations would be summed + // as though they were one. + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.InventoryLots.AnyAsync(ct); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.InventoryLots.FirstOrDefaultAsync(l => l.Id == id, ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs b/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs index 3415d8a74..01fefac41 100644 --- a/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Sales; using Cluckwork.Domain.Sales; using Cluckwork.Infrastructure.Persistence; @@ -5,8 +6,12 @@ namespace Cluckwork.Infrastructure.Repositories; -public sealed class PaymentRepository(AppDbContext db) : IPaymentRepository +public sealed class PaymentRepository(AppDbContext db) : IPaymentRepository, + ICurrencyBoundRowSource { + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.Payments.AnyAsync(ct); + // Reads rely on the tenant query filter (AccountId == current tenant). public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.Payments.FirstOrDefaultAsync(p => p.Id == id, ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs b/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs index f1546e26e..c710bd9fc 100644 --- a/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs @@ -1,3 +1,4 @@ +using Cluckwork.Application.Features.Accounts; using Cluckwork.Application.Features.Sales; using Cluckwork.Domain.Sales; using Cluckwork.Infrastructure.Persistence; @@ -5,8 +6,12 @@ namespace Cluckwork.Infrastructure.Repositories; -public sealed class SalesOrderRepository(AppDbContext db) : ISalesOrderRepository +public sealed class SalesOrderRepository(AppDbContext db) : ISalesOrderRepository, + ICurrencyBoundRowSource { + Task ICurrencyBoundRowSource.AnyAsync(CancellationToken ct) => + db.SalesOrders.AnyAsync(ct); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => db.SalesOrders .Include(o => o.Items.OrderBy(i => i.CreatedAtUtc).ThenBy(i => EF.Property(i, "Sequence"))) diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json index 307bcc9df..e81db8971 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json @@ -1683,22 +1683,6 @@ "reason": "Checks WaterUsages for a fixture row per flock and date before calling IInventoryModule.RecordWaterUsageAsync, so a re-run converges instead of duplicating. IInventoryModule has no existence read. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", "deleteWhen": "#858" }, - { - "symbol": "Cluckwork.Infrastructure.Repositories.CurrencyBoundRowProbe.AnyAsync", - "reaches": "Finance", - "tables": ["Expenses"], - "owner": "Farm", - "reason": "Farm's currency lock: any expense carries an amount in the farm currency, so it blocks a currency change. The same probe reads Commerce and General Inventory tables, so it can only move behind module contracts once the last of them has one, the Commerce contract slice.", - "deleteWhen": "#854" - }, - { - "symbol": "Cluckwork.Infrastructure.Repositories.CurrencyBoundRowProbe.AnyAsync", - "reaches": "GeneralInventory", - "tables": ["FeedUsages", "InventoryItems", "InventoryLots"], - "owner": "Farm", - "reason": "Farm's currency lock: every lot and feed usage, and any item with a default cost, carries an amount in the farm currency, so it blocks a currency change. The same probe reads Commerce, which has no contract yet, so it moves behind module contracts with the last of them, the Commerce contract slice.", - "deleteWhen": "#854" - }, { "symbol": "Cluckwork.Infrastructure.Repositories.UserRoleAssignmentRepository.ListByNameByUserAsync", "reaches": "FlockManagement", From b16790d2c4dfff53b20b838798d423ef91290d4a Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 04:44:39 +0000 Subject: [PATCH 03/11] refactor(sales): put Commerce behind an ICommerceModule contract Product, customer, sale and payment endpoints and both seeders reach Commerce only through ICommerceModule, which forwards to the existing handlers and repositories and copies reads into *Details records. Farm's settings handler and Access's stepper-unit handler read packed-unit conversions through the new IEggUnitConversionLookup port. The ledger lists the 34 contract types and six repository implementations. The eight seeder reads of Commerce tables are compatibility exceptions for #858. Refs #854 --- .../Endpoints/Catalog/ProductEndpoints.cs | 38 +++-- .../Endpoints/Customers/CustomerEndpoints.cs | 20 +-- .../Endpoints/Sales/PaymentEndpoints.cs | 25 ++- .../Endpoints/Sales/SaleEndpoints.cs | 52 +++---- ...kworkFeatureServiceCollectionExtensions.cs | 6 + .../UpdateFarmSettingsHandler.cs | 2 +- .../Catalog/EggUnitConversionLookup.cs | 12 ++ .../Catalog/IEggUnitConversionLookup.cs | 12 ++ .../Features/Sales/CommerceModule.cs | 147 ++++++++++++++++++ .../Features/Sales/ICommerceModule.cs | 108 +++++++++++++ .../SetStepperUnit/SetStepperUnitHandler.cs | 2 +- .../Persistence/DemoDataSeeder.cs | 31 ++-- .../Persistence/SimulationDataSeeder.cs | 22 ++- .../Architecture/Data/module-ledger.json | 110 ++++++++++++- 14 files changed, 482 insertions(+), 105 deletions(-) create mode 100644 src/Cluckwork.Application/Features/Catalog/EggUnitConversionLookup.cs create mode 100644 src/Cluckwork.Application/Features/Catalog/IEggUnitConversionLookup.cs create mode 100644 src/Cluckwork.Application/Features/Sales/CommerceModule.cs create mode 100644 src/Cluckwork.Application/Features/Sales/ICommerceModule.cs diff --git a/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs b/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs index b7db71d59..ee8c9b441 100644 --- a/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs @@ -1,10 +1,9 @@ using Cluckwork.Api.Validation; using Cluckwork.Application.Features.Catalog; using Cluckwork.Application.Features.Catalog.CreateProduct; -using Cluckwork.Application.Features.Catalog.SetProductActive; using Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion; using Cluckwork.Application.Features.Catalog.UpdateProduct; -using Cluckwork.Domain.Catalog; +using Cluckwork.Application.Features.Sales; using Cluckwork.Infrastructure.Persistence; using FluentValidation; @@ -32,13 +31,13 @@ public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group .RequireAuthorization(AuthPolicies.AdminOnly); group.MapPost("/{id:guid}/deactivate", - (Guid id, SetProductActiveHandler h, TenantContext t, CancellationToken ct) => SetActive(id, false, h, t, ct)) + (Guid id, ICommerceModule commerce, TenantContext t, CancellationToken ct) => SetActive(id, false, commerce, t, ct)) .WithName("DeactivateProduct") .WithSummary("Deactivate a product: it leaves sale pickers; history is unaffected.") .RequireAuthorization(AuthPolicies.AdminOnly); group.MapPost("/{id:guid}/activate", - (Guid id, SetProductActiveHandler h, TenantContext t, CancellationToken ct) => SetActive(id, true, h, t, ct)) + (Guid id, ICommerceModule commerce, TenantContext t, CancellationToken ct) => SetActive(id, true, commerce, t, ct)) .WithName("ActivateProduct") .WithSummary("Reactivate a previously deactivated product.") .RequireAuthorization(AuthPolicies.AdminOnly); @@ -61,17 +60,16 @@ public static RouteGroupBuilder MapEggUnitConversionEndpoints(this RouteGroupBui } private static async Task ListProducts( - IProductRepository products, TenantContext tenant, CancellationToken ct, + ICommerceModule commerce, TenantContext tenant, CancellationToken ct, bool includeInactive = false) { if (!tenant.IsResolved) return Results.Unauthorized(); - var list = await products.ListAsync(includeInactive, ct); - var mappings = (await products.ListMappingsAsync(ct)).ToDictionary(m => m.ProductId, m => m.EggGradeId); - return Results.Ok(list.Select(p => ToResponse(p, mappings.GetValueOrDefault(p.Id)))); + var list = await commerce.ListProductsAsync(includeInactive, ct); + return Results.Ok(list.Select(ToResponse)); } private static async Task CreateProduct( - CreateProductRequest request, CreateProductHandler handler, + CreateProductRequest request, ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); @@ -84,14 +82,14 @@ private static async Task CreateProduct( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, tenant.AccountId, ct); + var result = await commerce.CreateProductAsync(command, tenant.AccountId, ct); return result.IsSuccess ? Results.Created($"/api/v1/products/{result.Value}", new { Id = result.Value }) : MapFailure(result.Error); } private static async Task UpdateProduct( - Guid id, UpdateProductRequest request, UpdateProductHandler handler, + Guid id, UpdateProductRequest request, ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); @@ -104,30 +102,30 @@ private static async Task UpdateProduct( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, ct); + var result = await commerce.UpdateProductAsync(command, ct); return result.IsSuccess ? Results.NoContent() : MapFailure(result.Error); } private static async Task SetActive( - Guid id, bool active, SetProductActiveHandler handler, TenantContext tenant, CancellationToken ct) + Guid id, bool active, ICommerceModule commerce, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var result = await handler.HandleAsync(id, active, ct); + var result = await commerce.SetProductActiveAsync(id, active, ct); return result.IsSuccess ? Results.NoContent() : MapFailure(result.Error); } private static async Task ListConversions( - IEggUnitConversionRepository conversions, TenantContext tenant, CancellationToken ct) + ICommerceModule commerce, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var list = await conversions.ListAsync(ct); + var list = await commerce.ListConversionsAsync(ct); return Results.Ok(list.Select(c => new EggUnitConversionResponse(c.Id, c.UnitCode.ToString(), c.EggsPerUnit, c.Active, c.Version))); } private static async Task UpdateConversion( Guid id, UpdateEggUnitConversionRequest request, - UpdateEggUnitConversionHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) { @@ -138,7 +136,7 @@ private static async Task UpdateConversion( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, ct); + var result = await commerce.UpdateConversionAsync(command, ct); return result.IsSuccess ? Results.NoContent() : MapFailure(result.Error); } @@ -151,10 +149,10 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error) : Results.Problem(error.Description, statusCode: 422, title: error.Code); } - private static ProductResponse ToResponse(Product p, Guid? eggGradeId) => + private static ProductResponse ToResponse(ProductDetails p) => new(p.Id, p.Name, p.ProductType.ToString(), p.DefaultUnit.ToString(), p.DefaultPriceMinorUnits, p.CurrencyCode, p.CurrencyMinorUnit, - eggGradeId, p.Notes, p.Active, p.Version); + p.EggGradeId, p.Notes, p.Active, p.Version); } public sealed record ProductResponse( diff --git a/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs b/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs index 5585d8048..76d5116ac 100644 --- a/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs @@ -2,7 +2,7 @@ using Cluckwork.Application.Features.Customers; using Cluckwork.Application.Features.Customers.CreateCustomer; using Cluckwork.Application.Features.Customers.UpdateCustomer; -using Cluckwork.Domain.Sales; +using Cluckwork.Application.Features.Sales; using Cluckwork.Infrastructure.Persistence; using FluentValidation; @@ -42,7 +42,7 @@ public static RouteGroupBuilder MapCustomerEndpoints(this RouteGroupBuilder grou } private static async Task UpdateCustomer( - Guid id, UpdateCustomerRequest request, UpdateCustomerHandler handler, + Guid id, UpdateCustomerRequest request, ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); @@ -50,7 +50,7 @@ private static async Task UpdateCustomer( id, request.Version, request.Name, request.Phone, request.Email, request.Address, request.Note); var validation = await validator.ValidateAsync(command, ct); if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, ct); + var result = await commerce.UpdateCustomerAsync(command, ct); return result.IsSuccess ? Results.NoContent() : result.Error.Code == "Customer.VersionMismatch" ? Results.Problem(result.Error.Description, statusCode: StatusCodes.Status409Conflict, title: result.Error.Code) @@ -61,7 +61,7 @@ private static async Task UpdateCustomer( private static async Task CreateCustomer( CreateCustomerRequest request, - CreateCustomerHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) @@ -75,14 +75,14 @@ private static async Task CreateCustomer( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, tenant.AccountId, ct); + var result = await commerce.CreateCustomerAsync(command, tenant.AccountId, ct); return result.IsSuccess ? Results.Created($"/api/v1/customers/{result.Value}", new { Id = result.Value }) : Results.Problem(result.Error.Description, statusCode: 422, title: result.Error.Code); } private static async Task ListCustomers( - ICustomerRepository customers, TenantContext tenant, CancellationToken ct, + ICommerceModule commerce, TenantContext tenant, CancellationToken ct, string? search = null, int? limit = null, int? offset = null) { if (!tenant.IsResolved) return Results.Unauthorized(); @@ -93,19 +93,19 @@ private static async Task ListCustomers( // #512 — additive `search` only: trimming and the literal wildcard // handling live in the repository, and every existing caller that // omits it lands on the same query it always ran (#512 compatibility). - var list = await customers.SearchAsync(search, take, skip, ct); + var list = await commerce.SearchCustomersAsync(search, take, skip, ct); return Results.Ok(list.Select(ToResponse)); } private static async Task GetCustomer( - Guid id, ICustomerRepository customers, TenantContext tenant, CancellationToken ct) + Guid id, ICommerceModule commerce, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var customer = await customers.GetByIdAsync(id, ct); + var customer = await commerce.GetCustomerAsync(id, ct); return customer is null ? Results.NotFound() : Results.Ok(ToResponse(customer)); } - private static CustomerResponse ToResponse(Customer c) => + private static CustomerResponse ToResponse(CustomerDetails c) => new(c.Id, c.Name, c.Phone, c.Email, c.Address, c.Note, c.Version); } diff --git a/src/Cluckwork.Api/Endpoints/Sales/PaymentEndpoints.cs b/src/Cluckwork.Api/Endpoints/Sales/PaymentEndpoints.cs index 9b47bb13d..044420eb8 100644 --- a/src/Cluckwork.Api/Endpoints/Sales/PaymentEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Sales/PaymentEndpoints.cs @@ -2,7 +2,6 @@ using Cluckwork.Application.Features.Sales; using Cluckwork.Application.Features.Sales.RecordPayment; using Cluckwork.Application.Features.Sales.VoidPayment; -using Cluckwork.Domain.Sales; using Cluckwork.Infrastructure.Persistence; using FluentValidation; @@ -56,17 +55,16 @@ public static RouteGroupBuilder MapCustomerBalanceEndpoints(this RouteGroupBuild private static async Task ListOrderPayments( Guid id, - IPaymentRepository payments, - ISalesOrderRepository orders, + ICommerceModule commerce, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var order = await orders.GetReadOnlyAsync(id, ct); + var order = await commerce.GetSalesOrderAsync(id, ct); if (order is null) return Results.NotFound(); - var list = await payments.ListByOrderAsync(id, ct); + var list = await commerce.ListOrderPaymentsAsync(id, ct); var paid = list.Where(p => !p.Voided).Sum(p => p.AmountMinorUnits); return Results.Ok(new OrderPaymentsResponse( list.Select(ToResponse).ToList(), @@ -80,7 +78,7 @@ private static async Task ListOrderPayments( private static async Task RecordPayment( Guid id, RecordPaymentRequest request, - RecordPaymentHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) @@ -95,7 +93,7 @@ private static async Task RecordPayment( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, tenant.AccountId, ct); + var result = await commerce.RecordPaymentAsync(command, tenant.AccountId, ct); return result.IsSuccess ? Results.Created($"/api/v1/sales/{id}/payments", new { Id = result.Value }) : MapFailure(result.Error); @@ -104,9 +102,8 @@ private static async Task RecordPayment( private static async Task VoidPayment( Guid id, VoidPaymentRequest request, - VoidPaymentHandler handler, + ICommerceModule commerce, IValidator validator, - IPaymentRepository payments, TenantContext tenant, CancellationToken ct) { @@ -117,22 +114,22 @@ private static async Task VoidPayment( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, ct); + var result = await commerce.VoidPaymentAsync(command, ct); if (result.IsFailure) return MapFailure(result.Error); - var updated = await payments.GetByIdAsync(id, ct); + var updated = await commerce.GetPaymentAsync(id, ct); return updated is null ? Results.NotFound() : Results.Ok(ToResponse(updated)); } private static async Task ListCustomerBalances( - IPaymentRepository payments, + ICommerceModule commerce, Cluckwork.Application.Features.Accounts.IFarmModule farm, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var rows = await payments.ListCustomerBalancesAsync(ct); + var rows = await commerce.ListCustomerBalancesAsync(ct); var settings = await farm.GetSettingsAsync(ct); return Results.Ok(new CustomerBalancesResponse( rows.Select(r => new CustomerBalanceResponse( @@ -155,7 +152,7 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error) : Results.Problem(error.Description, statusCode: 422, title: error.Code); } - private static PaymentResponse ToResponse(Payment p) => + private static PaymentResponse ToResponse(PaymentDetails p) => new(p.Id, p.SalesOrderId, p.CustomerId, p.PaymentDate, p.AmountMinorUnits, p.CurrencyCode, p.CurrencyMinorUnit, p.Method.ToString(), p.ReferenceNumber, p.Note, p.Voided, p.VoidReason, p.Version); diff --git a/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs b/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs index c1663572e..b80149d95 100644 --- a/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs @@ -3,10 +3,8 @@ using Cluckwork.Application.Features.Audit; using Cluckwork.Application.Features.Sales; using Cluckwork.Application.Features.Sales.AddOrderItem; -using Cluckwork.Application.Features.Sales.CancelSalesOrder; using Cluckwork.Application.Features.Sales.ConfirmSale; using Cluckwork.Application.Features.Sales.CreateSalesOrder; -using Cluckwork.Application.Features.Sales.RemoveOrderItem; using Cluckwork.Application.Features.Sales.UpdateOrderItem; using Cluckwork.Application.Features.Sales.VoidSale; using FluentValidation; @@ -89,7 +87,7 @@ public static RouteGroupBuilder MapSaleEndpoints(this RouteGroupBuilder group) private static async Task CreateSalesOrder( CreateSalesOrderRequest request, - CreateSalesOrderHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) @@ -101,7 +99,7 @@ private static async Task CreateSalesOrder( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, tenant.AccountId, ct); + var result = await commerce.CreateSalesOrderAsync(command, tenant.AccountId, ct); if (result.IsSuccess) return Results.Created($"/api/v1/sales/{result.Value}", new { Id = result.Value }); return result.Error.Code.EndsWith(".NotFound", StringComparison.Ordinal) @@ -112,7 +110,7 @@ private static async Task CreateSalesOrder( private static async Task AddOrderItem( Guid id, AddOrderItemRequest request, - AddOrderItemHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) @@ -127,7 +125,7 @@ private static async Task AddOrderItem( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, tenant.AccountId, ct); + var result = await commerce.AddOrderItemAsync(command, tenant.AccountId, ct); if (result.IsSuccess) return Results.Created($"/api/v1/sales/{id}", new { OrderId = id, ItemId = result.Value }); if (result.Error.Code.EndsWith(".NotFound", StringComparison.Ordinal)) @@ -151,7 +149,7 @@ private static async Task UpdateOrderItem( Guid id, Guid itemId, UpdateOrderItemRequest request, - UpdateOrderItemHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) @@ -163,31 +161,31 @@ private static async Task UpdateOrderItem( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, ct); + var result = await commerce.UpdateOrderItemAsync(command, ct); return result.IsSuccess ? Results.NoContent() : MapItemMutationFailure(result.Error); } private static async Task RemoveOrderItem( Guid id, Guid itemId, - RemoveOrderItemHandler handler, + ICommerceModule commerce, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var result = await handler.HandleAsync(id, itemId, ct); + var result = await commerce.RemoveOrderItemAsync(id, itemId, ct); return result.IsSuccess ? Results.NoContent() : MapItemMutationFailure(result.Error); } private static async Task CancelSalesOrder( Guid id, - CancelSalesOrderHandler handler, + ICommerceModule commerce, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var result = await handler.HandleAsync(id, ct); + var result = await commerce.CancelSalesOrderAsync(id, ct); if (result.IsSuccess) return Results.NoContent(); if (result.Error.Code.EndsWith(".NotFound", StringComparison.Ordinal)) return Results.NotFound(); @@ -197,17 +195,16 @@ private static async Task CancelSalesOrder( } private static async Task GetSalesOrder( - Guid id, ISalesOrderRepository orders, ICustomerRepository customers, IEggGradeLookup grades, - IInsightsModule audit, IPaymentRepository payments, + Guid id, ICommerceModule commerce, IEggGradeLookup grades, IInsightsModule audit, IAuthorizationService authorization, ClaimsPrincipal caller, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); - var order = await orders.GetReadOnlyAsync(id, ct); + var order = await commerce.GetSalesOrderAsync(id, ct); if (order is null) return Results.NotFound(); - var provenance = await audit.GetProvenanceAsync(nameof(SalesOrder), [id], ct); + var provenance = await audit.GetProvenanceAsync(ICommerceModule.SalesOrderAuditEntityType, [id], ct); // Detail and list must answer identically (#512): same read, one id. - var customer = (await customers.GetDisplayNamesAsync([order.CustomerId], ct)) + var customer = (await commerce.GetCustomerNamesAsync([order.CustomerId], ct)) .GetValueOrDefault(order.CustomerId); // #769 — same figure, same tier, same NULL-off-Confirmed rule as the // list. Leaving this null while the list carries it would make the two @@ -215,7 +212,7 @@ private static async Task GetSalesOrder( long? outstanding = null; if (order.Status == SalesOrderStatus.Confirmed && await MaySeeMoneyAsync(authorization, caller)) outstanding = order.TotalAmount.MinorUnits - - await payments.SumNonVoidedByOrderAsync(id, ct); + - await commerce.SumNonVoidedPaymentsAsync(id, ct); var gradeNames = await grades.GetDisplayNamesAsync(order.Items.Select(i => i.EggGradeId).Distinct().ToList(), ct); return Results.Ok(ToResponse( order, provenance.GetValueOrDefault(id), gradeNames, customer, outstanding)); @@ -243,8 +240,7 @@ private static async Task MaySeeMoneyAsync( (await authorization.AuthorizeAsync(caller, AuthPolicies.SalesAccess)).Succeeded; private static async Task ListSalesOrders( - ISalesOrderRepository orders, - Cluckwork.Application.Features.Customers.ICustomerRepository customers, + ICommerceModule commerce, IEggGradeLookup grades, IInsightsModule audit, IAuthorizationService authorization, ClaimsPrincipal caller, @@ -286,16 +282,16 @@ private static async Task ListSalesOrders( : unpaid == true ? SettlementScope.UnpaidOnly : SettlementScope.Visible; - var list = await orders.ListAsync( + var list = await commerce.ListSalesOrdersAsync( new SalesOrderListFilter(statusFilter, customerId, from, to, settlement), take, skip, ct); var provenance = await audit.GetProvenanceAsync( - nameof(SalesOrder), list.Select(r => r.Order.Id).ToList(), ct); + ICommerceModule.SalesOrderAuditEntityType, list.Select(r => r.Order.Id).ToList(), ct); // #512 T048 — one scoped bulk customer read for the page, not one per // order. A missing key means the customer left this tenant, which the // tenant filter makes unreachable on a scoped route; the row then carries // a null name rather than an identifier fragment. - var names = await customers.GetDisplayNamesAsync( + var names = await commerce.GetCustomerNamesAsync( list.Select(r => r.Order.CustomerId).ToList(), ct); var gradeNames = await grades.GetDisplayNamesAsync( list.SelectMany(r => r.Order.Items).Select(i => i.EggGradeId).Distinct().ToList(), ct); @@ -305,7 +301,7 @@ private static async Task ListSalesOrders( } private static SalesOrderResponse ToResponse( - SalesOrder o, EntityProvenance? p, IReadOnlyDictionary gradeNames, + SalesOrderDetails o, EntityProvenance? p, IReadOnlyDictionary gradeNames, CustomerReference? customer = null, long? outstandingMinorUnits = null) => new( o.Id, o.CustomerId, o.ReferenceNumber, o.OrderDate, o.Status.ToString(), @@ -325,7 +321,7 @@ private static SalesOrderResponse ToResponse( private static async Task VoidSale( Guid id, VoidSaleRequest request, - VoidSaleHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, CancellationToken ct) @@ -338,7 +334,7 @@ private static async Task VoidSale( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync(command, tenant.AccountId, ct); + var result = await commerce.VoidSaleAsync(command, tenant.AccountId, ct); if (result.IsSuccess) return Results.Ok(result.Value); @@ -358,7 +354,7 @@ private static async Task VoidSale( private static async Task ConfirmSale( Guid id, ConfirmSaleRequest? request, - ConfirmSaleHandler handler, + ICommerceModule commerce, IValidator validator, TenantContext tenant, ICurrentUser currentUser, @@ -373,7 +369,7 @@ private static async Task ConfirmSale( if (!validation.IsValid) return ValidationResponse.Problem(validation); - var result = await handler.HandleAsync( + var result = await commerce.ConfirmSaleAsync( command, tenant.AccountId, currentUser.UserId, ct); // TenantMismatch is surfaced as NotFound to avoid revealing that the diff --git a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs index d0b0dfa88..44fc82657 100644 --- a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs +++ b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs @@ -298,6 +298,12 @@ private static void AddHandlers(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped< + Cluckwork.Application.Features.Sales.ICommerceModule, + Cluckwork.Application.Features.Sales.CommerceModule>(); + services.AddScoped< + Cluckwork.Application.Features.Catalog.IEggUnitConversionLookup, + Cluckwork.Application.Features.Catalog.EggUnitConversionLookup>(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Cluckwork.Application/Features/Accounts/UpdateFarmSettings/UpdateFarmSettingsHandler.cs b/src/Cluckwork.Application/Features/Accounts/UpdateFarmSettings/UpdateFarmSettingsHandler.cs index 607b82bf8..320ebe15e 100644 --- a/src/Cluckwork.Application/Features/Accounts/UpdateFarmSettings/UpdateFarmSettingsHandler.cs +++ b/src/Cluckwork.Application/Features/Accounts/UpdateFarmSettings/UpdateFarmSettingsHandler.cs @@ -9,7 +9,7 @@ namespace Cluckwork.Application.Features.Accounts.UpdateFarmSettings; public sealed class UpdateFarmSettingsHandler( IAccountRepository accounts, ICurrencyBoundRowProbe currencyBoundRows, - IEggUnitConversionRepository conversions, + IEggUnitConversionLookup conversions, IUnitOfWork unitOfWork, IAuditWriter audit) { diff --git a/src/Cluckwork.Application/Features/Catalog/EggUnitConversionLookup.cs b/src/Cluckwork.Application/Features/Catalog/EggUnitConversionLookup.cs new file mode 100644 index 000000000..192362d24 --- /dev/null +++ b/src/Cluckwork.Application/Features/Catalog/EggUnitConversionLookup.cs @@ -0,0 +1,12 @@ +using Cluckwork.Domain.Catalog; + +namespace Cluckwork.Application.Features.Catalog; + +public sealed class EggUnitConversionLookup(IEggUnitConversionRepository conversions) : IEggUnitConversionLookup +{ + public async Task GetByUnitAsync(EggUnit unit, CancellationToken ct) => + await conversions.GetByUnitAsync(unit, ct) is { } conversion ? ToDetails(conversion) : null; + + internal static EggUnitConversionDetails ToDetails(EggUnitConversion c) => + new(c.Id, c.UnitCode, c.EggsPerUnit, c.Active, c.Version); +} diff --git a/src/Cluckwork.Application/Features/Catalog/IEggUnitConversionLookup.cs b/src/Cluckwork.Application/Features/Catalog/IEggUnitConversionLookup.cs new file mode 100644 index 000000000..c3db20d4f --- /dev/null +++ b/src/Cluckwork.Application/Features/Catalog/IEggUnitConversionLookup.cs @@ -0,0 +1,12 @@ +using Cluckwork.Domain.Catalog; + +namespace Cluckwork.Application.Features.Catalog; + +// #854: Commerce's packed-unit read port for peer modules. Farm settings and a +// user's stepper preference may point only at an active conversion. +public interface IEggUnitConversionLookup +{ + Task GetByUnitAsync(EggUnit unit, CancellationToken ct); +} + +public sealed record EggUnitConversionDetails(Guid Id, EggUnit UnitCode, int EggsPerUnit, bool Active, int Version); diff --git a/src/Cluckwork.Application/Features/Sales/CommerceModule.cs b/src/Cluckwork.Application/Features/Sales/CommerceModule.cs new file mode 100644 index 000000000..4ad0b5d80 --- /dev/null +++ b/src/Cluckwork.Application/Features/Sales/CommerceModule.cs @@ -0,0 +1,147 @@ +using Cluckwork.Application.Features.Catalog; +using Cluckwork.Application.Features.Catalog.CreateProduct; +using Cluckwork.Application.Features.Catalog.SetProductActive; +using Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion; +using Cluckwork.Application.Features.Catalog.UpdateProduct; +using Cluckwork.Application.Features.Customers; +using Cluckwork.Application.Features.Customers.CreateCustomer; +using Cluckwork.Application.Features.Customers.UpdateCustomer; +using Cluckwork.Application.Features.Sales.AddOrderItem; +using Cluckwork.Application.Features.Sales.CancelSalesOrder; +using Cluckwork.Application.Features.Sales.ConfirmSale; +using Cluckwork.Application.Features.Sales.CreateSalesOrder; +using Cluckwork.Application.Features.Sales.RecordPayment; +using Cluckwork.Application.Features.Sales.RemoveOrderItem; +using Cluckwork.Application.Features.Sales.UpdateOrderItem; +using Cluckwork.Application.Features.Sales.VoidPayment; +using Cluckwork.Application.Features.Sales.VoidSale; +using Cluckwork.Domain.Catalog; +using Cluckwork.Domain.Sales; + +namespace Cluckwork.Application.Features.Sales; + +public sealed class CommerceModule( + IProductRepository products, + IEggUnitConversionRepository conversions, + ICustomerRepository customers, + ISalesOrderRepository orders, + IPaymentRepository payments, + CreateProductHandler createProduct, + UpdateProductHandler updateProduct, + SetProductActiveHandler setProductActive, + UpdateEggUnitConversionHandler updateConversion, + CreateCustomerHandler createCustomer, + UpdateCustomerHandler updateCustomer, + CreateSalesOrderHandler createOrder, + AddOrderItemHandler addItem, + UpdateOrderItemHandler updateItem, + RemoveOrderItemHandler removeItem, + CancelSalesOrderHandler cancelOrder, + ConfirmSaleHandler confirmSale, + VoidSaleHandler voidSale, + RecordPaymentHandler recordPayment, + VoidPaymentHandler voidPayment) : ICommerceModule +{ + public async Task> ListProductsAsync(bool includeInactive, CancellationToken ct) + { + var list = await products.ListAsync(includeInactive, ct); + var grades = (await products.ListMappingsAsync(ct)).ToDictionary(m => m.ProductId, m => m.EggGradeId); + return list.Select(p => new ProductDetails( + p.Id, p.Name, p.ProductType, p.DefaultUnit, p.DefaultPriceMinorUnits, p.CurrencyCode, + p.CurrencyMinorUnit, grades.GetValueOrDefault(p.Id), p.Notes, p.Active, p.Version)).ToList(); + } + + public Task> CreateProductAsync(CreateProductCommand command, Guid accountId, CancellationToken ct) => + createProduct.HandleAsync(command, accountId, ct); + + public Task UpdateProductAsync(UpdateProductCommand command, CancellationToken ct) => + updateProduct.HandleAsync(command, ct); + + public Task SetProductActiveAsync(Guid id, bool active, CancellationToken ct) => + setProductActive.HandleAsync(id, active, ct); + + public async Task> ListConversionsAsync(CancellationToken ct) => + (await conversions.ListAsync(ct)).Select(EggUnitConversionLookup.ToDetails).ToList(); + + public Task UpdateConversionAsync(UpdateEggUnitConversionCommand command, CancellationToken ct) => + updateConversion.HandleAsync(command, ct); + + public Task> CreateCustomerAsync(CreateCustomerCommand command, Guid accountId, CancellationToken ct) => + createCustomer.HandleAsync(command, accountId, ct); + + public Task UpdateCustomerAsync(UpdateCustomerCommand command, CancellationToken ct) => + updateCustomer.HandleAsync(command, ct); + + public async Task> SearchCustomersAsync( + string? search, int limit, int offset, CancellationToken ct) => + (await customers.SearchAsync(search, limit, offset, ct)).Select(ToDetails).ToList(); + + public async Task GetCustomerAsync(Guid id, CancellationToken ct) => + await customers.GetByIdAsync(id, ct) is { } customer ? ToDetails(customer) : null; + + public Task> GetCustomerNamesAsync( + IReadOnlyCollection customerIds, CancellationToken ct) => + customers.GetDisplayNamesAsync(customerIds, ct); + + public Task> CreateSalesOrderAsync(CreateSalesOrderCommand command, Guid accountId, CancellationToken ct) => + createOrder.HandleAsync(command, accountId, ct); + + public Task> AddOrderItemAsync(AddOrderItemCommand command, Guid accountId, CancellationToken ct) => + addItem.HandleAsync(command, accountId, ct); + + public Task UpdateOrderItemAsync(UpdateOrderItemCommand command, CancellationToken ct) => + updateItem.HandleAsync(command, ct); + + public Task RemoveOrderItemAsync(Guid orderId, Guid itemId, CancellationToken ct) => + removeItem.HandleAsync(orderId, itemId, ct); + + public Task CancelSalesOrderAsync(Guid orderId, CancellationToken ct) => + cancelOrder.HandleAsync(orderId, ct); + + public Task> ConfirmSaleAsync( + ConfirmSaleCommand command, Guid accountId, Guid actingUserId, CancellationToken ct) => + confirmSale.HandleAsync(command, accountId, actingUserId, ct); + + public Task> VoidSaleAsync(VoidSaleCommand command, Guid accountId, CancellationToken ct) => + voidSale.HandleAsync(command, accountId, ct); + + public async Task GetSalesOrderAsync(Guid id, CancellationToken ct) => + await orders.GetReadOnlyAsync(id, ct) is { } order ? ToDetails(order) : null; + + public async Task> ListSalesOrdersAsync( + SalesOrderListFilter filter, int limit, int offset, CancellationToken ct) => + (await orders.ListAsync(filter, limit, offset, ct)) + .Select(r => new SalesOrderListItem(ToDetails(r.Order), r.OutstandingMinorUnits)).ToList(); + + public Task> RecordPaymentAsync(RecordPaymentCommand command, Guid accountId, CancellationToken ct) => + recordPayment.HandleAsync(command, accountId, ct); + + public Task VoidPaymentAsync(VoidPaymentCommand command, CancellationToken ct) => + voidPayment.HandleAsync(command, ct); + + public async Task GetPaymentAsync(Guid id, CancellationToken ct) => + await payments.GetByIdAsync(id, ct) is { } payment ? ToDetails(payment) : null; + + public async Task> ListOrderPaymentsAsync(Guid salesOrderId, CancellationToken ct) => + (await payments.ListByOrderAsync(salesOrderId, ct)).Select(ToDetails).ToList(); + + public Task SumNonVoidedPaymentsAsync(Guid salesOrderId, CancellationToken ct) => + payments.SumNonVoidedByOrderAsync(salesOrderId, ct); + + public Task> ListCustomerBalancesAsync(CancellationToken ct) => + payments.ListCustomerBalancesAsync(ct); + + private static CustomerDetails ToDetails(Customer c) => + new(c.Id, c.Name, c.Phone, c.Email, c.Address, c.Note, c.Version); + + private static SalesOrderDetails ToDetails(SalesOrder o) => + new(o.Id, o.CustomerId, o.ReferenceNumber, o.OrderDate, o.Status, o.TotalAmount, o.VoidReason, + o.Items.Select(i => new SalesOrderItemDetails( + i.Id, i.ProductId, i.EggGradeId, i.Unit, i.BaseUnitFactor, i.Quantity, i.QuantityBase, + i.UnitPrice, i.ListUnitPriceMinorUnits, i.ListPriceBasis)).ToList(), + o.DiscountReasonCode, o.DiscountReasonNote); + + private static PaymentDetails ToDetails(Payment p) => + new(p.Id, p.SalesOrderId, p.CustomerId, p.PaymentDate, p.AmountMinorUnits, p.CurrencyCode, + p.CurrencyMinorUnit, p.Method, p.ReferenceNumber, p.Note, p.Voided, p.VoidReason, p.Version); +} diff --git a/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs b/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs new file mode 100644 index 000000000..969c7c0ab --- /dev/null +++ b/src/Cluckwork.Application/Features/Sales/ICommerceModule.cs @@ -0,0 +1,108 @@ +using Cluckwork.Application.Features.Catalog; +using Cluckwork.Application.Features.Catalog.CreateProduct; +using Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion; +using Cluckwork.Application.Features.Catalog.UpdateProduct; +using Cluckwork.Application.Features.Customers; +using Cluckwork.Application.Features.Customers.CreateCustomer; +using Cluckwork.Application.Features.Customers.UpdateCustomer; +using Cluckwork.Application.Features.Sales.AddOrderItem; +using Cluckwork.Application.Features.Sales.ConfirmSale; +using Cluckwork.Application.Features.Sales.CreateSalesOrder; +using Cluckwork.Application.Features.Sales.RecordPayment; +using Cluckwork.Application.Features.Sales.UpdateOrderItem; +using Cluckwork.Application.Features.Sales.VoidPayment; +using Cluckwork.Application.Features.Sales.VoidSale; +using Cluckwork.Domain.Catalog; +using Cluckwork.Domain.Sales; + +namespace Cluckwork.Application.Features.Sales; + +// #854: the Commerce contract for adapters. Adapters reach Commerce only +// through the types module-ledger.json lists under owners.Commerce.contract; +// peer modules use the narrower IEggUnitConversionLookup port. +public interface ICommerceModule +{ + // The EntityType Commerce writes on a sales order's audit rows; provenance reads key by it. + const string SalesOrderAuditEntityType = nameof(SalesOrder); + + Task> ListProductsAsync(bool includeInactive, CancellationToken ct); + + Task> CreateProductAsync(CreateProductCommand command, Guid accountId, CancellationToken ct); + + Task UpdateProductAsync(UpdateProductCommand command, CancellationToken ct); + + Task SetProductActiveAsync(Guid id, bool active, CancellationToken ct); + + Task> ListConversionsAsync(CancellationToken ct); + + Task UpdateConversionAsync(UpdateEggUnitConversionCommand command, CancellationToken ct); + + Task> CreateCustomerAsync(CreateCustomerCommand command, Guid accountId, CancellationToken ct); + + Task UpdateCustomerAsync(UpdateCustomerCommand command, CancellationToken ct); + + Task> SearchCustomersAsync( + string? search, int limit, int offset, CancellationToken ct); + + Task GetCustomerAsync(Guid id, CancellationToken ct); + + // A missing key is a customer outside the tenant or gone. + Task> GetCustomerNamesAsync( + IReadOnlyCollection customerIds, CancellationToken ct); + + Task> CreateSalesOrderAsync(CreateSalesOrderCommand command, Guid accountId, CancellationToken ct); + + Task> AddOrderItemAsync(AddOrderItemCommand command, Guid accountId, CancellationToken ct); + + Task UpdateOrderItemAsync(UpdateOrderItemCommand command, CancellationToken ct); + + Task RemoveOrderItemAsync(Guid orderId, Guid itemId, CancellationToken ct); + + Task CancelSalesOrderAsync(Guid orderId, CancellationToken ct); + + Task> ConfirmSaleAsync( + ConfirmSaleCommand command, Guid accountId, Guid actingUserId, CancellationToken ct); + + Task> VoidSaleAsync(VoidSaleCommand command, Guid accountId, CancellationToken ct); + + Task GetSalesOrderAsync(Guid id, CancellationToken ct); + + Task> ListSalesOrdersAsync( + SalesOrderListFilter filter, int limit, int offset, CancellationToken ct); + + Task> RecordPaymentAsync(RecordPaymentCommand command, Guid accountId, CancellationToken ct); + + Task VoidPaymentAsync(VoidPaymentCommand command, CancellationToken ct); + + Task GetPaymentAsync(Guid id, CancellationToken ct); + + Task> ListOrderPaymentsAsync(Guid salesOrderId, CancellationToken ct); + + Task SumNonVoidedPaymentsAsync(Guid salesOrderId, CancellationToken ct); + + Task> ListCustomerBalancesAsync(CancellationToken ct); +} + +public sealed record ProductDetails( + Guid Id, string Name, ProductType ProductType, ProductUnit DefaultUnit, long? DefaultPriceMinorUnits, + string CurrencyCode, int CurrencyMinorUnit, Guid? EggGradeId, string? Notes, bool Active, int Version); + +public sealed record CustomerDetails( + Guid Id, string Name, string Phone, string? Email, string? Address, string? Note, int Version); + +public sealed record SalesOrderDetails( + Guid Id, Guid CustomerId, string ReferenceNumber, DateOnly OrderDate, SalesOrderStatus Status, + Money TotalAmount, string? VoidReason, IReadOnlyList Items, + DiscountReasonCode? DiscountReasonCode, string? DiscountReasonNote); + +public sealed record SalesOrderItemDetails( + Guid Id, Guid ProductId, Guid EggGradeId, ProductUnit Unit, int BaseUnitFactor, int Quantity, + int QuantityBase, Money UnitPrice, long? ListUnitPriceMinorUnits, ListPriceBasis ListPriceBasis); + +// What the order still owes; see SalesOrderListRow. +public sealed record SalesOrderListItem(SalesOrderDetails Order, long? OutstandingMinorUnits); + +public sealed record PaymentDetails( + Guid Id, Guid SalesOrderId, Guid CustomerId, DateOnly PaymentDate, long AmountMinorUnits, + string CurrencyCode, int CurrencyMinorUnit, PaymentMethod Method, string? ReferenceNumber, + string? Note, bool Voided, string? VoidReason, int Version); diff --git a/src/Cluckwork.Application/Features/Users/SetStepperUnit/SetStepperUnitHandler.cs b/src/Cluckwork.Application/Features/Users/SetStepperUnit/SetStepperUnitHandler.cs index 1db54bbc7..06afa18d0 100644 --- a/src/Cluckwork.Application/Features/Users/SetStepperUnit/SetStepperUnitHandler.cs +++ b/src/Cluckwork.Application/Features/Users/SetStepperUnit/SetStepperUnitHandler.cs @@ -9,7 +9,7 @@ namespace Cluckwork.Application.Features.Users.SetStepperUnit; // only ever set their OWN stepper-unit preference. Account-scoped inside the // provider, same as SetLanguageHandler. public sealed class SetStepperUnitHandler( - IIdentityProvider identity, IEggUnitConversionRepository conversions) + IIdentityProvider identity, IEggUnitConversionLookup conversions) { public async Task HandleAsync( SetStepperUnitCommand command, Guid accountId, Guid userId, CancellationToken ct = default) diff --git a/src/Cluckwork.Infrastructure/Persistence/DemoDataSeeder.cs b/src/Cluckwork.Infrastructure/Persistence/DemoDataSeeder.cs index 85eeef3b4..33219d0a6 100644 --- a/src/Cluckwork.Infrastructure/Persistence/DemoDataSeeder.cs +++ b/src/Cluckwork.Infrastructure/Persistence/DemoDataSeeder.cs @@ -7,6 +7,7 @@ using Cluckwork.Application.Features.Flocks.CreateFlock; using Cluckwork.Application.Features.Flocks.RecordBirdMovement; using Cluckwork.Application.Features.Catalog.CreateProduct; +using Cluckwork.Application.Features.Sales; using Cluckwork.Application.Features.Sales.AddOrderItem; using Cluckwork.Application.Features.Sales.ConfirmSale; using Cluckwork.Application.Features.Sales.CreateSalesOrder; @@ -44,11 +45,7 @@ public sealed class DemoDataSeeder( IAccountUserDirectory directory, IEggOperationsModule eggs, IFlockModule flocks, - CreateProductHandler createProduct, - CreateCustomerHandler createCustomer, - CreateSalesOrderHandler createOrder, - AddOrderItemHandler addItem, - ConfirmSaleHandler confirmSale, + ICommerceModule commerce, IFarmClock farmClock, ILogger logger) { @@ -369,41 +366,41 @@ private async Task SeedDemoAsync(Guid accountId, CancellationToken ct) accountId, ct)); // --- Customers. - var mercado = Require(await createCustomer.HandleAsync(new CreateCustomerCommand( + var mercado = Require(await commerce.CreateCustomerAsync(new CreateCustomerCommand( "Mercado Central", "555-0100", "orders@mercadocentral.example", "12 Market Rd", "Pays cash"), accountId, ct)); - var kcc = Require(await createCustomer.HandleAsync(new CreateCustomerCommand( + var kcc = Require(await commerce.CreateCustomerAsync(new CreateCustomerCommand( "KCC Bakery", "555-0117", null, null, "Weekly standing order"), accountId, ct)); - Require(await createCustomer.HandleAsync(new CreateCustomerCommand( + Require(await commerce.CreateCustomerAsync(new CreateCustomerCommand( "Hotel Paraíso", "555-0142"), accountId, ct)); // --- Products (#99): sales sell products, not raw grades. Prices are // per individual egg (unit Egg → factor 1), preserving the old demo math. // The catalog is deliberately partial — Small has no product either, so // the Stock screen shows a realistic mix of sellable and unlisted grades. - var largeEggs = Require(await createProduct.HandleAsync(new CreateProductCommand( + var largeEggs = Require(await commerce.CreateProductAsync(new CreateProductCommand( "Large Eggs", "Egg", "Egg", 45, grades["Large"], null), accountId, ct)); - var mediumEggs = Require(await createProduct.HandleAsync(new CreateProductCommand( + var mediumEggs = Require(await commerce.CreateProductAsync(new CreateProductCommand( "Medium Eggs", "Egg", "Egg", 38, grades["Medium"], null), accountId, ct)); // #396: the cracked counter now mints its own lot at submit, so the demo // carries a discounted product for it — otherwise the feature reads as // stock appearing that nothing can ever sell. Priced below Small. - Require(await createProduct.HandleAsync(new CreateProductCommand( + Require(await commerce.CreateProductAsync(new CreateProductCommand( "Cracked Eggs", "Egg", "Egg", 18, grades["Cracked"], "Sold at a discount"), accountId, ct)); // --- Orders: one confirmed (exercises FIFO allocation), one open draft. - var confirmed = Require(await createOrder.HandleAsync(new CreateSalesOrderCommand( + var confirmed = Require(await commerce.CreateSalesOrderAsync(new CreateSalesOrderCommand( mercado, today.AddDays(-1)), accountId, ct)); - Require(await addItem.HandleAsync(new AddOrderItemCommand( + Require(await commerce.AddOrderItemAsync(new AddOrderItemCommand( confirmed, largeEggs, 360, null, null), accountId, ct)); - Require(await addItem.HandleAsync(new AddOrderItemCommand( + Require(await commerce.AddOrderItemAsync(new AddOrderItemCommand( confirmed, mediumEggs, 180, null, null), accountId, ct)); - Check((await confirmSale.HandleAsync(new ConfirmSaleCommand(confirmed), accountId, currentUser.UserId, ct)) + Check((await commerce.ConfirmSaleAsync(new ConfirmSaleCommand(confirmed), accountId, currentUser.UserId, ct)) is { IsSuccess: true } ? Result.Success() : Result.Failure(Error.Domain("Demo.Confirm", "confirm failed"))); - var draft = Require(await createOrder.HandleAsync(new CreateSalesOrderCommand( + var draft = Require(await commerce.CreateSalesOrderAsync(new CreateSalesOrderCommand( kcc, today), accountId, ct)); - Require(await addItem.HandleAsync(new AddOrderItemCommand( + Require(await commerce.AddOrderItemAsync(new AddOrderItemCommand( draft, largeEggs, 240, null, null), accountId, ct)); } diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs index 2d3c9516a..11a1055f3 100644 --- a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs @@ -21,6 +21,7 @@ using Cluckwork.Application.Features.Inventory.RecordFeedUsage; using Cluckwork.Application.Features.Inventory.RecordPurchase; using Cluckwork.Application.Features.Inventory.RecordWaterUsage; +using Cluckwork.Application.Features.Sales; using Cluckwork.Application.Features.Sales.AddOrderItem; using Cluckwork.Application.Features.Sales.ConfirmSale; using Cluckwork.Application.Features.Sales.CreateSalesOrder; @@ -100,12 +101,7 @@ public sealed class SimulationDataSeeder( IUserRoleAssignmentRepository assignments, IFarmModule farm, IEggOperationsModule eggs, - CreateProductHandler createProduct, - CreateCustomerHandler createCustomer, - CreateSalesOrderHandler createSalesOrder, - AddOrderItemHandler addOrderItem, - ConfirmSaleHandler confirmSale, - RecordPaymentHandler recordPayment, + ICommerceModule commerce, IInventoryModule inventory, IFinanceModule finance, DailyEntryLockSweep lockSweep, @@ -1370,7 +1366,7 @@ private async Task EnsureProductAsync( if (existing is not null) return existing.Id; ActAs(actor); - var result = await createProduct.HandleAsync(new CreateProductCommand( + var result = await commerce.CreateProductAsync(new CreateProductCommand( name, "Egg", "Egg", priceMinorUnits, eggGradeId, "Simulation fixture product"), accountId, ct); Require(result, $"create product {name}"); return result.Value; @@ -1420,7 +1416,7 @@ private async Task EnsureCustomerAsync( if (existing is not null) return existing.Id; ActAs(actor); - var result = await createCustomer.HandleAsync( + var result = await commerce.CreateCustomerAsync( new CreateCustomerCommand(name, phone, Note: note), accountId, ct); Require(result, $"create customer {name}"); return result.Value; @@ -1442,14 +1438,14 @@ private async Task EnsureDraftOrderAsync( if (existing is not null) return existing.Value; ActAs(actor); - var created = await createSalesOrder.HandleAsync( + var created = await commerce.CreateSalesOrderAsync( new CreateSalesOrderCommand(customerId, orderDate), accountId, ct); Require(created, $"create sales order for customer {customerId} on {orderDate:yyyy-MM-dd}"); var orderId = created.Value; // Unit/price both null: defaults from the product (Egg unit, the // catalog price seeded above) — same pattern as DemoDataSeeder. - var added = await addOrderItem.HandleAsync( + var added = await commerce.AddOrderItemAsync( new AddOrderItemCommand(orderId, productId, quantityEggs, null, null), accountId, ct); Require(added, $"add item to sales order {orderId}"); @@ -1471,7 +1467,7 @@ private async Task EnsureExtraLineAsync( if (alreadyHasLine) return; ActAs(actor); - var added = await addOrderItem.HandleAsync( + var added = await commerce.AddOrderItemAsync( new AddOrderItemCommand(orderId, productId, quantityEggs, null, explicitPriceMinorUnits), accountId, ct); Require(added, $"add extra line to sales order {orderId}"); @@ -1507,7 +1503,7 @@ private async Task EnsureConfirmedOrderAsync( // FIFO allocation (tech spec §10.9.1): draws from the oldest // available lots for this grade under a FOR UPDATE lock — this is // what depletes the production history's egg lots. - var confirmed = await confirmSale.HandleAsync(new ConfirmSaleCommand(orderId), accountId, actor.UserId, ct); + var confirmed = await commerce.ConfirmSaleAsync(new ConfirmSaleCommand(orderId), accountId, actor.UserId, ct); Require(confirmed, $"confirm sales order {orderId}"); return orderId; } @@ -1528,7 +1524,7 @@ private async Task EnsurePartialPaymentAsync( $"Simulation seed: sales order {orderId}'s total is too small to seed a partial payment."); ActAs(actor); - var result = await recordPayment.HandleAsync(new RecordPaymentCommand( + var result = await commerce.RecordPaymentAsync(new RecordPaymentCommand( orderId, paymentDate, amount, "Cash", null, "Simulation fixture partial payment"), accountId, ct); Require(result, $"record partial payment for sales order {orderId}"); } diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json index e81db8971..fc512d25a 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json @@ -74,7 +74,51 @@ "Cluckwork.Infrastructure.Repositories.EggInventoryMovementRepository", "Cluckwork.Infrastructure.Repositories.EggLotRepository" ] }, - "Commerce": { "kind": "module", "namespaces": ["Cluckwork.Domain.Catalog", "Cluckwork.Domain.Sales", "Cluckwork.Application.Features.Catalog", "Cluckwork.Application.Features.Customers", "Cluckwork.Application.Features.Sales"] }, + "Commerce": { "kind": "module", "namespaces": ["Cluckwork.Domain.Catalog", "Cluckwork.Domain.Sales", "Cluckwork.Application.Features.Catalog", "Cluckwork.Application.Features.Customers", "Cluckwork.Application.Features.Sales"], + "contract": [ + "Cluckwork.Application.Features.Sales.ICommerceModule", + "Cluckwork.Application.Features.Catalog.IEggUnitConversionLookup", + "Cluckwork.Application.Features.Sales.ProductDetails", + "Cluckwork.Application.Features.Catalog.EggUnitConversionDetails", + "Cluckwork.Application.Features.Sales.CustomerDetails", + "Cluckwork.Application.Features.Customers.CustomerReference", + "Cluckwork.Application.Features.Sales.SalesOrderDetails", + "Cluckwork.Application.Features.Sales.SalesOrderItemDetails", + "Cluckwork.Application.Features.Sales.SalesOrderListItem", + "Cluckwork.Application.Features.Sales.SalesOrderListFilter", + "Cluckwork.Application.Features.Sales.SettlementScope", + "Cluckwork.Application.Features.Sales.PaymentDetails", + "Cluckwork.Application.Features.Sales.CustomerBalance", + "Cluckwork.Application.Features.Catalog.CreateProduct.CreateProductCommand", + "Cluckwork.Application.Features.Catalog.UpdateProduct.UpdateProductCommand", + "Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion.UpdateEggUnitConversionCommand", + "Cluckwork.Application.Features.Customers.CreateCustomer.CreateCustomerCommand", + "Cluckwork.Application.Features.Customers.UpdateCustomer.UpdateCustomerCommand", + "Cluckwork.Application.Features.Sales.CreateSalesOrder.CreateSalesOrderCommand", + "Cluckwork.Application.Features.Sales.AddOrderItem.AddOrderItemCommand", + "Cluckwork.Application.Features.Sales.UpdateOrderItem.UpdateOrderItemCommand", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleCommand", + "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleResponse", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleCommand", + "Cluckwork.Application.Features.Sales.VoidSale.VoidSaleResponse", + "Cluckwork.Application.Features.Sales.RecordPayment.RecordPaymentCommand", + "Cluckwork.Application.Features.Sales.VoidPayment.VoidPaymentCommand", + "Cluckwork.Domain.Catalog.ProductType", + "Cluckwork.Domain.Catalog.ProductUnit", + "Cluckwork.Domain.Catalog.EggUnit", + "Cluckwork.Domain.Sales.SalesOrderStatus", + "Cluckwork.Domain.Sales.DiscountReasonCode", + "Cluckwork.Domain.Sales.ListPriceBasis", + "Cluckwork.Domain.Sales.PaymentMethod" + ], + "implementations": [ + "Cluckwork.Infrastructure.Repositories.CustomerRepository", + "Cluckwork.Infrastructure.Repositories.EggUnitConversionRepository", + "Cluckwork.Infrastructure.Repositories.PaymentRepository", + "Cluckwork.Infrastructure.Repositories.ProductRepository", + "Cluckwork.Infrastructure.Repositories.SalesOrderAllocationRepository", + "Cluckwork.Infrastructure.Repositories.SalesOrderRepository" + ] }, "GeneralInventory": { "kind": "module", "namespaces": ["Cluckwork.Domain.Inventory", "Cluckwork.Application.Features.Inventory"], "contract": [ "Cluckwork.Application.Features.Inventory.IInventoryModule", @@ -1491,6 +1535,14 @@ "reason": "Lists every farm's id and time zone before resolving one tenant scope per farm to lock due daily entries. IFarmModule reads only the current farm; the Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", "deleteWhen": "#858" }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.DemoDataSeeder.CleanupPartialSeedAsync", + "reaches": "Commerce", + "tables": ["Customers", "SalesOrderItems", "SalesOrders"], + "owner": "Platform", + "reason": "Deletes a partial demo seed's SalesOrderItems, SalesOrders and Customers for one farm, children before parents, in the same transaction as every other module's demo rows. ICommerceModule has no delete: orders are cancelled or voided, never removed. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, { "symbol": "Cluckwork.Infrastructure.Persistence.DemoDataSeeder.CleanupPartialSeedAsync", "reaches": "EggOperations", @@ -1539,6 +1591,14 @@ "reason": "Loads the demo's historical flock to deplete it on a backdated day, because DepleteFlockHandler stamps the farm's today and that would block the historical entries the demo records next. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", "deleteWhen": "#858" }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.ComputeCountsAsync", + "reaches": "Commerce", + "tables": ["Customers", "Payments", "SalesOrders"], + "owner": "Platform", + "reason": "Counts Customers, SalesOrders by status and Payments for the simulation manifest check, beside every other module's counts. ICommerceModule has no count read. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, { "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.ComputeCountsAsync", "reaches": "EggOperations", @@ -1587,6 +1647,22 @@ "reason": "Checks InventoryMovements for an Adjustment or Discard on the lot before calling IInventoryModule.RecordAdjustmentAsync, so a re-run converges instead of duplicating. IInventoryModule has no existence read. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", "deleteWhen": "#858" }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsureConfirmedOrderAsync", + "reaches": "Commerce", + "tables": ["SalesOrders"], + "owner": "Platform", + "reason": "Reads the order's status before calling ICommerceModule.ConfirmSaleAsync, so a re-run confirms only an order still in Draft. ICommerceModule.GetSalesOrderAsync would also load the order's lines. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsureCustomerAsync", + "reaches": "Commerce", + "tables": ["Customers"], + "owner": "Platform", + "reason": "Looks up Customers by name before calling ICommerceModule.CreateCustomerAsync, so a re-run converges and reuses the existing id. ICommerceModule has no lookup by name. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, { "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsureExpenseAsync", "reaches": "Finance", @@ -1603,6 +1679,14 @@ "reason": "Looks up ExpenseCategories by name before calling IFinanceModule.CreateCategoryAsync, so a re-run converges and reuses the existing id. IFinanceModule has no lookup by name: #849 kept the contract to the writes the seeder makes, and the seeder's conversion to module contracts is the Platform composition slice.", "deleteWhen": "#858" }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsureExtraLineAsync", + "reaches": "Commerce", + "tables": ["SalesOrderItems"], + "owner": "Platform", + "reason": "Checks SalesOrderItems for the product's line on the order before calling ICommerceModule.AddOrderItemAsync, so a re-run converges instead of duplicating. ICommerceModule has no existence read. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, { "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsureInventoryItemAsync", "reaches": "GeneralInventory", @@ -1619,6 +1703,30 @@ "reason": "Checks InventoryLots for the item's opening lot, and reads its id, before calling IInventoryModule.RecordPurchaseAsync, so a re-run converges and reuses the lot. IInventoryModule has no existence read. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", "deleteWhen": "#858" }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsurePartialPaymentAsync", + "reaches": "Commerce", + "tables": ["Payments", "SalesOrders"], + "owner": "Platform", + "reason": "Checks Payments for the order and reads its total before calling ICommerceModule.RecordPaymentAsync, so a re-run converges and the payment stays half the total. ICommerceModule has no existence read. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.EnsureProductAsync", + "reaches": "Commerce", + "tables": ["Products"], + "owner": "Platform", + "reason": "Looks up Products by name before calling ICommerceModule.CreateProductAsync, so a re-run converges and reuses the existing id. ICommerceModule has no lookup by name. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, + { + "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.FindOrderAsync", + "reaches": "Commerce", + "tables": ["SalesOrders"], + "owner": "Platform", + "reason": "Finds the fixture's order by customer and order date, the natural key this seeder controls, so a re-run reuses the order instead of creating another. ICommerceModule has no lookup by that key. The Platform composition slice converts jobs, CLI verbs and seeders to module contracts.", + "deleteWhen": "#858" + }, { "symbol": "Cluckwork.Infrastructure.Persistence.SimulationDataSeeder.MissingBaseDataAsync", "reaches": "EggOperations", From bc677ab4f54b79cde2915b0a539aa821d2929012 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 04:51:58 +0000 Subject: [PATCH 04/11] test(sales): pin Commerce's read copies and a confirm's all-or-nothing commit CommerceModuleTests pins every Details copy field by field. Swapping two same-typed fields in the customer, order-line or payment copy turns it red. ConfirmSaleAtomicityTests faults a confirm after a lot allocation, after its Sale movement, after the allocation rows and after the order is confirmed, and finds nothing persisted. Running confirm without its transaction while saving after each draw turns all four red. Refs #854 --- .../ConfirmSaleAtomicityTests.cs | 127 ++++++++ .../Sales/CommerceModuleTests.cs | 285 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs create mode 100644 tests/Cluckwork.Application.Tests/Sales/CommerceModuleTests.cs diff --git a/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs b/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs new file mode 100644 index 000000000..aa72c73a8 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs @@ -0,0 +1,127 @@ +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Application.Common; +using Cluckwork.Application.Features.EggLots; +using Cluckwork.Application.Features.Eggs; +using Cluckwork.Application.Features.Sales; +using Cluckwork.Application.Features.Sales.ConfirmSale; +using Cluckwork.Domain.Eggs; +using Cluckwork.Domain.Sales; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cluckwork.Api.IntegrationTests; + +// #854: a confirm commits its lot draws, Sale movements, allocation rows, order +// state and audit row together or not at all. Each case faults one step of the +// confirm on the second of two draws and finds nothing changed. The handler +// runs outside HTTP, so IdempotencyMiddleware's request transaction does not +// wrap it. +[Collection(IntegrationCollection.Name)] +public sealed class ConfirmSaleAtomicityTests(CluckworkWebApplicationFactory factory) +{ + public enum FaultPoint { AfterLotAllocation, AfterEggMovement, AfterAllocationRows, AfterOrderConfirmation } + + private sealed record Snapshot( + string Lots, int Movements, int Allocations, SalesOrderStatus Status, int OrderVersion, int AuditRows); + + [Theory] + [InlineData(FaultPoint.AfterLotAllocation)] + [InlineData(FaultPoint.AfterEggMovement)] + [InlineData(FaultPoint.AfterAllocationRows)] + [InlineData(FaultPoint.AfterOrderConfirmation)] + public async Task Confirm_FailureMidTransaction_PersistsNothing(FaultPoint fault) + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var userId = await factory.WithTenantScopeAsync(accountId, db => + db.Users.Where(u => u.Email == email).Select(u => u.Id).SingleAsync()); + var grades = await factory.SeedEggGradesAsync(accountId, Guid.NewGuid(), "Large"); + var older = await factory.SeedEggLotAsync(accountId, grades["Large"], 30, productionDate: Today.AddDays(-5)); + var newer = await factory.SeedEggLotAsync(accountId, grades["Large"], 40, productionDate: Today.AddDays(-2)); + var orderId = await factory.SeedSalesOrderAsync(accountId, grades["Large"], 50); + var before = await SnapshotAsync(accountId, orderId, [older, newer]); + + using (var scope = factory.Services.CreateScope().ResolveTenantAndActor(accountId, userId, email)) + { + var services = scope.ServiceProvider; + var stock = new EggStock(services.GetRequiredService(), + new FaultingMovements(services.GetRequiredService(), fault)); + var handler = ActivatorUtilities.CreateInstance(services, stock, + new FaultingAllocations(services.GetRequiredService(), fault), + new FaultingAuditWriter(services.GetRequiredService(), fault)); + var thrown = await Assert.ThrowsAsync(() => handler.HandleAsync( + new ConfirmSaleCommand(orderId), accountId, userId, CancellationToken.None)); + Assert.Equal(Fault(fault).Message, thrown.Message); + } + + // Each seeded lot carries its Production movement; adding the line bumped the order once. + Assert.Equal(new Snapshot($"{older}:30:0,{newer}:40:0", 2, 0, SalesOrderStatus.Draft, 1, 0), before); + Assert.Equal(before, await SnapshotAsync(accountId, orderId, [older, newer])); + } + + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + private Task SnapshotAsync(Guid accountId, Guid orderId, Guid[] lotIds) => + factory.WithTenantScopeAsync(accountId, async db => + { + var lots = new List(); + foreach (var id in lotIds) + { + var lot = await db.EggLots.AsNoTracking().SingleAsync(l => l.Id == id); + lots.Add($"{lot.Id}:{lot.QuantityAvailable}:{lot.Version}"); + } + var order = await db.SalesOrders.AsNoTracking().SingleAsync(o => o.Id == orderId); + return new Snapshot( + string.Join(",", lots), + await db.EggInventoryMovements.CountAsync(m => lotIds.Contains(m.EggLotId)), + await db.SalesOrderAllocations.CountAsync(a => a.SalesOrderId == orderId), + order.Status, order.Version, + await db.AuditEvents.CountAsync(e => e.EntityId == orderId)); + }); + + private static InvalidOperationException Fault(FaultPoint at) => + new($"#854 atomicity guard: faulting {at}."); + + private sealed class FaultingMovements(IEggInventoryMovementRepository inner, FaultPoint fault) + : IEggInventoryMovementRepository + { + private int _calls; + + public async Task AddAsync(EggInventoryMovement movement, CancellationToken ct = default) + { + var second = ++_calls == 2; + if (second && fault == FaultPoint.AfterLotAllocation) throw Fault(fault); + await inner.AddAsync(movement, ct); + if (second && fault == FaultPoint.AfterEggMovement) throw Fault(fault); + } + + public Task AddRangeAsync(IEnumerable movements, CancellationToken ct = default) => + inner.AddRangeAsync(movements, ct); + + public Task> ListByLotAsync(Guid eggLotId, CancellationToken ct = default) => + inner.ListByLotAsync(eggLotId, ct); + } + + private sealed class FaultingAllocations(ISalesOrderAllocationRepository inner, FaultPoint fault) + : ISalesOrderAllocationRepository + { + public async Task AddRangeAsync(IReadOnlyList allocations, CancellationToken ct = default) + { + await inner.AddRangeAsync(allocations, ct); + if (fault == FaultPoint.AfterAllocationRows) throw Fault(fault); + } + + public Task> ListPendingByOrderAsync( + Guid salesOrderId, CancellationToken ct = default) => + inner.ListPendingByOrderAsync(salesOrderId, ct); + } + + private sealed class FaultingAuditWriter(IAuditWriter inner, FaultPoint fault) : IAuditWriter + { + public Task WriteAsync(string action, string entityType, Guid entityId, string? reason = null, + object? details = null, CancellationToken ct = default) => + fault == FaultPoint.AfterOrderConfirmation + ? throw Fault(fault) + : inner.WriteAsync(action, entityType, entityId, reason, details, ct); + } +} diff --git a/tests/Cluckwork.Application.Tests/Sales/CommerceModuleTests.cs b/tests/Cluckwork.Application.Tests/Sales/CommerceModuleTests.cs new file mode 100644 index 000000000..5301b473b --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Sales/CommerceModuleTests.cs @@ -0,0 +1,285 @@ +using Cluckwork.Application.Features.Catalog; +using Cluckwork.Application.Features.Customers; +using Cluckwork.Application.Features.Sales; +using Cluckwork.Domain.Catalog; +using Cluckwork.Domain.Common; +using Cluckwork.Domain.Sales; + +namespace Cluckwork.Application.Tests.Sales; + +// #854: the read paths copy each aggregate field by field into positional +// records, where two Guids, two strings or two counts can swap without a +// compile error. +public sealed class CommerceModuleTests +{ + private static readonly Guid AccountId = Guid.Parse("00000000-0000-0000-0000-0000000000a1"); + private static readonly Guid FarmId = Guid.Parse("00000000-0000-0000-0000-0000000000f1"); + private static readonly Guid ProductId = Guid.Parse("00000000-0000-0000-0000-0000000000b1"); + private static readonly Guid UnmappedProductId = Guid.Parse("00000000-0000-0000-0000-0000000000b2"); + private static readonly Guid GradeId = Guid.Parse("00000000-0000-0000-0000-0000000000c1"); + private static readonly Guid ConversionId = Guid.Parse("00000000-0000-0000-0000-0000000000c2"); + private static readonly Guid CustomerId = Guid.Parse("00000000-0000-0000-0000-0000000000d1"); + private static readonly Guid OrderId = Guid.Parse("00000000-0000-0000-0000-0000000000e1"); + private static readonly Guid PaymentId = Guid.Parse("00000000-0000-0000-0000-0000000000e2"); + private static readonly DateOnly OrderDate = new(2026, 9, 20); + private static readonly DateOnly PaymentDate = new(2026, 9, 22); + + private readonly FakeProducts _products = new(); + private readonly FakeConversions _conversions = new(); + private readonly FakeCustomers _customers = new(); + private readonly FakeOrders _orders = new(); + private readonly FakePayments _payments = new(); + private readonly Guid _itemId; + + // The handlers sit on the write paths only; these tests exercise the reads. + private CommerceModule Module() => new( + _products, _conversions, _customers, _orders, _payments, + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + + public CommerceModuleTests() + { + var product = Product.Create( + ProductId, AccountId, FarmId, "Large Eggs", ProductType.Egg, ProductUnit.Dozen, 1_800, "KWD", 3, "Graded A"); + Assert.True(product.Deactivate().IsSuccess); + _products.Rows.Add(product); + _products.Rows.Add(Product.Create( + UnmappedProductId, AccountId, FarmId, "Spent hens", ProductType.LiveBird, ProductUnit.Bird, null, "KWD", 3, null)); + _products.Mappings.Add(ProductEggGradeMapping.Create(Guid.NewGuid(), AccountId, ProductId, GradeId)); + + var conversion = EggUnitConversion.Create(ConversionId, AccountId, EggUnit.Tray, 30); + Assert.True(conversion.Update(24, false).IsSuccess); + _conversions.Rows.Add(conversion); + + var customer = Customer.Create(CustomerId, AccountId, "Mercado", "555-0100", "orders@mercado.example", + "12 Market Rd", "Pays cash"); + Assert.True(customer.Update("Mercado Central", "555-0117", "buy@mercado.example", "14 Market Rd", "Pays card") + .IsSuccess); + _customers.Rows.Add(customer); + + var order = SalesOrder.Create(OrderId, AccountId, CustomerId, "SO-77", OrderDate, "KWD", 3); + _itemId = order.AddItem(ProductId, ProductType.Egg, GradeId, ProductUnit.Dozen, 12, 3, + new Money(1_500, "KWD", 3), 1_800, ListPriceBasis.Recorded).Value.Id; + Assert.True(order.Confirm(DiscountReasonCode.Volume, "Ten trays a week").IsSuccess); + Assert.True(order.Void("Entered twice").IsSuccess); + _orders.Rows.Add(order); + + var payment = Payment.Create(PaymentId, AccountId, OrderId, CustomerId, PaymentDate, 700, "KWD", 3, + PaymentMethod.Card, "REF-9", "Deposit"); + Assert.True(payment.Void("Bounced").IsSuccess); + _payments.Rows.Add(payment); + } + + private SalesOrderDetails ExpectedOrder => new( + OrderId, CustomerId, "SO-77", OrderDate, SalesOrderStatus.Voided, new Money(4_500, "KWD", 3), + "Entered twice", [], DiscountReasonCode.Volume, "Ten trays a week"); + + private SalesOrderItemDetails ExpectedItem => new( + _itemId, ProductId, GradeId, ProductUnit.Dozen, 12, 3, 36, new Money(1_500, "KWD", 3), 1_800, + ListPriceBasis.Recorded); + + private static readonly PaymentDetails ExpectedPayment = new( + PaymentId, OrderId, CustomerId, PaymentDate, 700, "KWD", 3, PaymentMethod.Card, "REF-9", "Deposit", + true, "Bounced", 1); + + private void AssertOrder(SalesOrderDetails? actual) + { + Assert.NotNull(actual); + Assert.Equal(ExpectedOrder, actual with { Items = [] }); + Assert.Equal([ExpectedItem], actual.Items); + } + + // A product with no grade mapping answers Guid.Empty, not null, as + // ProductEndpoints did before the contract. + [Fact] + public async Task ListProducts_CopiesEveryFieldAndJoinsTheGradeMapping() + { + Assert.Equal( + [ + new ProductDetails(ProductId, "Large Eggs", ProductType.Egg, ProductUnit.Dozen, 1_800, "KWD", 3, + GradeId, "Graded A", false, 1), + new ProductDetails(UnmappedProductId, "Spent hens", ProductType.LiveBird, ProductUnit.Bird, null, + "KWD", 3, Guid.Empty, null, true, 0), + ], + await Module().ListProductsAsync(includeInactive: true, default)); + Assert.Equal(["list True", "mappings"], _products.Calls); + } + + [Fact] + public async Task ConversionReads_CopyEveryField() + { + var expected = new EggUnitConversionDetails(ConversionId, EggUnit.Tray, 24, false, 1); + + Assert.Equal([expected], await Module().ListConversionsAsync(default)); + Assert.Equal(expected, await new EggUnitConversionLookup(_conversions).GetByUnitAsync(EggUnit.Tray, default)); + Assert.Null(await new EggUnitConversionLookup(_conversions).GetByUnitAsync(EggUnit.Case, default)); + } + + [Fact] + public async Task CustomerReads_CopyEveryFieldAndForwardTheArguments() + { + var expected = new CustomerDetails( + CustomerId, "Mercado Central", "555-0117", "buy@mercado.example", "14 Market Rd", "Pays card", 1); + + Assert.Equal([expected], await Module().SearchCustomersAsync("merc", 25, 50, default)); + Assert.Equal(expected, await Module().GetCustomerAsync(CustomerId, default)); + Assert.Null(await Module().GetCustomerAsync(Guid.NewGuid(), default)); + Assert.Equal( + new Dictionary { [CustomerId] = new(CustomerId, "Mercado Central") }, + await Module().GetCustomerNamesAsync([CustomerId], default)); + Assert.Equal(["search merc 25 50", $"names {CustomerId}"], _customers.Calls); + } + + [Fact] + public async Task OrderReads_CopyEveryFieldAndForwardTheArguments() + { + AssertOrder(await Module().GetSalesOrderAsync(OrderId, default)); + Assert.Null(await Module().GetSalesOrderAsync(Guid.NewGuid(), default)); + + var filter = new SalesOrderListFilter(SalesOrderStatus.Voided, CustomerId, OrderDate, PaymentDate, + SettlementScope.UnpaidOnly); + var row = Assert.Single(await Module().ListSalesOrdersAsync(filter, 10, 20, default)); + AssertOrder(row.Order); + Assert.Equal(4_200, row.OutstandingMinorUnits); + Assert.Equal([$"list {filter} 10 20"], _orders.Calls); + } + + [Fact] + public async Task PaymentReads_CopyEveryFieldAndForwardTheArguments() + { + Assert.Equal(ExpectedPayment, await Module().GetPaymentAsync(PaymentId, default)); + Assert.Null(await Module().GetPaymentAsync(Guid.NewGuid(), default)); + Assert.Equal([ExpectedPayment], await Module().ListOrderPaymentsAsync(OrderId, default)); + Assert.Equal(300, await Module().SumNonVoidedPaymentsAsync(OrderId, default)); + Assert.Equal([new CustomerBalance(CustomerId, 4_500, 300)], await Module().ListCustomerBalancesAsync(default)); + Assert.Equal([$"payments {OrderId}", $"sum {OrderId}", "balances"], _payments.Calls); + } + + private sealed class FakeProducts : IProductRepository + { + public List Rows { get; } = []; + public List Mappings { get; } = []; + public List Calls { get; } = []; + + public Task> ListAsync(bool includeInactive, CancellationToken ct = default) + { + Calls.Add($"list {includeInactive}"); + return Task.FromResult>(Rows); + } + + public Task> ListMappingsAsync(CancellationToken ct = default) + { + Calls.Add("mappings"); + return Task.FromResult>(Mappings); + } + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task NameExistsAsync(string name, Guid? excludeId = null, CancellationToken ct = default) => + throw new NotSupportedException(); + public Task AddAsync(Product product, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetMappingAsync(Guid productId, CancellationToken ct = default) => + throw new NotSupportedException(); + public Task AddMappingAsync(ProductEggGradeMapping mapping, CancellationToken ct = default) => + throw new NotSupportedException(); + } + + private sealed class FakeConversions : IEggUnitConversionRepository + { + public List Rows { get; } = []; + + public Task> ListAsync(CancellationToken ct = default) => + Task.FromResult>(Rows); + + public Task GetByUnitAsync(EggUnit unit, CancellationToken ct = default) => + Task.FromResult(Rows.SingleOrDefault(c => c.UnitCode == unit)); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + throw new NotSupportedException(); + } + + private sealed class FakeCustomers : ICustomerRepository + { + public List Rows { get; } = []; + public List Calls { get; } = []; + + public Task> SearchAsync( + string? search, int limit, int offset, CancellationToken ct = default) + { + Calls.Add($"search {search} {limit} {offset}"); + return Task.FromResult>(Rows); + } + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(Rows.SingleOrDefault(c => c.Id == id)); + + public Task> GetDisplayNamesAsync( + IReadOnlyCollection customerIds, CancellationToken ct = default) + { + Calls.Add($"names {string.Join(",", customerIds)}"); + return Task.FromResult>(Rows + .Where(c => customerIds.Contains(c.Id)).ToDictionary(c => c.Id, c => new CustomerReference(c.Id, c.Name))); + } + + public Task> ListAsync(int limit, int offset, CancellationToken ct = default) => + throw new NotSupportedException(); + public Task AddAsync(Customer entity, CancellationToken ct = default) => throw new NotSupportedException(); + public void Update(Customer entity) => throw new NotSupportedException(); + public void Remove(Customer entity) => throw new NotSupportedException(); + } + + private sealed class FakeOrders : ISalesOrderRepository + { + public List Rows { get; } = []; + public List Calls { get; } = []; + + public Task GetReadOnlyAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(Rows.SingleOrDefault(o => o.Id == id)); + + public Task> ListAsync( + SalesOrderListFilter filter, int limit, int offset, CancellationToken ct = default) + { + Calls.Add($"list {filter} {limit} {offset}"); + return Task.FromResult>( + Rows.Select(o => new SalesOrderListRow(o, 4_200)).ToList()); + } + + public Task GetByIdLockedAsync(Guid accountId, Guid id, CancellationToken ct = default) => + throw new NotSupportedException(); + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task AddAsync(SalesOrder entity, CancellationToken ct = default) => throw new NotSupportedException(); + public void Update(SalesOrder entity) => throw new NotSupportedException(); + public void Remove(SalesOrder entity) => throw new NotSupportedException(); + } + + private sealed class FakePayments : IPaymentRepository + { + public List Rows { get; } = []; + public List Calls { get; } = []; + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(Rows.SingleOrDefault(p => p.Id == id)); + + public Task> ListByOrderAsync(Guid salesOrderId, CancellationToken ct = default) + { + Calls.Add($"payments {salesOrderId}"); + return Task.FromResult>(Rows.Where(p => p.SalesOrderId == salesOrderId).ToList()); + } + + public Task SumNonVoidedByOrderAsync(Guid salesOrderId, CancellationToken ct = default) + { + Calls.Add($"sum {salesOrderId}"); + return Task.FromResult(300L); + } + + public Task> ListCustomerBalancesAsync(CancellationToken ct = default) + { + Calls.Add("balances"); + return Task.FromResult>([new CustomerBalance(CustomerId, 4_500, 300)]); + } + + public Task AnyNonVoidedByOrderAsync(Guid salesOrderId, CancellationToken ct = default) => + throw new NotSupportedException(); + public Task AddAsync(Payment entity, CancellationToken ct = default) => throw new NotSupportedException(); + public void Update(Payment entity) => throw new NotSupportedException(); + public void Remove(Payment entity) => throw new NotSupportedException(); + } +} From cab5277c5120c748b6a7adb4b325ab271f583c55 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 04:53:44 +0000 Subject: [PATCH 05/11] docs(sales): record the Commerce contract and the IEggStock port Refs #854 --- AGENTS.md | 2 +- docs/architecture.md | 6 +- docs/decisions/854-commerce-contract.md | 139 ++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/854-commerce-contract.md diff --git a/AGENTS.md b/AGENTS.md index 1ee81cc21..d23d05f3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ dotnet test Cluckwork.sln # 2887 tests as of 2026-09-21; integr - **The coupling matrix is generated from the ledger and its three walks (#848).** `CouplingMatrixRealTreeTests` validates the module-edge, EF-model and adapter reports before rendering `tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md`, then compares normalized line endings with the committed file. Regenerate with `CLUCKWORK_REGENERATE_MATRIX=1 dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~CouplingMatrixRealTreeTests`; do not hand-edit the Markdown. Its observable-differences table has seven `W`/`R`/dash mismatches with §3.4. A separate table records its unobservable `E`, `Q` and Platform cells. `P` is the free Platform hub, and `A` counts adapter reaches. → [`848-generated-coupling-matrix.md`](docs/decisions/848-generated-coupling-matrix.md) -- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. `owners..contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory and Egg Operations are contracted today. The check covers adapters, not peer modules, because the edge walk records namespaces rather than types. → [`849-module-contract.md`](docs/decisions/849-module-contract.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. Nothing checks peer modules: a peer that injects `IFlockRepository` again stays green. → [`852-flock-contract.md`](docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. `CurrencyBoundRowProbe` stays a compatibility exception until Commerce's contract (#854). → [`855-inventory-contract.md`](docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. Commerce's handlers still reach lots through `IEggLotRepository` until #854 builds the stock seam. → [`853-egg-operations-contract.md`](docs/decisions/853-egg-operations-contract.md) +- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. `owners..contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations and Commerce are contracted today. The check covers adapters, not peer modules, because the edge walk records namespaces rather than types. → [`849-module-contract.md`](docs/decisions/849-module-contract.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. Nothing checks peer modules: a peer that injects `IFlockRepository` again stays green. → [`852-flock-contract.md`](docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a pure planner over the locked lots so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. Commerce never sees `EggLot`. `ConfirmSaleAtomicityTests` holds the all-or-nothing confirm, and it stays green if only the transaction or only the never-save rule breaks, because each covers the other; keep both. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](docs/decisions/854-commerce-contract.md) - **Register every read of a contracted module's tables from outside it (#514/#850).** `CompatibilityExceptionRealTreeTests` compiles `src/Cluckwork.Infrastructure` from source, and the projects referencing it with errors tolerated. It finds every member that obtains a `DbSet`, maps `T` through the EF model to the tables a query touches (its own, owned values mapped apart and derived types), and takes each table's owner from the ledger's `tables`, never the entity's namespace. A read of a contracted module's table is allowed for the module's own types, a type its ledger edge's `symbols` names, a type listed under `owners..implementations`, and a `DbSet` property whose whole body is `Set()`. Every other read needs a `compatibilityExceptions` row in `module-ledger.json`, keyed by namespace, type and member, with `reaches`, the `tables` read, a ledger `owner`, a `reason` and `deleteWhen`, the slice issue that deletes it. `deleteWhen` is `#`, never a date. A new read, a table the row does not name, a `DbSet` of a type parameter and an unbound candidate name fail; a row or table no longer read fails as stale; an Infrastructure compile error fails closed. SQL text is not read. The next contract slice lists its implementations and registers its module's other readers. → [`850-compatibility-exceptions.md`](docs/decisions/850-compatibility-exceptions.md) ### Data and correctness diff --git a/docs/architecture.md b/docs/architecture.md index 78498b613..1b995ba07 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,7 +60,7 @@ complete set of inventory writers: |---|---|---| | `DailyEntry.Submit` | `SubmitDailyEntryHandler` | creates lots | | `DailyEntry` adjust / void | `AdjustDailyEntryHandler`, `VoidDailyEntryHandler` | `EggLot.AdjustProduction` reconciles the lot down to what the corrected day says, with the already-sold amount as the floor | -| `SalesOrder.Confirm` / `Void` | `ConfirmSaleHandler`, `VoidSaleHandler` | `Allocate` / `Restore` | +| `SalesOrder.Confirm` / `Void` | `ConfirmSaleHandler`, `VoidSaleHandler`, through Egg Operations' `IEggStock` | `Allocate` / `Restore` | | **Manual stock movement** | `RecordEggLotMovementHandler` (`/stock`) | `EggLot.AdjustAvailable` for a `Discard`, `InternalUse` or `Reconciliation` movement — no daily entry and no sale involved | Anything touching lot concurrency or the movement ledger has to account for all @@ -122,7 +122,9 @@ allocation before the state changes — insufficient stock on any line aborts th transaction, so a half-allocated confirmed order cannot exist. Lots are drawn **FIFO by `ProductionDate`, then `Id`** as tiebreaker (`Infrastructure/Repositories/EggLotRepository.cs`), locked `FOR UPDATE`, and -each draw writes a `SalesOrderAllocation` row. `Void` re-locks those same lots +each draw writes a `SalesOrderAllocation` row. Commerce reaches the lots only +through Egg Operations' `IEggStock` port, which locks, plans and draws inside +the confirm's transaction and never saves (#854). `Void` re-locks those same lots **in the same order** — that shared ordering is what stops confirm and void deadlocking against each other — restores each quantity, and marks the allocation rows released rather than deleting them. diff --git a/docs/decisions/854-commerce-contract.md b/docs/decisions/854-commerce-contract.md new file mode 100644 index 000000000..32532c747 --- /dev/null +++ b/docs/decisions/854-commerce-contract.md @@ -0,0 +1,139 @@ +# Put Commerce behind a contract and reach egg stock through a port (#854) + +> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md), +> beside the #849 contract rule; this file records the Commerce contract, the +> stock port it calls, and the currency probe this slice inverted. + +**Status:** accepted +**Date:** 2026-10-03 + +## What happened + +No incident. This is epic #514, Track C slice 12, the sixth module behind a +contract after Finance (#849), Farm (#851), Flock Management (#852), General +Inventory (#855) and Egg Operations (#853). Commerce owns products, packed-unit +conversions, customers, sales orders, allocations and payments. Its confirm and +void are the system's most concurrency-sensitive writes. + +Before this change, adapters reached Commerce through 57 parameter crossings of +37 non-contract types across 31 adapter rows: the product, customer, sale and +payment endpoints and both seeders. The count comes from running the #849 +adapter check against `main` at d6dbd47d with a placeholder contract. After it, +the count is 0, and the matrix cell stays `A (31)`. + +The design checkpoint found that nothing tested a confirm's lock order across +grades. `EggLotLockOrderTests` uses one grade, so locking each grade's lots in +line order, the shape `IEggLotRepository` warns deadlocks against a void, left +156 tests across 15 classes green. This slice adds a two-grade case. + +## The rule + +Commerce has two contract interfaces: + +- `ICommerceModule` in `Cluckwork.Application.Features.Sales` is the adapter + surface. It lists, creates, updates and activates products; lists and updates + conversions; creates, updates, searches and reads customers; drafts, confirms, + voids, cancels and reads orders; and records, voids and reads payments. The + endpoints and both seeders call it. +- `IEggUnitConversionLookup` is the packed-unit read port. Farm's settings + handler and Access's stepper-unit handler read through it. + +Reads return `ProductDetails`, `EggUnitConversionDetails`, `CustomerDetails`, +`SalesOrderDetails` with `SalesOrderItemDetails`, `SalesOrderListItem` and +`PaymentDetails`, plus the existing `CustomerReference` and `CustomerBalance`. +`CommerceModule` only forwards to the existing handlers and five repositories, so +the SQL does not change. `owners.Commerce.contract` lists 34 types; the six +Commerce repositories are its `implementations` under #850. + +## The stock port + +Commerce reaches egg lots through Egg Operations' `IEggStock`, never through +`IEggLotRepository`, and never sees `EggLot`. + +- `LockForSaleAsync` takes the confirm's one `FOR UPDATE` over every available + lot of the order's grades, farm-wide, in `(ProductionDate, Id)` order, and + returns an `IEggStockReservation`. +- `IEggStockReservation.Plan` is the FIFO planner over those locked lots. It is + pure, so `ConfirmSaleHandler` can plan over a restricted Worker's assigned + flocks first and over every locked lot second without a second lock (#612). +- `IEggStockReservation.DrawAsync` calls `EggLot.Allocate` on the locked + instance and writes the Sale movement. A refusal throws: the plan read the + same instance. +- `IEggStock.RestoreAsync` locks the void's source lots by id in the same order, + refuses `EggLot.AllocationSourceMissing`, restores each lot and writes its + Void movement. + +The port never saves, like `IMortalityLedger` (#852). Commerce keeps the order +lock, the #612 policy, the refusal messages, the allocation rows and their ids, +and the audit row. Egg Operations keeps the lock, the planner, lot mutation and +the ledger rows. The reference type on a movement is passed in, so Egg +Operations names no Commerce type. + +The handlers run the same steps in the same order. One step moved: a draw's +`SalesOrderAllocation` is now created before `Allocate` runs, because the Sale +movement needs its id. Both are in memory until the one save, and EF orders that +save's batch itself. A trace of every SQL statement and transaction event, taken +on `main` and on this branch, is byte-identical for a confirm, a void, a short +confirm and a payment-gated void. + +`SaleAllocationPlanner`, `SaleAllocationPlan` and `PlannedEggLotDraw` moved to +Egg Operations. The planner now takes `SaleDemandLine`s, not order items, so +`SaleAllocationPlannerTests` builds its input differently; its assertions are +unchanged. + +## The currency probe + +Farm's currency rule asks whether anything holds an amount in the farm currency. +`CurrencyBoundRowProbe` used to read Commerce, Finance and General Inventory +tables itself, which made it two compatibility exceptions. Now Farm owns +`ICurrencyBoundRowSource`, and each repository that owns a probed table +implements it with the probe's query for that table. The probe asks the sources +in registration order, which is the order it read the tables in before, and +stops at the first yes. Both exception rows are gone. + +Why not a read on each module's contract: Farm would then resolve +`ICommerceModule` and its handlers on every `IFarmModule` use, and it would gain +Farm to Finance and Farm to General Inventory edges, closing cycles with the +existing Finance to Farm and General Inventory to Farm edges. The sources sit in +Infrastructure, the Platform hub, so the inversion adds no module edge. + +## What this does NOT cover + +- Peer modules are not checked (#849, #852). A peer that injects + `IEggUnitConversionRepository` again stays green. +- `ConfirmSaleHandler`'s in-transaction role and assignment reads stay a + declared Commerce to Access edge for #857. `AccountProvisioner` still inserts a + new farm's default conversions, a declared Access to Commerce edge (#857). +- Seeder reads. Eight seeder methods read Commerce tables to clean up a partial + demo seed, count the manifest and converge on a re-run. They are compatibility + exceptions that #858 deletes; the contract does not grow reads only a seeder + needs. +- Insights reads Commerce tables through its declared edge. +- `EggLot.AllocationSourceMissing` has no test and cannot be reached: + `FK_SalesOrderAllocations_EggLots_EggLotId` stops a drawn lot being deleted, + and void is AdminOnly, so the flock scope never hides one. +- Method bodies. `ICommerceModule.SalesOrderAuditEntityType` exists for the + endpoints' provenance reads. + +## How it is enforced + +- `AdapterReachRealTreeTests` reports a contract bypass for any adapter that + takes a non-contract Commerce type. Adding `ISalesOrderRepository orders` back + to `SaleEndpoints.GetSalesOrder` turns it red. +- `ModuleContractRealAssemblyTests` walks the contract types. A member returning + `SalesOrder` on `ICommerceModule`, or an `EggLot` list on + `IEggStockReservation`, turns it red. +- `CompatibilityExceptionRealTreeTests` fails on an unregistered read of a + Commerce table. Removing `PaymentRepository` from `implementations` turns it + red, its currency-source read included. +- `EggLotLockOrderTests.ConfirmSale_TwoGrades_HoldsTheOtherGradesEarlierLotWhileWaitingOnTheLater` + turns red when the port locks each grade in line order. +- `ConfirmSaleAtomicityTests` faults a confirm after a lot allocation, after its + Sale movement, after the allocation rows and after the order is confirmed, and + finds nothing persisted. Removing the confirm's transaction alone, or saving + after each draw alone, leaves it green: one holds the other. Both together + turn all four cases red. +- `CommerceModuleTests` pins the copies. Swapping `Email` and `Address`, + `Quantity` and `QuantityBase`, or `ReferenceNumber` and `Note` turns it red. +- Unregistering `ProductRepository` as a currency source turns + `FarmSettingsTests.CurrencyChange_AfterAPricedProductExists_IsRefused` red. From 4036702d7526780b35bd506095edfc35d9133af7 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 04:55:09 +0000 Subject: [PATCH 06/11] refactor(sales): drop three unused usings and a duplicated comment Refs #854 --- src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs | 1 - src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs | 1 - src/Cluckwork.Application/Features/EggLots/EggStock.cs | 3 --- src/Cluckwork.Application/Features/EggLots/IEggStock.cs | 3 ++- src/Cluckwork.Application/Features/Sales/CommerceModule.cs | 1 - .../Features/Sales/ConfirmSale/ConfirmSaleHandler.cs | 5 +++-- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs b/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs index ee8c9b441..0e2e5e79e 100644 --- a/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Catalog/ProductEndpoints.cs @@ -1,5 +1,4 @@ using Cluckwork.Api.Validation; -using Cluckwork.Application.Features.Catalog; using Cluckwork.Application.Features.Catalog.CreateProduct; using Cluckwork.Application.Features.Catalog.UpdateEggUnitConversion; using Cluckwork.Application.Features.Catalog.UpdateProduct; diff --git a/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs b/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs index 76d5116ac..fac5cf6a7 100644 --- a/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs @@ -1,5 +1,4 @@ using Cluckwork.Api.Validation; -using Cluckwork.Application.Features.Customers; using Cluckwork.Application.Features.Customers.CreateCustomer; using Cluckwork.Application.Features.Customers.UpdateCustomer; using Cluckwork.Application.Features.Sales; diff --git a/src/Cluckwork.Application/Features/EggLots/EggStock.cs b/src/Cluckwork.Application/Features/EggLots/EggStock.cs index 0997ce018..47be68e21 100644 --- a/src/Cluckwork.Application/Features/EggLots/EggStock.cs +++ b/src/Cluckwork.Application/Features/EggLots/EggStock.cs @@ -47,9 +47,6 @@ public SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet public async Task DrawAsync( PlannedEggLotDraw draw, string referenceType, Guid referenceId, CancellationToken ct) { - // The lot is one of the SAME locked instances the plan read, so a - // refusal here contradicts the plan: an invariant violation, never - // a 422. var lot = _lotsById[draw.EggLotId]; var allocated = lot.Allocate(draw.Quantity, saleDate); if (allocated.IsFailure) diff --git a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs index 0f484ba39..8c3aa383a 100644 --- a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs +++ b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs @@ -26,6 +26,7 @@ public interface IEggStockReservation SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet? fromFlocks = null); // Takes one planned draw from its locked lot and writes the Sale movement. - // A draw the lot refuses means the plan was wrong, so it throws. + // The lot is the same locked instance the plan read, so a refusal means the + // plan was wrong: it throws, and is never a 422. Task DrawAsync(PlannedEggLotDraw draw, string referenceType, Guid referenceId, CancellationToken ct); } diff --git a/src/Cluckwork.Application/Features/Sales/CommerceModule.cs b/src/Cluckwork.Application/Features/Sales/CommerceModule.cs index 4ad0b5d80..a1ee414b5 100644 --- a/src/Cluckwork.Application/Features/Sales/CommerceModule.cs +++ b/src/Cluckwork.Application/Features/Sales/CommerceModule.cs @@ -15,7 +15,6 @@ using Cluckwork.Application.Features.Sales.UpdateOrderItem; using Cluckwork.Application.Features.Sales.VoidPayment; using Cluckwork.Application.Features.Sales.VoidSale; -using Cluckwork.Domain.Catalog; using Cluckwork.Domain.Sales; namespace Cluckwork.Application.Features.Sales; diff --git a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs index f768e18ff..65ae7f6f0 100644 --- a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs @@ -186,8 +186,9 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => && order.FindCeilingBreach(ceiling) is { } breach) { // The grade name resolves through Egg Operations' grade lookup, - // the same way the insufficient-stock message below does. The domain decides WHO breaches; this composes the - // refusal, because SalesOrderItem carries an EggGradeId only. + // the same way the insufficient-stock message below does. The + // domain decides WHO breaches; this composes the refusal, + // because SalesOrderItem carries an EggGradeId only. var gradeName = (await eggGrades.GetAsync(breach.EggGradeId, transactionCt))?.Name ?? breach.EggGradeId.ToString(); failure = Result.Failure( From e797cd8560b83001f5d97c94da5a92936b93d1a0 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 05:28:35 +0000 Subject: [PATCH 07/11] fix(eggs): refuse a stock reservation outside the transaction that locked it IEggStock refuses to run without a transaction, and a reservation remembers the transaction that locked its lots and refuses to plan or draw once another transaction, or none, is current. It reads the transaction id through a new Platform port, ICurrentTransaction, so no persistence type reaches Application. EggStockTransactionTests keeps a reservation past its commit and requires both refusals. ConfirmSaleAtomicityTests now checks, at each fault, that the confirm is still in a transaction and that no draw was saved yet, so each rule fails on its own. IEggStockReservation.Plan's comment no longer calls it pure. Refs #854 --- ...kworkFeatureServiceCollectionExtensions.cs | 1 + .../Common/ICurrentTransaction.cs | 9 ++ .../Features/EggLots/EggStock.cs | 39 ++++-- .../Features/EggLots/IEggStock.cs | 13 +- .../Persistence/CurrentTransaction.cs | 8 ++ .../ConfirmSaleAtomicityTests.cs | 40 ++++--- .../EggStockTransactionTests.cs | 112 ++++++++++++++++++ 7 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 src/Cluckwork.Application/Common/ICurrentTransaction.cs create mode 100644 src/Cluckwork.Infrastructure/Persistence/CurrentTransaction.cs create mode 100644 tests/Cluckwork.Api.IntegrationTests/EggStockTransactionTests.cs diff --git a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs index 44fc82657..59a5a437a 100644 --- a/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs +++ b/src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs @@ -103,6 +103,7 @@ private static void AddPortsAndRepositories(IServiceCollection services) ExportQueries>(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(TimeProvider.System); diff --git a/src/Cluckwork.Application/Common/ICurrentTransaction.cs b/src/Cluckwork.Application/Common/ICurrentTransaction.cs new file mode 100644 index 000000000..fef7dffb1 --- /dev/null +++ b/src/Cluckwork.Application/Common/ICurrentTransaction.cs @@ -0,0 +1,9 @@ +namespace Cluckwork.Application.Common; + +// The id of the transaction the current unit of work runs in, or null outside +// one. Row locks last only as long as their transaction, so a port that hands +// out locked state checks that the same transaction is still current (#854). +public interface ICurrentTransaction +{ + Guid? Id { get; } +} diff --git a/src/Cluckwork.Application/Features/EggLots/EggStock.cs b/src/Cluckwork.Application/Features/EggLots/EggStock.cs index 47be68e21..931ff7d11 100644 --- a/src/Cluckwork.Application/Features/EggLots/EggStock.cs +++ b/src/Cluckwork.Application/Features/EggLots/EggStock.cs @@ -1,19 +1,26 @@ +using Cluckwork.Application.Common; using Cluckwork.Application.Features.Eggs; using Cluckwork.Domain.Eggs; namespace Cluckwork.Application.Features.EggLots; -public sealed class EggStock(IEggLotRepository lots, IEggInventoryMovementRepository movements) : IEggStock +public sealed class EggStock( + IEggLotRepository lots, IEggInventoryMovementRepository movements, ICurrentTransaction transaction) : IEggStock { public async Task LockForSaleAsync( - Guid accountId, IReadOnlyList eggGradeIds, DateOnly saleDate, CancellationToken ct) => - new Reservation(accountId, saleDate, - await lots.GetAvailableFifoLockedAsync(accountId, eggGradeIds, saleDate, ct), movements); + Guid accountId, IReadOnlyList eggGradeIds, DateOnly saleDate, CancellationToken ct) + { + var lockedIn = RequireTransaction(); + return new Reservation(accountId, saleDate, + await lots.GetAvailableFifoLockedAsync(accountId, eggGradeIds, saleDate, ct), movements, + transaction, lockedIn); + } public async Task RestoreAsync( Guid accountId, IReadOnlyDictionary quantityByLot, string referenceType, Guid referenceId, string reason, CancellationToken ct) { + RequireTransaction(); var lockedLots = await lots.GetByIdsLockedAsync(accountId, quantityByLot.Keys.ToList(), ct); if (lockedLots.Count != quantityByLot.Count) return Result.Failure(Error.Domain( @@ -34,19 +41,28 @@ await movements.AddAsync(EggInventoryMovement.Create( return Result.Success(); } + private Guid RequireTransaction() => + transaction.Id ?? throw new InvalidOperationException( + "IEggStock runs only inside a transaction: its FOR UPDATE locks end with it."); + private sealed class Reservation( Guid accountId, DateOnly saleDate, IReadOnlyList lockedLots, - IEggInventoryMovementRepository movements) : IEggStockReservation + IEggInventoryMovementRepository movements, ICurrentTransaction transaction, Guid lockedIn) + : IEggStockReservation { private readonly Dictionary _lotsById = lockedLots.ToDictionary(l => l.Id); - public SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet? fromFlocks = null) => - SaleAllocationPlanner.Plan(lines, + public SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet? fromFlocks = null) + { + EnsureLocked(); + return SaleAllocationPlanner.Plan(lines, fromFlocks is null ? lockedLots : lockedLots.Where(l => fromFlocks.Contains(l.FlockId)).ToList()); + } public async Task DrawAsync( PlannedEggLotDraw draw, string referenceType, Guid referenceId, CancellationToken ct) { + EnsureLocked(); var lot = _lotsById[draw.EggLotId]; var allocated = lot.Allocate(draw.Quantity, saleDate); if (allocated.IsFailure) @@ -62,5 +78,14 @@ await movements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, EggMovementType.Sale, -draw.Quantity, referenceType, referenceId), ct); } + + // After the locking transaction ends, another one may already have + // changed these lots, so neither the plan nor a draw can be trusted. + private void EnsureLocked() + { + if (transaction.Id != lockedIn) + throw new InvalidOperationException( + "This reservation's transaction has ended, so its lots are no longer locked."); + } } } diff --git a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs index 8c3aa383a..77ebad710 100644 --- a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs +++ b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs @@ -2,7 +2,8 @@ namespace Cluckwork.Application.Features.EggLots; // #854: Egg Operations' stock port for Commerce. It locks, plans and moves egg // lots inside the caller's transaction and never saves, like IMortalityLedger, -// so a confirm or a void commits its lot, ledger and order rows together. +// so a confirm or a void commits its lot, ledger and order rows together. It +// refuses to run outside a transaction. public interface IEggStock { // ONE FOR UPDATE over every available lot of these grades, farm-wide, in @@ -17,12 +18,14 @@ Task RestoreAsync( string referenceType, Guid referenceId, string reason, CancellationToken ct); } -// The lots one LockForSaleAsync call locked. +// The lots one LockForSaleAsync call locked. It works only while the +// transaction that locked them is current, and throws after it ends. public interface IEggStockReservation { - // Pure: plans over the locked lots, or only those from fromFlocks, and - // plans the same way every time, so a caller can try assigned flocks first - // and farm-wide second without a second lock. + // Plans over the locked lots' current availability, or only the lots from + // fromFlocks, and changes nothing, so a caller can try assigned flocks first + // and farm-wide second without a second lock. A DrawAsync lowers + // availability, so a plan made after a draw sees less. SaleAllocationPlan Plan(IReadOnlyList lines, IReadOnlySet? fromFlocks = null); // Takes one planned draw from its locked lot and writes the Sale movement. diff --git a/src/Cluckwork.Infrastructure/Persistence/CurrentTransaction.cs b/src/Cluckwork.Infrastructure/Persistence/CurrentTransaction.cs new file mode 100644 index 000000000..616d07076 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/CurrentTransaction.cs @@ -0,0 +1,8 @@ +using Cluckwork.Application.Common; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed class CurrentTransaction(AppDbContext db) : ICurrentTransaction +{ + public Guid? Id => db.Database.CurrentTransaction?.TransactionId; +} diff --git a/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs b/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs index aa72c73a8..06b4c85d2 100644 --- a/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs @@ -6,6 +6,7 @@ using Cluckwork.Application.Features.Sales.ConfirmSale; using Cluckwork.Domain.Eggs; using Cluckwork.Domain.Sales; +using Cluckwork.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -15,7 +16,9 @@ namespace Cluckwork.Api.IntegrationTests; // state and audit row together or not at all. Each case faults one step of the // confirm on the second of two draws and finds nothing changed. The handler // runs outside HTTP, so IdempotencyMiddleware's request transaction does not -// wrap it. +// wrap it. Each fault first checks the two rules that make the confirm atomic, +// so breaking either one alone fails here: the confirm is inside a transaction, +// and no draw was saved before the end. [Collection(IntegrationCollection.Name)] public sealed class ConfirmSaleAtomicityTests(CluckworkWebApplicationFactory factory) { @@ -44,14 +47,16 @@ public async Task Confirm_FailureMidTransaction_PersistsNothing(FaultPoint fault using (var scope = factory.Services.CreateScope().ResolveTenantAndActor(accountId, userId, email)) { var services = scope.ServiceProvider; + var db = services.GetRequiredService(); var stock = new EggStock(services.GetRequiredService(), - new FaultingMovements(services.GetRequiredService(), fault)); + new FaultingMovements(services.GetRequiredService(), fault, db), + services.GetRequiredService()); var handler = ActivatorUtilities.CreateInstance(services, stock, - new FaultingAllocations(services.GetRequiredService(), fault), - new FaultingAuditWriter(services.GetRequiredService(), fault)); + new FaultingAllocations(services.GetRequiredService(), fault, db), + new FaultingAuditWriter(services.GetRequiredService(), fault, db)); var thrown = await Assert.ThrowsAsync(() => handler.HandleAsync( new ConfirmSaleCommand(orderId), accountId, userId, CancellationToken.None)); - Assert.Equal(Fault(fault).Message, thrown.Message); + Assert.Equal(FaultMessage(fault), thrown.Message); } // Each seeded lot carries its Production movement; adding the line bumped the order once. @@ -79,10 +84,17 @@ await db.SalesOrderAllocations.CountAsync(a => a.SalesOrderId == orderId), await db.AuditEvents.CountAsync(e => e.EntityId == orderId)); }); - private static InvalidOperationException Fault(FaultPoint at) => - new($"#854 atomicity guard: faulting {at}."); + private static string FaultMessage(FaultPoint at) => $"#854 atomicity guard: faulting {at}."; - private sealed class FaultingMovements(IEggInventoryMovementRepository inner, FaultPoint fault) + // Both lots are drawn, so an Unchanged lot at the fault is a draw already saved. + private static Exception Fault(FaultPoint at, AppDbContext db) => + db.Database.CurrentTransaction is null + ? new Xunit.Sdk.XunitException($"{at}: the confirm runs outside a transaction") + : db.ChangeTracker.Entries().Any(e => e.State == EntityState.Unchanged) + ? new Xunit.Sdk.XunitException($"{at}: a drawn lot was already saved before the fault") + : new InvalidOperationException(FaultMessage(at)); + + private sealed class FaultingMovements(IEggInventoryMovementRepository inner, FaultPoint fault, AppDbContext db) : IEggInventoryMovementRepository { private int _calls; @@ -90,9 +102,9 @@ private sealed class FaultingMovements(IEggInventoryMovementRepository inner, Fa public async Task AddAsync(EggInventoryMovement movement, CancellationToken ct = default) { var second = ++_calls == 2; - if (second && fault == FaultPoint.AfterLotAllocation) throw Fault(fault); + if (second && fault == FaultPoint.AfterLotAllocation) throw Fault(fault, db); await inner.AddAsync(movement, ct); - if (second && fault == FaultPoint.AfterEggMovement) throw Fault(fault); + if (second && fault == FaultPoint.AfterEggMovement) throw Fault(fault, db); } public Task AddRangeAsync(IEnumerable movements, CancellationToken ct = default) => @@ -102,13 +114,13 @@ public Task> ListByLotAsync(Guid eggLotId, C inner.ListByLotAsync(eggLotId, ct); } - private sealed class FaultingAllocations(ISalesOrderAllocationRepository inner, FaultPoint fault) + private sealed class FaultingAllocations(ISalesOrderAllocationRepository inner, FaultPoint fault, AppDbContext db) : ISalesOrderAllocationRepository { public async Task AddRangeAsync(IReadOnlyList allocations, CancellationToken ct = default) { await inner.AddRangeAsync(allocations, ct); - if (fault == FaultPoint.AfterAllocationRows) throw Fault(fault); + if (fault == FaultPoint.AfterAllocationRows) throw Fault(fault, db); } public Task> ListPendingByOrderAsync( @@ -116,12 +128,12 @@ public Task> ListPendingByOrderAsync( inner.ListPendingByOrderAsync(salesOrderId, ct); } - private sealed class FaultingAuditWriter(IAuditWriter inner, FaultPoint fault) : IAuditWriter + private sealed class FaultingAuditWriter(IAuditWriter inner, FaultPoint fault, AppDbContext db) : IAuditWriter { public Task WriteAsync(string action, string entityType, Guid entityId, string? reason = null, object? details = null, CancellationToken ct = default) => fault == FaultPoint.AfterOrderConfirmation - ? throw Fault(fault) + ? throw Fault(fault, db) : inner.WriteAsync(action, entityType, entityId, reason, details, ct); } } diff --git a/tests/Cluckwork.Api.IntegrationTests/EggStockTransactionTests.cs b/tests/Cluckwork.Api.IntegrationTests/EggStockTransactionTests.cs new file mode 100644 index 000000000..bb67788ed --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/EggStockTransactionTests.cs @@ -0,0 +1,112 @@ +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Application.Common; +using Cluckwork.Application.Features.EggLots; +using Cluckwork.Domain.Eggs; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cluckwork.Api.IntegrationTests; + +// #854: IEggStock's FOR UPDATE locks end with the transaction that took them, +// so the port refuses to run outside one and a reservation refuses to plan or +// draw once that transaction is no longer the current one. +[Collection(IntegrationCollection.Name)] +public sealed class EggStockTransactionTests(CluckworkWebApplicationFactory factory) +{ + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + [Fact] + public async Task Reservation_AfterItsTransactionCommits_RefusesToPlanOrDraw() + { + var (accountId, gradeId, lotId) = await SeedLotAsync(); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + services.GetRequiredService().Resolve(accountId); + var unitOfWork = services.GetRequiredService(); + var reservation = await LockAsync(services, unitOfWork, accountId, gradeId); + Assert.Null(services.GetRequiredService().Database.CurrentTransaction); + + AssertRefused(Assert.Throws(() => + reservation.Plan([new SaleDemandLine(Guid.NewGuid(), gradeId, 10)]))); + AssertRefused(await Assert.ThrowsAsync(() => reservation.DrawAsync( + new PlannedEggLotDraw(Guid.NewGuid(), lotId, 10), "SalesOrderAllocation", Guid.NewGuid(), default))); + await unitOfWork.SaveChangesAsync(); + + Assert.Equal((30, 0), await StockAsync(accountId, lotId)); + } + + [Fact] + public async Task Reservation_InsideALaterTransaction_RefusesToPlanOrDraw() + { + var (accountId, gradeId, lotId) = await SeedLotAsync(); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + services.GetRequiredService().Resolve(accountId); + var unitOfWork = services.GetRequiredService(); + var reservation = await LockAsync(services, unitOfWork, accountId, gradeId); + + AssertRefused(await Assert.ThrowsAsync(() => + unitOfWork.ExecuteInTransactionAsync(_ => + { + reservation.Plan([new SaleDemandLine(Guid.NewGuid(), gradeId, 10)]); + return Task.FromResult(true); + }))); + AssertRefused(await Assert.ThrowsAsync(() => + unitOfWork.ExecuteInTransactionAsync(async ct => + { + await reservation.DrawAsync( + new PlannedEggLotDraw(Guid.NewGuid(), lotId, 10), "SalesOrderAllocation", Guid.NewGuid(), ct); + return true; + }))); + + Assert.Equal((30, 0), await StockAsync(accountId, lotId)); + } + + [Fact] + public async Task LockAndRestore_OutsideATransaction_Refuse() + { + var (accountId, gradeId, lotId) = await SeedLotAsync(); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + services.GetRequiredService().Resolve(accountId); + var stock = services.GetRequiredService(); + + AssertRefused(await Assert.ThrowsAsync(() => + stock.LockForSaleAsync(accountId, [gradeId], Today, default))); + AssertRefused(await Assert.ThrowsAsync(() => stock.RestoreAsync( + accountId, new Dictionary { [lotId] = 1 }, "SalesOrder", Guid.NewGuid(), "late", default))); + await services.GetRequiredService().SaveChangesAsync(); + + Assert.Equal((30, 0), await StockAsync(accountId, lotId)); + } + + private static void AssertRefused(InvalidOperationException thrown) => + Assert.Contains("transaction", thrown.Message, StringComparison.Ordinal); + + private static async Task LockAsync( + IServiceProvider services, IUnitOfWork unitOfWork, Guid accountId, Guid gradeId) + { + IEggStockReservation? reservation = null; + await unitOfWork.ExecuteInTransactionAsync(async ct => + { + reservation = await services.GetRequiredService() + .LockForSaleAsync(accountId, [gradeId], Today, ct); + return true; + }); + return reservation!; + } + + private async Task<(Guid AccountId, Guid GradeId, Guid LotId)> SeedLotAsync() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var gradeId = (await factory.SeedEggGradesAsync(accountId, Guid.NewGuid(), "Large"))["Large"]; + return (accountId, gradeId, await factory.SeedEggLotAsync(accountId, gradeId, 30, productionDate: Today)); + } + + // Availability and Sale movements, read through a fresh context. + private Task<(int Available, int SaleMovements)> StockAsync(Guid accountId, Guid lotId) => + factory.WithTenantScopeAsync(accountId, async db => ( + await db.EggLots.Where(l => l.Id == lotId).Select(l => l.QuantityAvailable).SingleAsync(), + await db.EggInventoryMovements.CountAsync(m => m.EggLotId == lotId && m.MovementType == EggMovementType.Sale))); +} From 1e7f511b1b7c311ec8317ffd2626a1664800576b Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 05:28:35 +0000 Subject: [PATCH 08/11] test(arch): check that Commerce reaches Egg Operations only through its contract PeerContractRealTreeTests walks Commerce's namespaces as adapter roots with the existing adapter scanner and fails on any Egg Operations type outside owners.EggOperations.contract. It is the first peer check #1023 asks for. Re-adding IEggLotRepository to ConfirmSaleHandler turns it red while every other architecture test stays green. Refs #854, #1023 --- .../Architecture/PeerContractRealTreeTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/Cluckwork.Application.Tests/Architecture/PeerContractRealTreeTests.cs diff --git a/tests/Cluckwork.Application.Tests/Architecture/PeerContractRealTreeTests.cs b/tests/Cluckwork.Application.Tests/Architecture/PeerContractRealTreeTests.cs new file mode 100644 index 000000000..24275013c --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/PeerContractRealTreeTests.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Nodes; +using Cluckwork.Application.Tests.TenantBypass; + +namespace Cluckwork.Application.Tests.Architecture; + +// #854, the first peer check #1023 asks for: Commerce reaches Egg Operations +// only through owners.EggOperations.contract. Commerce's own namespaces are +// walked as adapter roots, so the adapter scanner reads the same parameter +// types and service resolutions it reads for endpoints. Only the Egg Operations +// reach is checked: Commerce calls Farm's IAccountRepository and Access's +// assignment repository on purpose (#851, #857). +public sealed class PeerContractRealTreeTests +{ + [Fact] + public void Commerce_ReachesEggOperationsOnlyThroughItsContract() + { + var repoRoot = GuardScanner.FindRepoRoot(AppContext.BaseDirectory) + ?? throw new InvalidOperationException("repo root not found"); + var ledger = JsonNode.Parse(File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Architecture", "Data", "module-ledger.json")))!; + ledger["adapterRoots"]!["namespaces"] = ledger["owners"]!["Commerce"]!["namespaces"]!.DeepClone(); + ledger["adapterRoots"]!["types"] = new JsonArray(); + ledger["adapterRoots"]!["topLevelPrograms"] = new JsonArray(); + var ledgerPath = Path.Combine(Path.GetTempPath(), $"commerce-peer-ledger-{Guid.NewGuid():N}.json"); + File.WriteAllText(ledgerPath, ledger.ToJsonString()); + try + { + var report = AdapterReachScanner.Scan(Path.Combine(repoRoot, "src"), ledgerPath); + + Assert.Contains(report.LiveReach, r => + r.Symbol == "Cluckwork.Application.Features.Sales.ConfirmSale.ConfirmSaleHandler.ctor" + && r.Type == "Cluckwork.Application.Features.EggLots.IEggStock"); + var bypasses = report.ContractBypasses.Where(r => r.Owner == "EggOperations") + .Select(r => $"{r.Symbol} through {r.Type} at {r.File}:{r.Line}").ToList(); + Assert.True(bypasses.Count == 0, + "Commerce reaches Egg Operations outside its contract:\n" + string.Join("\n", bypasses)); + } + finally + { + File.Delete(ledgerPath); + } + } +} From bc3eb12e9e4ca6b0e18bac8eb93d721df74673f6 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 05:28:35 +0000 Subject: [PATCH 09/11] docs(sales): name the lookup ports in ledger reasons and record this round The Farm -> Commerce and Access -> Commerce reasons, and three Commerce foreign-key reasons, still named repositories the handlers no longer take. The decision record and AGENTS.md describe the transaction binding, the peer check and the stricter atomicity test. Refs #854 --- AGENTS.md | 2 +- docs/decisions/854-commerce-contract.md | 41 +++++++++++++++---- .../Architecture/Data/module-ledger.json | 10 ++--- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e3ab4a00..ccd3d7521 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ dotnet test Cluckwork.sln # 2887 tests as of 2026-09-21; integr - **The coupling matrix is generated from the ledger and its three walks (#848).** `CouplingMatrixRealTreeTests` validates the module-edge, EF-model and adapter reports before rendering `tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md`, then compares normalized line endings with the committed file. Regenerate with `CLUCKWORK_REGENERATE_MATRIX=1 dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~CouplingMatrixRealTreeTests`; do not hand-edit the Markdown. Its observable-differences table has seven `W`/`R`/dash mismatches with §3.4. A separate table records its unobservable `E`, `Q` and Platform cells. `P` is the free Platform hub, and `A` counts adapter reaches. → [`848-generated-coupling-matrix.md`](docs/decisions/848-generated-coupling-matrix.md) -- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. `owners..contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations and Commerce are contracted today. The check covers adapters, not peer modules, because the edge walk records namespaces rather than types. → [`849-module-contract.md`](docs/decisions/849-module-contract.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. Nothing checks peer modules: a peer that injects `IFlockRepository` again stays green. → [`852-flock-contract.md`](docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a pure planner over the locked lots so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. Commerce never sees `EggLot`. `ConfirmSaleAtomicityTests` holds the all-or-nothing confirm, and it stays green if only the transaction or only the never-save rule breaks, because each covers the other; keep both. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](docs/decisions/854-commerce-contract.md) +- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. `owners..contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations and Commerce are contracted today. The check covers adapters, not peer modules, because the edge walk records namespaces rather than types. → [`849-module-contract.md`](docs/decisions/849-module-contract.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. Nothing checks peer modules: a peer that injects `IFlockRepository` again stays green. → [`852-flock-contract.md`](docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads signatures, not method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](docs/decisions/854-commerce-contract.md) - **Register every read of a contracted module's tables from outside it (#514/#850).** `CompatibilityExceptionRealTreeTests` compiles `src/Cluckwork.Infrastructure` from source, and the projects referencing it with errors tolerated. It finds every member that obtains a `DbSet`, maps `T` through the EF model to the tables a query touches (its own, owned values mapped apart and derived types), and takes each table's owner from the ledger's `tables`, never the entity's namespace. A read of a contracted module's table is allowed for the module's own types, a type its ledger edge's `symbols` names, a type listed under `owners..implementations`, and a `DbSet` property whose whole body is `Set()`. Every other read needs a `compatibilityExceptions` row in `module-ledger.json`, keyed by namespace, type and member, with `reaches`, the `tables` read, a ledger `owner`, a `reason` and `deleteWhen`, the slice issue that deletes it. `deleteWhen` is `#`, never a date. A new read, a table the row does not name, a `DbSet` of a type parameter and an unbound candidate name fail; a row or table no longer read fails as stale; an Infrastructure compile error fails closed. SQL text is not read. The next contract slice lists its implementations and registers its module's other readers. → [`850-compatibility-exceptions.md`](docs/decisions/850-compatibility-exceptions.md) ### Data and correctness diff --git a/docs/decisions/854-commerce-contract.md b/docs/decisions/854-commerce-contract.md index 32532c747..4a54184ce 100644 --- a/docs/decisions/854-commerce-contract.md +++ b/docs/decisions/854-commerce-contract.md @@ -53,9 +53,11 @@ Commerce reaches egg lots through Egg Operations' `IEggStock`, never through - `LockForSaleAsync` takes the confirm's one `FOR UPDATE` over every available lot of the order's grades, farm-wide, in `(ProductionDate, Id)` order, and returns an `IEggStockReservation`. -- `IEggStockReservation.Plan` is the FIFO planner over those locked lots. It is - pure, so `ConfirmSaleHandler` can plan over a restricted Worker's assigned - flocks first and over every locked lot second without a second lock (#612). +- `IEggStockReservation.Plan` is the FIFO planner over those locked lots' + current availability. It changes nothing, so `ConfirmSaleHandler` can plan + over a restricted Worker's assigned flocks first and over every locked lot + second without a second lock (#612). A draw lowers availability, so a plan + made after a draw sees less. - `IEggStockReservation.DrawAsync` calls `EggLot.Allocate` on the locked instance and writes the Sale movement. A refusal throws: the plan read the same instance. @@ -63,7 +65,13 @@ Commerce reaches egg lots through Egg Operations' `IEggStock`, never through refuses `EggLot.AllocationSourceMissing`, restores each lot and writes its Void movement. -The port never saves, like `IMortalityLedger` (#852). Commerce keeps the order +The port never saves, like `IMortalityLedger` (#852), and it runs only inside a +transaction, because a `FOR UPDATE` lock ends with its transaction. Both methods +refuse when the caller has no transaction. A reservation remembers the +transaction that locked its lots and refuses to plan or draw once another +transaction, or none, is current. It reads that through `ICurrentTransaction`, a +Platform port that exposes only the transaction's id, so no persistence type +reaches Application. Commerce keeps the order lock, the #612 policy, the refusal messages, the allocation rows and their ids, and the audit row. Egg Operations keeps the lock, the planner, lot mutation and the ledger rows. The reference type on a movement is passed in, so Egg @@ -99,8 +107,12 @@ Infrastructure, the Platform hub, so the inversion adds no module edge. ## What this does NOT cover -- Peer modules are not checked (#849, #852). A peer that injects - `IEggUnitConversionRepository` again stays green. +- Peer modules other than Commerce's reach into Egg Operations are not checked + (#849, #852, #1023). A peer that injects `IEggUnitConversionRepository` again + stays green. +- `PeerContractRealTreeTests` reads parameter types and service resolutions, as + the adapter check does, not method bodies. A Commerce method that calls + `SaleAllocationPlanner.Plan` from its body stays green. - `ConfirmSaleHandler`'s in-transaction role and assignment reads stay a declared Commerce to Access edge for #857. `AccountProvisioner` still inserts a new farm's default conversions, a declared Access to Commerce edge (#857). @@ -126,13 +138,24 @@ Infrastructure, the Platform hub, so the inversion adds no module edge. - `CompatibilityExceptionRealTreeTests` fails on an unregistered read of a Commerce table. Removing `PaymentRepository` from `implementations` turns it red, its currency-source read included. +- `PeerContractRealTreeTests` walks Commerce's namespaces as adapter roots and + fails on any Egg Operations type outside `owners.EggOperations.contract`, the + first peer check #1023 asks for. Adding `IEggLotRepository eggLots` back to + `ConfirmSaleHandler` turns it red; every other architecture test stays green. - `EggLotLockOrderTests.ConfirmSale_TwoGrades_HoldsTheOtherGradesEarlierLotWhileWaitingOnTheLater` turns red when the port locks each grade in line order. +- `EggStockTransactionTests` keeps a reservation after its transaction commits, + then plans and draws, both with no transaction and inside a later one, and + calls the port with no transaction. Dropping the reservation's check, or + letting the port run without a transaction, turns it red. - `ConfirmSaleAtomicityTests` faults a confirm after a lot allocation, after its Sale movement, after the allocation rows and after the order is confirmed, and - finds nothing persisted. Removing the confirm's transaction alone, or saving - after each draw alone, leaves it green: one holds the other. Both together - turn all four cases red. + finds nothing persisted. At each fault it also checks that the confirm still + runs in a transaction and that no draw was saved yet. Saving after each draw + turns all four cases red with `a drawn lot was already saved before the + fault`. Removing the confirm's transaction turns them red at the port's own + check, and with the port's checks also off, at the test's `the confirm runs + outside a transaction`. - `CommerceModuleTests` pins the copies. Swapping `Email` and `Address`, `Quantity` and `QuantityBase`, or `ReferenceNumber` and `Note` turns it red. - Unregistering `ProductRepository` as a currency source turns diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json index fc512d25a..3e305398a 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json @@ -164,7 +164,7 @@ "edges": [ { "from": "Access", "to": "Commerce", "kind": "W", - "reason": "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionRepository and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove.", + "reason": "W because AccountProvisioner inserts EggUnitConversion.Defaults for a new farm inside its provisioning transaction; the rest of the cell reads. Identity carries the per-user stepper-unit preference: ApplicationUser stores Domain.Catalog.EggUnit, IdentityProvider reads and writes it, SetStepperUnitHandler resolves it through Commerce's IEggUnitConversionLookup port (#854) and rejects inactive conversions, and SetStepperUnitValidator validates only the enum name. Nothing prevents a stored preference from pointing at a conversion later deactivated through UpdateEggUnitConversionHandler. Design 3.4 shows Access -> Commerce as none; this is live coupling the target design has still to remove.", "symbols": [ "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitHandler", "Cluckwork.Application.Features.Users.SetStepperUnit.SetStepperUnitValidator", @@ -266,7 +266,7 @@ }, { "from": "Farm", "to": "Commerce", "kind": "R", - "reason": "Farm settings own three Commerce-shaped values. Domain.Accounts.Account holds Domain.Catalog.EggUnit as the farm's default stepper unit and exposes Domain.Sales.DiscountCeiling, while UpdateFarmSettingsHandler resolves the stepper unit through Commerce's IEggUnitConversionRepository and UpdateFarmSettingsValidator parses the ceiling with DiscountCeiling.TryParsePercent (#727). The Farm contract's FarmSettingsDetails, declared beside IFarmModule, carries the same Domain.Catalog.EggUnit stepper unit out to its callers (#851). Design 3.4 shows Farm -> Commerce as none; this is live coupling the target design has still to remove.", + "reason": "Farm settings own three Commerce-shaped values. Domain.Accounts.Account holds Domain.Catalog.EggUnit as the farm's default stepper unit and exposes Domain.Sales.DiscountCeiling, while UpdateFarmSettingsHandler resolves the stepper unit through Commerce's IEggUnitConversionLookup port (#854) and UpdateFarmSettingsValidator parses the ceiling with DiscountCeiling.TryParsePercent (#727). The Farm contract's FarmSettingsDetails, declared beside IFarmModule, carries the same Domain.Catalog.EggUnit stepper unit out to its callers (#851). Design 3.4 shows Farm -> Commerce as none; this is live coupling the target design has still to remove.", "symbols": [ "Cluckwork.Application.Features.Accounts.FarmSettingsDetails", "Cluckwork.Application.Features.Accounts.IFarmModule", @@ -475,21 +475,21 @@ "name": "FK_ProductEggGradeMappings_EggGrades_EggGradeId", "from": "Commerce", "to": "EggOperations", - "reason": "product grade mappings reference egg grades; catalog handlers validate through IEggGradeRepository (design 3.3)" + "reason": "product grade mappings reference egg grades; catalog handlers validate through IEggGradeLookup (#854, design 3.3)" }, { "table": "SalesOrderAllocations", "name": "FK_SalesOrderAllocations_EggLots_EggLotId", "from": "Commerce", "to": "EggOperations", - "reason": "sale allocations reserve egg lots; ConfirmSaleHandler allocates through IEggLotRepository (design 3.3)" + "reason": "sale allocations reserve egg lots; ConfirmSaleHandler allocates through IEggStock (#854, design 3.3)" }, { "table": "SalesOrderItems", "name": "FK_SalesOrderItems_EggGrades_EggGradeId", "from": "Commerce", "to": "EggOperations", - "reason": "sale items retain their egg grade; AddOrderItemHandler validates through IEggGradeRepository (design 3.3)" + "reason": "sale items retain their egg grade; AddOrderItemHandler validates through IEggGradeLookup (#854, design 3.3)" }, { "table": "WaterUsages", From ae905442cf1b7d5c441199fad98db1bdbea76cb2 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 05:53:59 +0000 Subject: [PATCH 10/11] docs(eggs): record that a reservation must not outlive a savepoint rollback The reservation check compares transaction ids only. Rolling back to a savepoint taken before LockForSaleAsync releases its row locks while the transaction id stays the same. Nothing in src/ creates such a savepoint; the owner chose to record the limit (PR #1030, review round 2). Refs #854 --- docs/decisions/854-commerce-contract.md | 13 ++++++++++++- .../Features/EggLots/IEggStock.cs | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/decisions/854-commerce-contract.md b/docs/decisions/854-commerce-contract.md index 4a54184ce..686cd935d 100644 --- a/docs/decisions/854-commerce-contract.md +++ b/docs/decisions/854-commerce-contract.md @@ -71,7 +71,18 @@ refuse when the caller has no transaction. A reservation remembers the transaction that locked its lots and refuses to plan or draw once another transaction, or none, is current. It reads that through `ICurrentTransaction`, a Platform port that exposes only the transaction's id, so no persistence type -reaches Application. Commerce keeps the order +reaches Application. + +The check compares transaction ids only. Rolling back to a savepoint taken +before `LockForSaleAsync` releases the reservation's row locks, but the +transaction id stays the same, so the reservation would still plan and draw. +Do not use a reservation after rolling back to a savepoint taken before it was +created. Nothing in `src/` creates or rolls back to such a savepoint: the only +savepoint is the one EF takes inside `SaveChanges`, after the locks. The owner +chose to record this limit rather than track savepoints (PR #1030, review +round 2). + +Commerce keeps the order lock, the #612 policy, the refusal messages, the allocation rows and their ids, and the audit row. Egg Operations keeps the lock, the planner, lot mutation and the ledger rows. The reference type on a movement is passed in, so Egg diff --git a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs index 77ebad710..f65d27f19 100644 --- a/src/Cluckwork.Application/Features/EggLots/IEggStock.cs +++ b/src/Cluckwork.Application/Features/EggLots/IEggStock.cs @@ -19,7 +19,11 @@ Task RestoreAsync( } // The lots one LockForSaleAsync call locked. It works only while the -// transaction that locked them is current, and throws after it ends. +// transaction that locked them is current, and throws after it ends. The check +// compares transaction ids only, so do not use a reservation after rolling back +// to a savepoint taken before it was created: PostgreSQL releases the locks and +// the id stays the same. Nothing in src/ creates such a savepoint; EF's own +// SaveChanges savepoint comes after the locks. public interface IEggStockReservation { // Plans over the locked lots' current availability, or only the lots from From 04222ba6f990d0a12b52eeeb7d1b0d9ce08493fb Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 3 Oct 2026 06:52:05 +0000 Subject: [PATCH 11/11] test(sales): pin which order line each allocation row names SaleAllocationLineTests confirms a two-line order and requires each allocation row to name the line whose grade it drew; nothing pinned it, and the admin export carries it. AGENTS.md and the decision record now say the peer check reads parameter types and service resolutions, not return types or method bodies. Refs #854 --- AGENTS.md | 2 +- docs/decisions/854-commerce-contract.md | 10 +++-- .../SaleAllocationLineTests.cs | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 tests/Cluckwork.Api.IntegrationTests/SaleAllocationLineTests.cs diff --git a/AGENTS.md b/AGENTS.md index ccd3d7521..fedc84fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ dotnet test Cluckwork.sln # 2887 tests as of 2026-09-21; integr - **The coupling matrix is generated from the ledger and its three walks (#848).** `CouplingMatrixRealTreeTests` validates the module-edge, EF-model and adapter reports before rendering `tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md`, then compares normalized line endings with the committed file. Regenerate with `CLUCKWORK_REGENERATE_MATRIX=1 dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~CouplingMatrixRealTreeTests`; do not hand-edit the Markdown. Its observable-differences table has seven `W`/`R`/dash mismatches with §3.4. A separate table records its unobservable `E`, `Q` and Platform cells. `P` is the free Platform hub, and `A` counts adapter reaches. → [`848-generated-coupling-matrix.md`](docs/decisions/848-generated-coupling-matrix.md) -- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. `owners..contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations and Commerce are contracted today. The check covers adapters, not peer modules, because the edge walk records namespaces rather than types. → [`849-module-contract.md`](docs/decisions/849-module-contract.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. Nothing checks peer modules: a peer that injects `IFlockRepository` again stays green. → [`852-flock-contract.md`](docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads signatures, not method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](docs/decisions/854-commerce-contract.md) +- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `IModule` interface in the module's own Application namespace, implemented by a `Module` that delegates to the existing handlers and repositories and is registered beside them. `owners..contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations and Commerce are contracted today. The check covers adapters, not peer modules, because the edge walk records namespaces rather than types. → [`849-module-contract.md`](docs/decisions/849-module-contract.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. Nothing checks peer modules: a peer that injects `IFlockRepository` again stays green. → [`852-flock-contract.md`](docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads only parameter types and service resolutions, not return types or method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](docs/decisions/854-commerce-contract.md) - **Register every read of a contracted module's tables from outside it (#514/#850).** `CompatibilityExceptionRealTreeTests` compiles `src/Cluckwork.Infrastructure` from source, and the projects referencing it with errors tolerated. It finds every member that obtains a `DbSet`, maps `T` through the EF model to the tables a query touches (its own, owned values mapped apart and derived types), and takes each table's owner from the ledger's `tables`, never the entity's namespace. A read of a contracted module's table is allowed for the module's own types, a type its ledger edge's `symbols` names, a type listed under `owners..implementations`, and a `DbSet` property whose whole body is `Set()`. Every other read needs a `compatibilityExceptions` row in `module-ledger.json`, keyed by namespace, type and member, with `reaches`, the `tables` read, a ledger `owner`, a `reason` and `deleteWhen`, the slice issue that deletes it. `deleteWhen` is `#`, never a date. A new read, a table the row does not name, a `DbSet` of a type parameter and an unbound candidate name fail; a row or table no longer read fails as stale; an Infrastructure compile error fails closed. SQL text is not read. The next contract slice lists its implementations and registers its module's other readers. → [`850-compatibility-exceptions.md`](docs/decisions/850-compatibility-exceptions.md) ### Data and correctness diff --git a/docs/decisions/854-commerce-contract.md b/docs/decisions/854-commerce-contract.md index 686cd935d..497918e69 100644 --- a/docs/decisions/854-commerce-contract.md +++ b/docs/decisions/854-commerce-contract.md @@ -121,9 +121,10 @@ Infrastructure, the Platform hub, so the inversion adds no module edge. - Peer modules other than Commerce's reach into Egg Operations are not checked (#849, #852, #1023). A peer that injects `IEggUnitConversionRepository` again stays green. -- `PeerContractRealTreeTests` reads parameter types and service resolutions, as - the adapter check does, not method bodies. A Commerce method that calls - `SaleAllocationPlanner.Plan` from its body stays green. +- `PeerContractRealTreeTests` reads only parameter types and service + resolutions, as the adapter check does, not return types or method bodies. A + Commerce method that returns `EggLot`, or calls `SaleAllocationPlanner.Plan` + from its body, stays green. - `ConfirmSaleHandler`'s in-transaction role and assignment reads stay a declared Commerce to Access edge for #857. `AccountProvisioner` still inserts a new farm's default conversions, a declared Access to Commerce edge (#857). @@ -167,6 +168,9 @@ Infrastructure, the Platform hub, so the inversion adds no module edge. fault`. Removing the confirm's transaction turns them red at the port's own check, and with the port's checks also off, at the test's `the confirm runs outside a transaction`. +- `SaleAllocationLineTests` confirms a two-line order and requires each + allocation row to name the line whose grade it drew. Stamping every draw with + the first line's id, in the planner or in the handler, turns it red. - `CommerceModuleTests` pins the copies. Swapping `Email` and `Address`, `Quantity` and `QuantityBase`, or `ReferenceNumber` and `Note` turns it red. - Unregistering `ProductRepository` as a currency source turns diff --git a/tests/Cluckwork.Api.IntegrationTests/SaleAllocationLineTests.cs b/tests/Cluckwork.Api.IntegrationTests/SaleAllocationLineTests.cs new file mode 100644 index 000000000..5ffbc67b5 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/SaleAllocationLineTests.cs @@ -0,0 +1,39 @@ +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Cluckwork.Api.IntegrationTests; + +// #854: each allocation row names the order line it was drawn for. The admin +// export and the sale-to-lot chain (spec §9.6) read SalesOrderItemId, and only +// a multi-line order can show a row stamped with the wrong line. +[Collection(IntegrationCollection.Name)] +public sealed class SaleAllocationLineTests(CluckworkWebApplicationFactory factory) +{ + [Fact] + public async Task Confirm_MultiLineOrder_EachAllocationNamesTheLineItWasDrawnFor() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var grades = await factory.SeedEggGradesAsync(accountId, Guid.NewGuid(), "Large", "Medium"); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + await factory.SeedEggLotAsync(accountId, grades["Large"], 30, productionDate: today); + await factory.SeedEggLotAsync(accountId, grades["Medium"], 30, productionDate: today); + var order = await factory.SeedSalesOrderAsync(accountId, [(grades["Large"], 10), (grades["Medium"], 4)]); + + (await client.PostWithKeyAsync($"/api/v1/sales/{order}/confirm", Guid.NewGuid().ToString())) + .EnsureSuccessStatusCode(); + + var rows = await factory.WithTenantScopeAsync(accountId, db => + (from allocation in db.SalesOrderAllocations + join line in db.SalesOrderItems on allocation.SalesOrderItemId equals line.Id + join lot in db.EggLots on allocation.EggLotId equals lot.Id + where allocation.SalesOrderId == order + orderby allocation.Quantity + select new { LineGrade = line.EggGradeId, LotGrade = lot.EggGradeId, allocation.Quantity }) + .ToListAsync()); + Assert.Equal( + [(grades["Medium"], grades["Medium"], 4), (grades["Large"], grades["Large"], 10)], + rows.Select(r => (r.LineGrade, r.LotGrade, r.Quantity))); + } +}