Compare commits

...

40 Commits

Author SHA1 Message Date
DeepnaPooja 919c76746d Add local Claude settings 2026-09-16 16:26:29 +05:30
DeepnaPooja 76c08855e4 Add invoice model 2026-09-16 16:26:05 +05:30
DeepnaPooja 1ed78b9932 Add invoice lifecycle migration 2026-09-16 16:26:04 +05:30
DeepnaPooja 62de2ddce1 Add invoice lifecycle migration designer 2026-09-16 16:26:03 +05:30
DeepnaPooja 8bc895cbc5 Add invoice service 2026-09-16 16:26:02 +05:30
DeepnaPooja 792d7c0140 Add invoice repository 2026-09-16 16:26:00 +05:30
DeepnaPooja 54931ef110 Add invoice DTOs 2026-09-16 16:25:59 +05:30
DeepnaPooja 8575034714 Add invoice controller 2026-09-16 16:25:59 +05:30
DeepnaPooja 710fc15253 Add invoice repository contract 2026-09-16 16:25:58 +05:30
DeepnaPooja db24c66d77 Document order invoice lifecycle 2026-09-16 16:25:56 +05:30
DeepnaPooja 0cd4508d50 Update invoice collection docs 2026-09-16 16:25:55 +05:30
DeepnaPooja c8f86163f7 Update dispatch pipeline docs 2026-09-16 16:25:54 +05:30
DeepnaPooja 02c673328e Add order invoice settings 2026-09-16 16:25:53 +05:30
DeepnaPooja c137aae7a5 Register invoice services 2026-09-16 16:25:53 +05:30
DeepnaPooja e3d4837379 Add order invoice fields 2026-09-16 16:25:52 +05:30
DeepnaPooja de5dcf99b3 Add invoice enums 2026-09-16 16:25:51 +05:30
DeepnaPooja 2b68e2cc04 Update invoice model snapshot 2026-09-16 16:25:51 +05:30
DeepnaPooja faaa1fe6a5 Link sale invoices 2026-09-16 16:25:50 +05:30
DeepnaPooja f42809a4e3 Update return invoice handling 2026-09-16 16:25:50 +05:30
DeepnaPooja 4b56443d1a Add order invoice lifecycle 2026-09-16 16:25:50 +05:30
DeepnaPooja 72f850ea4d Update order invoice queries 2026-09-16 16:25:49 +05:30
DeepnaPooja 9f04242dc7 Update order invoice DTOs 2026-09-16 16:25:49 +05:30
DeepnaPooja d06d434143 Update order invoice endpoints 2026-09-16 16:25:48 +05:30
DeepnaPooja 248129900d Update order repository contract 2026-09-16 16:25:48 +05:30
DeepnaPooja ea8f952f14 Update delivery task invoice flow 2026-09-16 16:25:47 +05:30
DeepnaPooja 4d63b3e720 Seed invoice operations 2026-09-16 16:25:47 +05:30
DeepnaPooja 2f9661fafc Add invoice context mapping 2026-09-16 16:25:46 +05:30
DeepnaPooja 7d740efabb Update test file list cache 2026-09-16 16:25:46 +05:30
DeepnaPooja 222279b478 Update test compile inputs cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 70fd4a9150 Update test assembly reference cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 15b3e43fe8 Update test assembly inputs cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 8fd31a9b20 Update test assembly info 2026-09-16 16:25:45 +05:30
Kalana B. Thilakarathna 6c40421b2d Revamp dispatch pipeline and PO cancellation docs
Updated documentation to clarify and redesign dispatch pipeline, loading event, and order/invoice lifecycle. Added PO cancellation flow for both Shop and Confirmed POs, introduced new API endpoint for cancellation, and simplified loading sheet model and endpoints. Added new permissions for loading operations, improved auditability, and detailed migration steps. Highlighted open questions on cancellation cascades, permissions, and vehicle capacity checks for future resolution.
2026-09-16 11:30:21 +05:30
Kalana B. Thilakarathna 541f59b15d Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-09-16 10:57:40 +05:30
Kalana B. Thilakarathna b68e5043c7 refactor update 2026-09-16 10:57:31 +05:30
DeepnaPooja 436f7c10a0 Merge pull request 'Add invoice collection plan for cash, cheque, and credit payments post-finalization' (#24) from fixes into dev
Reviewed-on: #24
2026-09-16 05:12:49 +00:00
Kalana B. Thilakarathna 8739e8e1a7 refactor 2026-09-16 10:41:16 +05:30
Kalana B. Thilakarathna 308be2f5ce refactor invoice and po life cycle 2026-09-16 10:39:42 +05:30
DeepnaPooja ca2507a904 Merge pull request 'Refactor order invoice lifecycle and ride lifecycle documentation' (#22) from fixes into dev
Reviewed-on: #22
2026-09-14 06:28:31 +00:00
DeepnaPooja 307055f8e1 Merge pull request 'Fixes' (#21) from fixes into dev
Reviewed-on: #21
2026-09-10 10:30:04 +00:00
33 changed files with 5969 additions and 398 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(dotnet build *)",
"PowerShell(Get-Command mysql -ErrorAction SilentlyContinue)",
"PowerShell(Get-Command mysql.exe -ErrorAction SilentlyContinue)"
]
}
}
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6c40421b2d23630d4249841425ec7906d93d5985")]
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
00c7acd66c7d7d5bdbb8d4468924c7202fbdc15259175676556b482252622db8
@@ -1 +1 @@
ce3b0b0b2776e1f5cfcf61d517b9f6ce1c89ed303e7e35ab0b7261f5686d0ddb
b5689108dc90f1afbeded25902110c1295d20ccadf0aaae3dfd42fcd421eba23
@@ -125,3 +125,13 @@ C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\n
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.genruntimeconfig.cache
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\.msCoverageSourceRootsMapping_Distribution.Tests
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\CoverletSourceRootsMapping_Distribution.Tests
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.AssemblyReference.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.GeneratedMSBuildEditorConfig.editorconfig
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfoInputs.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfo.cs
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.CoreCompileInputs.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\refint\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
+51 -1
View File
@@ -20,6 +20,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
@@ -64,8 +65,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// OrderItem uses its inherited Template.Id as PK (no composite key on
// {FK_OrderId, FK_ItemId}) so that editing a PO's items can soft-delete the current
// row and insert a fresh one for the same item — versioning per order_invoice_lifecycle.md.
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
.HasIndex(oi => oi.FK_OrderId);
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -143,8 +147,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.CreatedByUser)
.WithMany()
.HasForeignKey(o => o.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Order>()
.HasOne(o => o.ConfirmedByUser)
.WithMany()
.HasForeignKey(o => o.ConfirmedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// Order.InvoiceId is a plain column with no DB-level FK constraint — deliberately
// unmapped as a relationship, per order_invoice_lifecycle.md.
modelBuilder.Entity<Order>()
.Property(o => o.InvoiceId)
.HasColumnName("InvoiceId");
modelBuilder.Entity<Invoice>()
.HasOne(i => i.Order)
.WithMany()
.HasForeignKey(i => i.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.PrintedByUser)
.WithMany()
.HasForeignKey(i => i.PrintedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.FinalizedByUser)
.WithMany()
.HasForeignKey(i => i.FinalizedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
@@ -104,6 +104,7 @@ public static class OperationsDataSeed
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
@@ -116,6 +117,7 @@ public static class OperationsDataSeed
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
TotalAmount = 1200m,
Status = OrderStatus.Pending,
FulfilledAt = null,
@@ -187,9 +187,9 @@ public class DeliveryTaskService(
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
if (fullyDelivered && order.FK_SaleId is Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
var sale = await saleRepo.GetByIdAsync(saleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
@@ -0,0 +1,9 @@
namespace Distribution.Features.Invoice;
public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.Invoice entity);
void Update(Models.Invoice entity);
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateInvoiceRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, CancellationToken ct)
{
try
{
var result = await service.PrintAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/finalize")]
public async Task<IActionResult> Finalize(Guid id, FinalizeInvoiceRequest request, CancellationToken ct)
{
try
{
var result = await service.FinalizeAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,29 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid OrderId);
public record FinalizeInvoiceItemRequest(Guid OrderItemId, decimal DeliveredQuantity, decimal FreeQuantity);
public record FinalizeInvoiceReturnRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
public record FinalizeInvoiceDiscountRequest(DiscountType Type, decimal Value, string? Reason);
public record FinalizeInvoiceRequest(
List<FinalizeInvoiceItemRequest> Items,
List<FinalizeInvoiceReturnRequest>? Returns,
FinalizeInvoiceDiscountRequest? Discount);
public record InvoiceResponse(
Guid Id,
Guid OrderId,
InvoiceStatus Status,
Guid CreatedByUserId,
DateTime? PrintedAt,
Guid? PrintedByUserId,
DateTime? FinalizedAt,
Guid? FinalizedByUserId,
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,19 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices.FindAsync(id);
public async Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId) =>
await context.Invoices.FirstOrDefaultAsync(i => i.FK_OrderId == orderId && i.IsActive);
public async Task AddAsync(Models.Invoice entity) =>
await context.Invoices.AddAsync(entity);
public void Update(Models.Invoice entity) =>
context.Invoices.Update(entity);
}
@@ -0,0 +1,203 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Returns;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IOrderRepository orderRepo,
IReturnRepository returnRepo,
IItemRepository itemRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IConfiguration configuration)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_OrderId, i.Status, i.CreatedByUserId, i.PrintedAt, i.PrintedByUserId,
i.FinalizedAt, i.FinalizedByUserId, i.TotalAmount, i.IsActive, i.CreatedAt);
private bool IsModeB =>
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") != "A";
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
}
/// <summary>Mode B only — creates the Invoice (Draft) for an already-Confirmed PO as a
/// separate action. Mode A creates the Invoice atomically via
/// OrderService.ConfirmAndCreateInvoiceAsync instead.</summary>
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, CancellationToken ct = default)
{
if (!IsModeB)
throw new InvalidOperationException("POST /api/invoices is not enabled — this system uses InvoiceCreationMode A, which creates the Invoice as part of PO confirmation.");
var order = await orderRepo.GetByIdAsync(request.OrderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.ConfirmedAt is null)
throw new InvalidOperationException("Only a Confirmed PO can have an Invoice created for it.");
if (order.InvoiceId is not null)
throw new InvalidOperationException("An Invoice already exists for this PO.");
var invoice = new Models.Invoice
{
FK_OrderId = order.Id,
Status = InvoiceStatus.Draft,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
TotalAmount = order.TotalAmount,
};
order.InvoiceId = invoice.Id;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(invoice);
orderRepo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
/// <summary>Draft → Printed. The agent takes the physical copy to the field and may make
/// handwritten edits during the ride.</summary>
public async Task<InvoiceResponse?> PrintAsync(Guid id, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException("Only a Draft invoice can be printed.");
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedByUserId = currentUser.UserId;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
/// <summary>Printed → Finalized. The office worker enters whatever the agent handwrote on the
/// returned printed copy — delivered/free quantities per line, returns/damage (reusing the
/// SaleReturn/CreditNote shape from 18_returns_claims.md), and an optional invoice-level
/// discount. Payload shape resolved in order_invoice_lifecycle.md Open Question 6.</summary>
public async Task<InvoiceResponse?> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException("An invoice can only be finalized from Printed — Draft → Finalized directly is not a valid transition.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("Finalize must include at least one line item.");
var order = await orderRepo.GetByIdAsync(invoice.FK_OrderId)
?? throw new InvalidOperationException("Order not found for this Invoice.");
var orderItems = (await orderRepo.GetItemsByOrderIdAsync(order.Id)).ToDictionary(oi => oi.Id);
decimal subtotal = 0;
foreach (var line in request.Items)
{
if (!orderItems.TryGetValue(line.OrderItemId, out var orderItem))
throw new InvalidOperationException($"OrderItem {line.OrderItemId} does not belong to this Invoice's PO.");
if (line.DeliveredQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Delivered/free quantity cannot be negative.");
subtotal += line.DeliveredQuantity * orderItem.UnitPrice; // freeQuantity never bills
orderItem.QuantityFulfilled = (int)(line.DeliveredQuantity + line.FreeQuantity);
}
Models.SaleReturn? saleReturn = null;
decimal creditNoteAmount = 0;
if (request.Returns is { Count: > 0 })
{
saleReturn = new Models.SaleReturn
{
FK_OrderId = order.Id,
Mode = ReturnMode.CreditDeduction,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
};
foreach (var line in request.Returns)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var matchingOrderItem = orderItems.Values.FirstOrDefault(oi => oi.FK_ItemId == line.ItemId);
var unitPrice = matchingOrderItem?.UnitPrice
?? (await itemRepo.GetByIdAsync(line.ItemId))?.SalePrice
?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice,
});
}
creditNoteAmount = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = creditNoteAmount;
}
decimal discountAmount = request.Discount switch
{
{ Type: DiscountType.Amount } d => d.Value,
{ Type: DiscountType.Percentage } d => subtotal * d.Value / 100m,
null => 0,
_ => 0,
};
invoice.TotalAmount = Math.Max(0, subtotal - discountAmount - creditNoteAmount);
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedByUserId = currentUser.UserId;
order.TotalAmount = invoice.TotalAmount;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
orderRepo.Update(order);
if (saleReturn is not null)
{
await returnRepo.AddAsync(saleReturn);
await returnRepo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = creditNoteAmount,
});
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
}
@@ -8,8 +8,12 @@ public interface IOrderRepository
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
/// <summary>Confirmed POs (ConfirmedAt set, not cancelled) matching the optional filters —
/// backs Mode B's "office browses Confirmed POs" step per order_invoice_lifecycle.md.</summary>
Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId);
}
+45 -1
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
@@ -10,6 +10,10 @@ public class OrderController(OrderService service) : ControllerBase
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("confirmed")]
public async Task<IActionResult> GetConfirmed([FromQuery] Guid? agentId, [FromQuery] Guid? routeId, [FromQuery] Guid? shopId) =>
Ok(await service.GetConfirmedAsync(agentId, routeId, shopId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -35,6 +39,39 @@ public class OrderController(OrderService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> ReplaceItems(Guid id, UpdateOrderItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.ReplaceItemsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-and-create-invoice")]
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAndCreateInvoiceAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
@@ -46,6 +83,13 @@ public class OrderController(OrderService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
var cancelled = await service.DeleteAsync(id, ct);
return cancelled ? NoContent() : NotFound();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
+10 -3
View File
@@ -2,19 +2,26 @@ using Distribution.Models;
namespace Distribution.Features.Order;
public record CreateOrderRequest(Guid SaleId);
public record CreateOrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice);
public record CreateOrderRequest(Guid StoreId, List<CreateOrderItemRequest> Items);
public record UpdateOrderItemsRequest(List<CreateOrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderItemResponse(Guid Id, Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
Guid? SaleId,
Guid? InvoiceId,
DateTime OrderDate,
Guid StoreId,
Guid CreatedByUserId,
decimal TotalAmount,
OrderStatus Status,
DateTime? ConfirmedAt,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
+16 -3
View File
@@ -28,8 +28,10 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
context.Orders.Update(entity);
}
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId) =>
await context.OrderItems.Where(oi => oi.FK_OrderId == orderId).ToListAsync();
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false) =>
await context.OrderItems
.Where(oi => oi.FK_OrderId == orderId && (includeHistory || oi.IsActive))
.ToListAsync();
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
@@ -40,10 +42,21 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.Include(o => o.OrderItems.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
public async Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId) =>
await context.Orders
.Include(o => o.Store)
.Where(o => o.IsActive
&& o.ConfirmedAt != null
&& o.Status != OrderStatus.Cancelled
&& (agentId == null || o.CreatedByUserId == agentId)
&& (routeId == null || o.Store.FK_RouteId == routeId)
&& (storeId == null || o.FK_StoreId == storeId))
.ToListAsync();
}
+236 -15
View File
@@ -1,11 +1,13 @@
using Distribution.Features.Sale;
using Distribution.Features.Invoice;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Sale;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
@@ -16,19 +18,30 @@ public class OrderService(
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IInvoiceRepository invoiceRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess)
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
new(o.Id, o.FK_SaleId, o.InvoiceId, o.OrderDate, o.FK_StoreId, o.CreatedByUserId, o.TotalAmount, o.Status,
o.ConfirmedAt, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.Id, i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
/// <summary>The active InvoiceCreationMode ("A" = combined confirm+create-invoice,
/// "B" = separate confirm then create-invoice) per order_invoice_lifecycle.md. Exactly one is
/// reachable at a time.</summary>
private InvoiceCreationMode ActiveMode =>
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") == "A"
? InvoiceCreationMode.A
: InvoiceCreationMode.B;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
@@ -76,17 +89,206 @@ public class OrderService(
return result;
}
/// <summary>Confirmed POs matching the given filters — Mode B's "office browses Confirmed POs"
/// step. Also usable as a general Confirmed-PO listing regardless of active mode.</summary>
public async Task<List<OrderResponse>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId)
{
var orders = await repo.GetConfirmedAsync(agentId, routeId, storeId);
var result = new List<OrderResponse>();
foreach (var order in orders)
{
var items = await repo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(order, items));
}
return result;
}
private async Task<decimal> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Guid storeId, DateTime orderDate)
{
if (requestedUnitPrice.HasValue) return requestedUnitPrice.Value;
var resolution = await priceListService.ResolveByStoreAsync(itemId, storeId, orderDate);
if (resolution is not null) return resolution.Price;
var item = await itemRepo.GetByIdAsync(itemId)
?? throw new InvalidOperationException($"Item {itemId} not found");
return item.SalePrice
?? throw new InvalidOperationException($"Could not resolve a price for item {itemId} — no price list covers it and it has no catalogue SalePrice");
}
/// <summary>Creates a Shop PO independent of any Sale — the entry point to the pipeline per
/// order_invoice_lifecycle.md. Items are freely editable until Confirm.</summary>
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var store = await storeRepo.GetByIdAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(request.StoreId)) return null;
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A PO must have at least one line item.");
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var order = new Models.Order
{
FK_StoreId = request.StoreId,
OrderDate = DateTime.Now,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
Status = OrderStatus.Pending,
};
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
var items = new List<Models.OrderItem>();
foreach (var line in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, request.StoreId, order.OrderDate);
items.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitPrice = unitPrice,
IsActive = true,
});
}
order.TotalAmount = items.Sum(i => i.Quantity * i.UnitPrice);
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(order);
await repo.AddItemsAsync(items);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, items);
}
/// <summary>Rewrites a Shop PO's line items — every edit soft-deletes the current active rows
/// and writes a fresh set (active = latest version, soft-deleted = history). Only allowed before
/// confirmation.</summary>
public async Task<OrderResponse?> ReplaceItemsAsync(Guid id, UpdateOrderItemsRequest request, CancellationToken ct = default)
{
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
if (order.ConfirmedAt is not null)
throw new InvalidOperationException("Items are locked once the PO is confirmed.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A PO must have at least one line item.");
var currentItems = await repo.GetItemsByOrderIdAsync(id);
foreach (var item in currentItems)
item.IsActive = false;
var newItems = new List<Models.OrderItem>();
foreach (var line in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(line.ItemId, line.UnitPrice, order.FK_StoreId, order.OrderDate);
newItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitPrice = unitPrice,
IsActive = true,
});
}
order.TotalAmount = newItems.Sum(i => i.Quantity * i.UnitPrice);
await uow.BeginAsync(ct);
try
{
// currentItems came from the tracked DbContext (OrderRepository does not use
// AsNoTracking), so flipping IsActive above is enough for SaveChanges to persist it —
// no explicit repo.Update call needed for the soft-deleted rows.
await repo.AddItemsAsync(newItems);
repo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, newItems);
}
/// <summary>Mode B — confirms the PO (locks items) without creating an Invoice. The Invoice is
/// created separately later via InvoiceService.CreateAsync.</summary>
public async Task<OrderResponse?> ConfirmAsync(Guid id, CancellationToken ct = default)
{
if (ActiveMode != InvoiceCreationMode.B)
throw new InvalidOperationException("Separate confirm is not enabled — this system uses InvoiceCreationMode A.");
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
if (order.Status == OrderStatus.Cancelled)
throw new InvalidOperationException("Cannot confirm a cancelled PO.");
if (order.ConfirmedAt is not null) return ToResponse(order, await repo.GetItemsByOrderIdAsync(id));
order.ConfirmedAt = DateTime.Now;
order.ConfirmedByUserId = currentUser.UserId;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
/// <summary>Mode A — confirms the PO and creates its Invoice (Draft) atomically in one
/// transaction.</summary>
public async Task<OrderResponse?> ConfirmAndCreateInvoiceAsync(Guid id, CancellationToken ct = default)
{
if (ActiveMode != InvoiceCreationMode.A)
throw new InvalidOperationException("Combined confirm-and-create-invoice is not enabled — this system uses InvoiceCreationMode B.");
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
if (order.Status == OrderStatus.Cancelled)
throw new InvalidOperationException("Cannot confirm a cancelled PO.");
if (order.InvoiceId is not null)
throw new InvalidOperationException("An Invoice already exists for this PO.");
var items = await repo.GetItemsByOrderIdAsync(id);
var invoice = new Models.Invoice
{
FK_OrderId = order.Id,
Status = InvoiceStatus.Draft,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
TotalAmount = order.TotalAmount,
};
order.ConfirmedAt ??= DateTime.Now;
order.ConfirmedByUserId ??= currentUser.UserId;
order.InvoiceId = invoice.Id;
await uow.BeginAsync(ct);
try
{
await invoiceRepo.AddAsync(invoice);
repo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
@@ -96,12 +298,13 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
if (order.FK_SaleId is Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId)
?? throw new InvalidOperationException("Sale not found for this order");
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
@@ -122,11 +325,29 @@ public class OrderService(
await uow.RollbackAsync(ct);
throw;
}
}
else
{
await uow.BeginAsync(ct);
try
{
repo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
/// <summary>Cancels a Shop PO or Confirmed PO. If an Invoice was already created for this PO,
/// the Invoice/loading-sheet cascade is an explicitly unresolved open question in
/// order_invoice_lifecycle.md (Open Question 5) — the Invoice is left untouched here.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
@@ -134,7 +355,7 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
var sale = order.FK_SaleId is Guid saleId ? await saleRepo.GetByIdAsync(saleId) : null;
await uow.BeginAsync(ct);
try
@@ -78,7 +78,9 @@ public class ReturnService(
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
if (order.FK_SaleId is null)
throw new InvalidOperationException("Sale not found for this order.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId.Value)
?? throw new InvalidOperationException("Sale not found for this order.");
if (sale.FK_RideId is null)
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
@@ -189,6 +189,7 @@ public class SaleService(
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
CreatedByUserId = userId,
};
foreach (var orderItem in orderItems)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddOrderInvoiceLifecycle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Must run before DropPrimaryKey below: OrderItems.FK_OrderId has a foreign key to
// Orders, and MySQL requires a supporting index on that column at all times. The old
// composite PK (FK_OrderId, FK_ItemId) was providing that support; without this index
// created first, dropping the PK leaves FK_OrderId's constraint without an index and
// MySQL rejects the DROP PRIMARY KEY (errno 150).
migrationBuilder.CreateIndex(
name: "IX_OrderItems_FK_OrderId",
table: "OrderItems",
column: "FK_OrderId");
migrationBuilder.DropPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems");
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
migrationBuilder.AlterColumn<Guid>(
name: "FK_SaleId",
table: "Orders",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "ConfirmedAt",
table: "Orders",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ConfirmedByUserId",
table: "Orders",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "CreatedByUserId",
table: "Orders",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
// Backfill every existing Order's new CreatedByUserId from its linked Sale (Order.FK_SaleId
// was required until this migration, so every pre-existing row has one). Covers every
// row generically, not just the two rows seeded via HasData below — otherwise any Order
// created outside the EF seed data would keep the temporary AddColumn default
// (Guid.Empty) and fail the FK_Orders_Users_CreatedByUserId constraint added at the end
// of this migration.
migrationBuilder.Sql(@"
UPDATE `Orders` o
INNER JOIN `Sales` s ON s.`Id` = o.`FK_SaleId`
SET o.`CreatedByUserId` = s.`CreatedByUserId`
WHERE o.`FK_SaleId` IS NOT NULL;");
migrationBuilder.AddColumn<Guid>(
name: "InvoiceId",
table: "Orders",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems",
column: "Id");
migrationBuilder.CreateTable(
name: "Invoices",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
PrintedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PrintedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FinalizedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalizedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Invoices", x => x.Id);
table.ForeignKey(
name: "FK_Invoices_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Invoices_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Invoices_Users_FinalizedByUserId",
column: x => x.FinalizedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Invoices_Users_PrintedByUserId",
column: x => x.PrintedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_OrderId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("34000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("34000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), true, 24, 24, 50m },
{ new Guid("34000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), true, 10, 0, 120m }
});
migrationBuilder.UpdateData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000001"),
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000002"), null });
migrationBuilder.UpdateData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000002"),
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000003"), null });
migrationBuilder.CreateIndex(
name: "IX_Orders_ConfirmedByUserId",
table: "Orders",
column: "ConfirmedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Orders_CreatedByUserId",
table: "Orders",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_CreatedByUserId",
table: "Invoices",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_FinalizedByUserId",
table: "Invoices",
column: "FinalizedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_FK_OrderId",
table: "Invoices",
column: "FK_OrderId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_PrintedByUserId",
table: "Invoices",
column: "PrintedByUserId");
migrationBuilder.AddForeignKey(
name: "FK_Orders_Users_ConfirmedByUserId",
table: "Orders",
column: "ConfirmedByUserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Orders_Users_CreatedByUserId",
table: "Orders",
column: "CreatedByUserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Orders_Users_ConfirmedByUserId",
table: "Orders");
migrationBuilder.DropForeignKey(
name: "FK_Orders_Users_CreatedByUserId",
table: "Orders");
migrationBuilder.DropTable(
name: "Invoices");
migrationBuilder.DropIndex(
name: "IX_Orders_ConfirmedByUserId",
table: "Orders");
migrationBuilder.DropIndex(
name: "IX_Orders_CreatedByUserId",
table: "Orders");
migrationBuilder.DropPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems");
migrationBuilder.DropIndex(
name: "IX_OrderItems_FK_OrderId",
table: "OrderItems");
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000003"));
migrationBuilder.DropColumn(
name: "ConfirmedAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "ConfirmedByUserId",
table: "Orders");
migrationBuilder.DropColumn(
name: "CreatedByUserId",
table: "Orders");
migrationBuilder.DropColumn(
name: "InvoiceId",
table: "Orders");
migrationBuilder.AlterColumn<Guid>(
name: "FK_SaleId",
table: "Orders",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems",
columns: new[] { "FK_OrderId", "FK_ItemId" });
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
});
}
}
}
@@ -536,6 +536,55 @@ namespace Distribution.Migrations
});
});
modelBuilder.Entity("Distribution.Models.Invoice", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<DateTime?>("FinalizedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("FinalizedByUserId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("PrintedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("PrintedByUserId")
.HasColumnType("char(36)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_OrderId");
b.HasIndex("FinalizedByUserId");
b.HasIndex("PrintedByUserId");
b.ToTable("Invoices");
});
modelBuilder.Entity("Distribution.Models.Item", b =>
{
b.Property<Guid>("Id")
@@ -768,10 +817,19 @@ namespace Distribution.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ConfirmedByUserId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
@@ -780,6 +838,10 @@ namespace Distribution.Migrations
b.Property<DateTime?>("FulfilledAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("InvoiceId")
.HasColumnType("char(36)")
.HasColumnName("InvoiceId");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
@@ -794,6 +856,10 @@ namespace Distribution.Migrations
b.HasKey("Id");
b.HasIndex("ConfirmedByUserId");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_SaleId")
.IsUnique();
@@ -806,6 +872,7 @@ namespace Distribution.Migrations
{
Id = new Guid("33000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
CreatedByUserId = new Guid("29000000-0000-0000-0000-000000000002"),
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000001"),
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000001"),
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified),
@@ -818,6 +885,7 @@ namespace Distribution.Migrations
{
Id = new Guid("33000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
CreatedByUserId = new Guid("29000000-0000-0000-0000-000000000003"),
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000002"),
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000005"),
IsActive = true,
@@ -829,16 +897,17 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.OrderItem", b =>
{
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("Id")
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
@@ -853,19 +922,21 @@ namespace Distribution.Migrations
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
b.HasKey("FK_OrderId", "FK_ItemId");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_OrderId");
b.ToTable("OrderItems");
b.HasData(
new
{
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("34000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true,
Quantity = 30,
QuantityFulfilled = 30,
@@ -873,10 +944,10 @@ namespace Distribution.Migrations
},
new
{
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("34000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true,
Quantity = 24,
QuantityFulfilled = 24,
@@ -884,10 +955,10 @@ namespace Distribution.Migrations
},
new
{
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000002"),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("34000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000002"),
IsActive = true,
Quantity = 10,
QuantityFulfilled = 0,
@@ -3300,6 +3371,39 @@ namespace Distribution.Migrations
b.Navigation("PurchaseOrderItem");
});
modelBuilder.Entity("Distribution.Models.Invoice", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "FinalizedByUser")
.WithMany()
.HasForeignKey("FinalizedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.User", "PrintedByUser")
.WithMany()
.HasForeignKey("PrintedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CreatedByUser");
b.Navigation("FinalizedByUser");
b.Navigation("Order");
b.Navigation("PrintedByUser");
});
modelBuilder.Entity("Distribution.Models.Item", b =>
{
b.HasOne("Distribution.Models.Uom", "BaseUom")
@@ -3371,11 +3475,21 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.HasOne("Distribution.Models.User", "ConfirmedByUser")
.WithMany()
.HasForeignKey("ConfirmedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
@@ -3383,6 +3497,10 @@ namespace Distribution.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ConfirmedByUser");
b.Navigation("CreatedByUser");
b.Navigation("Sale");
b.Navigation("Store");
+6
View File
@@ -23,6 +23,12 @@ namespace Distribution.Models
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum InvoiceStatus { Draft, Printed, Finalized }
public enum InvoiceCreationMode { A, B }
public enum DiscountType { Amount, Percentage }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
public enum PaymentMethod { Cash, Credit }
+30
View File
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Invoice : Template
{
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public DateTime? PrintedAt { get; set; }
public Guid? PrintedByUserId { get; set; }
[ForeignKey("PrintedByUserId")]
public User? PrintedByUser { get; set; }
public DateTime? FinalizedAt { get; set; }
public Guid? FinalizedByUserId { get; set; }
[ForeignKey("FinalizedByUserId")]
public User? FinalizedByUser { get; set; }
/// <summary>Reflects current PO line items through Draft/Printed; locked at Finalized.</summary>
public decimal TotalAmount { get; set; }
}
}
+20 -3
View File
@@ -1,12 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Order : Template
{
public Guid FK_SaleId { get; set; }
public Guid? FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public Sale? Sale { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
/// <summary>Set once an Invoice is created for this PO (either mode). No DB-level FK
/// constraint — configured explicitly in Fluent API per order_invoice_lifecycle.md.</summary>
public Guid? InvoiceId { get; set; }
public DateTime OrderDate { get; set; }
public Guid FK_StoreId { get; set; }
@@ -16,6 +24,15 @@ namespace Distribution.Models
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime? FulfilledAt { get; set; }
/// <summary>Null = Shop PO (items editable). Set = Confirmed PO (items locked). Not modeled
/// as an explicit field in order_invoice_lifecycle.md's diagram, but required to implement
/// its Business Rule 1 (item edits gated on confirmation) independent of Invoice existing —
/// Mode B confirms a PO with no Invoice at all.</summary>
public DateTime? ConfirmedAt { get; set; }
public Guid? ConfirmedByUserId { get; set; }
[ForeignKey("ConfirmedByUserId")]
public User? ConfirmedByUser { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
}
}
+3
View File
@@ -10,6 +10,7 @@ using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Item;
using Distribution.Features.Invoice;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
@@ -79,6 +80,7 @@ builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -112,6 +114,7 @@ builder.Services.AddScoped<ClaimService>();
builder.Services.AddScoped<SupplierClaimService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+3
View File
@@ -26,6 +26,9 @@
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
},
"OrderInvoice": {
"InvoiceCreationMode": "B"
},
"AuthService": {
"BaseUrl": ""
}
@@ -0,0 +1,170 @@
# [Refactor] Dispatch Pipeline — Shop PO to Invoice Finalization
**Status**: Proposed (design only — no code changes yet)
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [loading_event.md](./loading_event.md), [ride_lifecycle.md](./ride_lifecycle.md)
---
## Goal
Define the end-to-end order processing pipeline from a shop's initial PO through to invoice
completion. This replaces the separate Delivery ride and Sale ride flows
([05_delivery_ride.md](../05_delivery_ride.md), [06_sale_ride.md](../06_sale_ride.md)) with a
single unified pipeline.
---
## Pipeline Overview
```
[Stage 1] Shop PO created (editable)
↓
[Stage 2] PO Confirmed + Invoice Created → Draft (Mode A: combined / Mode B: separate)
↓
[Stage 3] Invoice Printed → agent takes to field
↓
[Stage 4] Loading Sheet Population (dispatcher-triggered, separate flow)
↓
[Stage 5] Delivery (TBD)
↓
[Stage 6] Agent returns printed invoice → office worker enters edits → Invoice Finalized
```
---
## Stage 1 — Shop PO
**Business term**: Shop PO
**System state**: `Order` (no Invoice yet)
The entry point to the pipeline. A sales agent creates a PO for a shop capturing line items and
quantities.
**Key rules:**
- Items editable — every edit soft-deletes current `OrderItem` rows (`IsActive = false`) and writes
a fresh set. `IsActive = true` = latest version; `IsActive = false` = history.
- Editing allowed only while Shop PO (before confirmation).
- No Invoice exists yet at this stage.
- A Shop PO can be cancelled (`Order.Status → Cancelled`). No Invoice exists at this point so no
cascade required.
**API:**
| Method | Route | Description |
|---|---|---|
| POST | `/api/orders` | Create Shop PO for a store. |
| PUT | `/api/orders/{id}/items` | Rewrite line items (versioned). |
| GET | `/api/orders/{id}` | Get PO with current active items. |
| POST | `/api/orders/{id}/cancel` | Cancel a Shop PO. |
---
## Stage 2 — Confirmed PO + Invoice Created
**Business term**: Confirmed PO
**System state**: `Order` (items locked) + `Invoice` at `Draft`
PO items are locked and an Invoice is created at `Draft`. How this happens depends on the active
mode — controlled by the system-level `InvoiceCreationMode` config flag.
**Mode A — combined action (one button):**
- `POST /api/orders/{id}/confirm-and-create-invoice`
- Locks PO items and creates Invoice at `Draft` atomically.
**Mode B — separate actions:**
- `POST /api/orders/{id}/confirm` — locks PO items only.
- Office user then browses Confirmed POs (filtered by `agentId`, `routeId`, `shopId`) and creates
the invoice: `POST /api/invoices { orderId }`.
**Key rules:**
- Confirmation requires no signature or OTP.
- `OrderItem` lines are permanently locked after confirmation.
- `Order.Status` (fulfillment tracking) is untouched — separate axis.
- A Confirmed PO can be cancelled even after confirmation (`Order.Status → Cancelled`). If an
Invoice was already created, cascade behaviour (void Invoice, impact on loading sheet items) is
unresolved — see Open Questions.
---
## Stage 3 — Invoice Printed
**Trigger**: `POST /api/invoices/{id}/print`
Office user prints the invoice. The agent takes the physical copy to the field.
**Key rules:**
- Agent may make handwritten edits on the printed copy during the ride (quantity adjustments,
returns, damage, discounts).
- Invoice is locked from system edits between `Printed` and `Finalized`.
---
## Stage 4 — Loading Sheet Population
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` with one or more
filters. The system finds all matching Confirmed POs and aggregates them into **one loading sheet**.
**Filters (at least one required):**
- `routeId` — all Confirmed POs for shops on this route
- `agentId` — all Confirmed POs created by this agent
- `shopId` — all Confirmed POs for this specific shop
**What happens:**
1. System finds all `Order` records matching the filter where items are locked (Confirmed PO)
2. One `LoadingSheet` created at `Planned` status
3. `LoadingSheetItem` rows populated from the active `OrderItem` lines of those POs
4. Each source PO recorded in `LoadingSheetOrder` linking table
5. Each item carries `FK_OrderItemId` pointing back to its source `OrderItem`
**After population — editing:**
- Dispatcher can edit item quantities while the sheet is `Planned` or `Assigned`
- Every edit soft-deletes current `LoadingSheetItem` rows (`IsActive = false`) and writes a fresh
set — same versioning as Shop PO items
**Confirmation and stock movement:**
- Ride is provided at populate time — no separate assign step
- Each item has its own `FK_WarehouseId` — one sheet can load from multiple warehouses
- Dispatcher confirms the sheet (`Planned → Confirmed`) — items frozen
- Stock movements: one debit per item's warehouse, one credit to the ride's vehicle
- Timing: open question (Path A at `Confirmed` vs Path B at `ReceiptConfirmed`)
See [loading_event.md](./loading_event.md) for the full state machine.
---
## Stage 5 — Delivery
**Status**: TBD — to be designed in a subsequent pass.
---
## Stage 6 — Invoice Finalized
**Trigger**: `POST /api/invoices/{id}/finalize`
After the ride or day-end, the agent returns the printed invoice to the office. The office worker
enters any edits the agent made into the system and submits.
**What happens:**
- Office worker enters adjustments (quantity changes, returns, damage, discounts) from the paper copy
- `Invoice.TotalAmount` is recomputed and locked
- `Invoice.Status` → `Finalized`
- `FinalizedAt` / `FinalizedBy` set
---
## Open Questions
1. **Who can confirm a Shop PO?** — the creating sales agent, a supervisor, or either?
2. **Can a Confirmed PO be reverted to Shop PO?** — no un-confirm path designed here.
3. **Cancellation cascade** — both Shop PO and Confirmed PO can be cancelled. If cancelling a
Confirmed PO that already has a linked Invoice: should the Invoice be voided automatically?
If the PO's items were already pulled into a loading sheet: what happens to those loading sheet
items? Cascade rules needed before implementation.
4. ~~**Exact adjustment payload for `POST /api/invoices/{id}/finalize`**~~ — **Resolved**, see
[order_invoice_lifecycle.md Open Question 6](./order_invoice_lifecycle.md#open-questions):
`items[]` (`deliveredQuantity` + `freeQuantity` per line), `returns[]` (reusing the
`SaleReturn`/`CreditNote` shape from `18_returns_claims.md`, `Damaged` as a `ReasonCode` rather
than a separate field), and a single invoice-level `discount`.
5. **Stage 3 and Stage 4 ordering** — Invoice printing (Stage 3) and loading sheet population
(Stage 4) are independent flows. They are not strictly sequential — both depend on a Confirmed
PO existing but neither requires the other to complete first.
@@ -2,7 +2,7 @@
**Status**: Proposed (design only — no code changes yet)
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md) — this document only applies
once an `Invoice` reaches `Finalized`; it does not touch Pending/Draft.
once an `Invoice` reaches `Finalized`; it does not touch Draft/Printed.
**Refactors**: `Models/Payment.cs`, `Models/Enums.cs` (`PaymentMethod`, `PaymentStatus`),
`Sale.PaymentStatus`, and the payment recording flow in
[06_sale_ride.md](../06_sale_ride.md) (`POST /api/sales/{saleId}/payments`). Also resolves
@@ -107,13 +107,13 @@ audit and for spotting collectors who never enter anything in real time.
```
Invoice
├── ... (existing fields from order_invoice_lifecycle.md: Status, ConfirmedAt, FinalizedAt, ...)
├── ... (existing fields from order_invoice_lifecycle.md: Status, PrintedAt, PrintedBy, FinalizedAt, FinalizedBy, ...)
├── PaymentStatus (PaymentStatus) — NEW: Pending, PartiallyPaid, Paid — mirrors the existing
│ Sale.PaymentStatus pattern from 06_sale_ride.md, recomputed on
│ every Payment insert (see Business Rules)
```
This is a deliberate second axis alongside `Invoice.Status` (`Pending/Draft/Finalized`, the document
This is a deliberate second axis alongside `Invoice.Status` (`Draft/Printed/Finalized`, the document
lifecycle) — `PaymentStatus` only starts being meaningful once `Status = Finalized`, and answers
`order_invoice_lifecycle.md`'s Open Question 3 (whether the two axes should merge): they stay
separate. `PaymentStatus` is the same axis `19_finance_accounting.md` proposed baking into
@@ -130,7 +130,7 @@ has a linked Order/Invoice — see Impact section.
1. A `Payment` can only be recorded against an `Invoice` whose `Status = Finalized` — matches
`order_invoice_lifecycle.md`'s framing that Finalize is "the record used for day-end calculations."
Attempting to record a `Payment` against a Pending/Draft invoice is rejected (409).
Attempting to record a `Payment` against a Draft/Printed invoice is rejected (409).
2. Multiple `Payment` rows are allowed per `Invoice` — collection is not required to happen in a
single action. Each row has exactly one `Method`; a mixed cash+cheque collection on one visit is
two rows with the same `CollectedAt`/visit context, not one split row.
+141 -157
View File
@@ -1,41 +1,38 @@
# [Refactor] Loading Event — PO-Generated, Route-Free, No Ride Activation
# [Refactor] Loading Event — Dispatcher-Populated, Multi-PO, Multi-Warehouse
**Status**: Proposed (design only — no code changes yet)
**Refactors**: [../04_loading_event.md](../04_loading_event.md) (kept as-is, this is the target design)
**Depends On**: [ride_lifecycle.md](./ride_lifecycle.md), [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)
**Depends On**: [ride_lifecycle.md](./ride_lifecycle.md), [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
---
## Goal
Bring `04_loading_event.md` in line with the other two refactors:
Replace the previous loading event design with a dispatcher-driven, multi-PO model:
1. **No more `Open -> Active`.** [ride_lifecycle.md](./ride_lifecycle.md) collapses `Ride.Status` to
`Open`/`Cancelled`/`Closed` — there is no `Active` stage left to auto-trigger. Confirming a
loading event no longer changes `Ride.Status` at all; the ride simply stays `Open` through
loading, delivery, and everything else, until it's explicitly `Closed` or `Cancelled`.
`LoadingSheet.Status` tracks loading progress entirely on its own.
2. **No more Route or `RideType`-based item population.** `04_loading_event.md`'s "Delivery rides
auto-populate from the ride's route" no longer applies — `Ride` no longer carries a route
([ride_lifecycle.md](./ride_lifecycle.md)) and no longer distinguishes Delivery/Sale. Instead, per
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md), a `LoadingSheet` is generated
automatically from a single PO's items the moment that PO is confirmed (`Invoice` Pending →
Draft) — before any ride exists.
3. **A `LoadingSheet` now starts ride-less.** It's created PO-scoped
(`FK_OrderId`), not ride-scoped, and gets attached to an actual `Ride` + source warehouse later,
via a new explicit action, once a vehicle/trip is planned for it. The rest of the
dispatch/receipt/discrepancy workflow is unchanged in shape — it just now runs per PO-generated
sheet instead of per ride-created sheet.
1. **No more auto-creation.** Loading sheets are no longer generated automatically when a PO is
confirmed. A dispatcher manually triggers population.
2. **No more `Open → Active`.** `Ride.Status` collapses to `Open`/`Cancelled`/`Closed` — no
`Active` stage. Loading sheet confirmation no longer changes `Ride.Status`.
3. **One sheet aggregates multiple POs.** A single loading sheet is created from all Confirmed POs
matching a dispatcher's filter (route/agent/shop). No longer one sheet per PO.
4. **Ride set at populate time.** The ride is provided when the dispatcher triggers population —
no separate assign-ride step.
5. **Warehouse is per item, not per sheet.** Different items on the same loading sheet can be
loaded from different warehouses. Stock movements are generated per item's warehouse.
6. **Item versioning.** Loading sheet items are versioned the same way as Shop PO items — every
edit soft-deletes current rows (`IsActive = false`) and writes a fresh set.
7. **Stock moves at confirmation.** When the dispatcher confirms the sheet, stock moves from each
item's warehouse to the vehicle. No driver receipt confirmation step.
---
## Background / Context
Same physical mechanics as `04_loading_event.md`: a vehicle is a location with
`LocationType.Vehicle` in the stock ledger, and confirming receipt (or resolving a discrepancy)
creates the same two stock movements per item — debit source warehouse, credit vehicle. None of that
changes here. What changes is *when a `LoadingSheet` comes into existence* and *what it takes to
start the dispatch/receipt workflow on it*.
`LocationType.Vehicle` in the stock ledger. Confirming the loading sheet creates stock movements —
debit source warehouse(s), credit vehicle. What changes is *when and how a `LoadingSheet` comes
into existence*, *which POs it covers*, and *how warehouse selection works*.
---
@@ -46,80 +43,104 @@ start the dispatch/receipt workflow on it*.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| **`FK_OrderId`** | Guid | **New, required.** FK → `Order` (PO). Set at creation; never changes. |
| **`FK_RideId`** | Guid? | **Now nullable.** Null while `Planned`; set by the new assign-ride action. |
| **`FK_SourceWarehouseId`** | Guid? | **Now nullable**, for the same reason — set together with `FK_RideId` at assign time, not at creation. |
| Status | LoadingSheetStatus | State machine below — gains a `Planned`/`Assigned` prefix. |
| **`AssignedAt`** | DateTime? | **New.** Set when a Ride + source warehouse are assigned. |
| **`AssignedBy`** | Guid? | **New.** UserId of whoever assigned the ride. |
| DispatchConfirmedAt | DateTime? | Unchanged — set when warehouse confirms dispatch. |
| DispatchConfirmedBy | Guid? | Unchanged. |
| ReceiptConfirmedAt | DateTime? | Unchanged — set when driver confirms receipt. |
| ReceiptConfirmedBy | Guid? | Unchanged. |
| CreatedAt | DateTime | Unchanged. |
| ~~`FK_OrderId`~~ | — | **Removed.** One sheet now aggregates multiple POs — replaced by `LoadingSheetOrder` linking table. |
| **`FK_RideId`** | Guid | **Required. Set at population time** — dispatcher provides the ride when triggering populate. |
| ~~`FK_SourceWarehouseId`~~ | — | **Removed.** Warehouse is tracked per item, not per sheet. |
| Status | LoadingSheetStatus | `Planned` or `Confirmed`. |
| **`PopulatedBy`** | Guid | **New.** UserId of dispatcher who triggered population. |
| **`PopulatedAt`** | DateTime | **New.** When population was triggered. |
| ConfirmedAt | DateTime? | Set when loading sheet is confirmed (items locked, stock moves). |
| ConfirmedBy | Guid? | UserId of whoever confirmed the sheet. TBD — see Open Questions. |
| CreatedAt | DateTime | From Template. |
`LoadingSheetItem` is **unchanged** (`FK_ItemId`, `QuantityDispatched`, `QuantityReceived`,
`QuantityResolved`) — still populated the same way, just from a different source (see
[Item Population](#item-population) below).
### New Entity — `LoadingSheetOrder` (linking table)
One loading sheet aggregates multiple Confirmed POs. This table records which POs were included.
| Column | Type | Notes |
|---|---|---|
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| FK_OrderId | Guid | FK → Order (must be a Confirmed PO at population time) |
### Updated Entity — `LoadingSheetItem`
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| **`FK_WarehouseId`** | Guid | **New. Required.** The warehouse this specific item is loaded from. Different items on the same sheet can come from different warehouses. Set by dispatcher when editing items. |
| **`FK_OrderItemId`** | Guid? | **New.** Traces back to the source `OrderItem` from the Confirmed PO. Null for manually added lines. |
| FK_ItemId | Guid | FK → Item (product) |
| QuantityDispatched | decimal | Quantity to load from `FK_WarehouseId` |
| **`IsActive`** | bool | **Versioning.** Every edit soft-deletes current items (`IsActive = false`) and writes a fresh set. Active items = latest version; soft-deleted = history. |
| CreatedAt | DateTime | From Template |
### Updated Enum — `LoadingSheetStatus`
```csharp
public enum LoadingSheetStatus
{
Planned, // new: created from a confirmed PO, no ride yet
Assigned, // new: ride + source warehouse attached, ready to dispatch
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
Planned, // populated by dispatcher — ride set, items editable and versioned
Confirmed // items locked, stock movements created
}
```
`Pending` from `04_loading_event.md` is replaced by two states (`Planned`, `Assigned`) to make the
new ride-less creation step explicit. `DispatchConfirmed`, `ReceiptConfirmed`,
`DiscrepancyFlagged`, and `Resolved` keep their existing meaning and stock-movement behavior
unchanged.
`Pending`, `Assigned`, `DispatchConfirmed`, `ReceiptConfirmed`, `DiscrepancyFlagged`, and
`Resolved` from `04_loading_event.md` are all removed. Stock moves at `Confirmed` — no driver
receipt confirmation step in this flow.
---
## State Machine
```text
Planned -> Assigned -> DispatchConfirmed -> ReceiptConfirmed
\-> DiscrepancyFlagged -> Resolved
Planned → Confirmed (stock moves)
```
| Transition | Trigger | What it does |
|---|---|---|
| (implicit) -> Planned | PO confirmed (`POST /api/orders/{id}/confirm`, per [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)) | `LoadingSheet` is created automatically with `FK_OrderId` set, items populated from the PO's `OrderItem` lines. No ride, no warehouse yet. |
| Planned -> Assigned | `POST /{id}/assign-ride` | Dispatcher attaches `FK_RideId` (must be an `Open` ride) and `FK_SourceWarehouseId`. Sets `AssignedAt`/`AssignedBy`. This is the new step that replaces `04_loading_event.md`'s ride-scoped sheet creation. |
| Assigned -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Unchanged from `04_loading_event.md` — warehouse manager locks in `QuantityDispatched` per line. **Ride status is untouched** (no more `Open` requirement check beyond what assign-ride already validated). |
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Unchanged — driver's received quantities all match dispatched. Creates stock movements. **No longer triggers any ride transition** (there is no `Active` to trigger). |
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Unchanged — any line mismatch. No stock movements created. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Unchanged mechanics — supervisor enters `QuantityResolved` per line, creates stock movements using resolved quantities. **No longer triggers any ride transition.** |
| (create) → Planned | `POST /api/loading-sheets/populate` | Dispatcher provides `rideId` + filters. System finds all matching Confirmed POs, creates one `LoadingSheet` with `FK_RideId` set, populates `LoadingSheetItem` rows from active `OrderItem` lines, records PO links in `LoadingSheetOrder`. `PopulatedBy`/`PopulatedAt` set. Items editable (versioned via `IsActive`). Dispatcher sets `FK_WarehouseId` per item. |
| Planned → Confirmed | `POST /{id}/confirm` | Locks the sheet — `LoadingSheetItem` rows frozen. Stock movements created: one debit per `LoadingSheetItem.FK_WarehouseId`, one credit to the ride's vehicle. Sets `ConfirmedAt`/`ConfirmedBy`. |
A `Planned` sheet stays as `Planned` indefinitely until the dispatcher confirms it — there is no
timeout or auto-cancellation.
---
## Item Population
Replaces `04_loading_event.md`'s "Item Population by Ride Type" section — there is no more
Delivery/Sale split:
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` providing:
Every `LoadingSheet` is auto-populated on creation (at `Planned`) from the aggregate of its own
PO's (`Order`) `OrderItem` lines — the same mechanism for every ride scenario, since
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md) generates one `LoadingSheet` per
confirmed PO regardless of how that PO originated. `PUT /{id}/items` remains available to override
quantities before dispatch is confirmed (through `Planned` and `Assigned`).
| Input | Notes |
|---|---|
| `rideId` | **Required.** The ride this loading sheet is for. Must be an `Open` ride. |
| `routeId` | Filter — all Confirmed POs for shops on this route |
| `agentId` | Filter — all Confirmed POs created by this agent |
| `shopId` | Filter — all Confirmed POs for this specific shop |
At least one filter must be provided alongside `rideId`. The system finds all matching `Order`
records where items are locked (Confirmed PO), aggregates their active `OrderItem` lines into one
`LoadingSheet`, and records each PO in `LoadingSheetOrder`.
`LoadingSheetItem.FK_OrderItemId` traces each line back to its source `OrderItem` for audit.
After population, the dispatcher sets `FK_WarehouseId` on each item — specifying which warehouse
that item is loaded from. Different items can come from different warehouses. Items are editable
while `Planned` — every edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`)
and writes a fresh set. Latest version = `IsActive = true` rows.
---
## Stock Movements
**Unchanged** from `04_loading_event.md`. Still created exactly once, at `ReceiptConfirmed` or
`Resolved`, still one pair per line item (debit `FK_SourceWarehouseId`, credit the assigned ride's
vehicle), still using `QuantityFinal` = `QuantityReceived` or `QuantityResolved`, still
`Type = Load`.
Created at `Confirmed`. One pair per active `LoadingSheetItem`:
- **Debit**: `LoadingSheetItem.FK_WarehouseId` (the warehouse that item loads from)
- **Credit**: vehicle on the assigned ride (`LoadingSheet.FK_RideId → DeliveryRun.FK_VehicleId`)
- **Type**: `Load`
Since different items can have different `FK_WarehouseId` values, one loading sheet can produce
stock movements from multiple warehouses into one vehicle in a single confirmation action.
---
@@ -129,63 +150,34 @@ Base route: `/api/loading-sheets`
| Method | Route | Who | Description |
|---|---|---|---|
| ~~POST `/`~~ | — | — | **Removed.** Sheets are no longer manually created — they're generated automatically when a PO is confirmed. |
| GET | `/` | Any | List sheets. Filter by `orderId`, `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail with all line items. |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities — allowed through `Planned` and `Assigned`. |
| **POST `/{id}/assign-ride`** | **Dispatcher** | **New.** `{ rideId, sourceWarehouseId }`. Requires the ride to be `Open`. `Planned -> Assigned`. |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | `Assigned -> DispatchConfirmed`. |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line. Unchanged. |
| POST | `/{id}/confirm-receipt` | Driver | `-> ReceiptConfirmed` or `DiscrepancyFlagged`. No ride-status side effect. |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line `-> Resolved`. No ride-status side effect. |
| POST | `/populate` | Dispatcher | `{ rideId, routeId?, agentId?, shopId? }` — `rideId` required, at least one filter required. Creates one `LoadingSheet` at `Planned` with `FK_RideId` set. |
| GET | `/` | Any | List sheets. Filter by `routeId`, `agentId`, `shopId`, `rideId`, `status`. Paginated. `routeId` resolves via `Store.FK_RouteId → Order.FK_StoreId → LoadingSheetOrder.FK_OrderId` server-side. |
| GET | `/{id}` | Any | Get sheet detail. Active items only by default; `?history=true` includes soft-deleted versions. |
| PUT | `/{id}/items` | Dispatcher | Rewrite items while `Planned`. Each item must include `FK_WarehouseId`. Soft-deletes current rows (`IsActive = false`) and writes a fresh set. |
| POST | `/{id}/confirm` | TBD | Lock the sheet. `Planned → Confirmed`. Items frozen. Stock movements created. |
---
## Business Rules
1. A `LoadingSheet` is created automatically, exactly once per PO, when that PO is confirmed — there
is no manual "create sheet" action and no `Open`-ride precondition at creation time (replaces
`04_loading_event.md` rule 1).
2. **A ride may now have more than one `LoadingSheet` assigned to it** — one per PO whose sheet was
assigned there. `04_loading_event.md`'s "a ride can only have one active loading sheet at a time"
no longer holds, since a single ride/trip can carry stock for several stores' POs at once. Each
assigned sheet still progresses through dispatch/receipt/discrepancy independently.
3. `assign-ride` requires the target ride to be `Open`; a `Cancelled` or `Closed` ride cannot receive
a new assignment.
4. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial receipt
confirmation (unchanged from `04_loading_event.md`, minus the "activation" language since there's
no `Active` status anymore).
5. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch or
on assignment. Unchanged.
6. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. Unchanged.
7. All list endpoints return paginated responses. Unchanged.
8. Vehicle capacity check is still not enforced in this step. Unchanged.
9. Loading confirmation and receipt no longer trigger any `Ride.Status` change — replaces
`04_loading_event.md` rules that referenced `Open -> Active`.
`04_loading_event.md`'s rule 8 ("Immediate handover from a Sale ride still creates an order") is
dropped along with `RideType` — every PO already creates its own `Invoice`/`LoadingSheet` uniformly,
regardless of how quickly it moves through Pending → Draft → Finalized.
---
## Impact on Dependent Features
1. **`ride_lifecycle.md`'s Impact item 2** (loading sheet auto-population without a route) and
**`order_invoice_lifecycle.md`'s Impact item 6** are addressed by this document: population now
comes from the PO directly, and the ride-attachment gap (`order_invoice_lifecycle.md`'s
[Open Question 7](../refactor_docs/order_invoice_lifecycle.md#open-questions)) is resolved by the
new `Planned -> Assigned` step and `POST /{id}/assign-ride` endpoint.
2. **Dispatcher role/permission is new** — `04_loading_event.md`'s permission table has no
"dispatcher" concept; `assign-ride` needs a new permission (see below), and who is authorized to
perform it (a route/trip planner role?) is not fully specified here.
3. **Multiple sheets per ride** changes how a driver/warehouse experiences dispatch — instead of one
confirm-dispatch per ride, there may now be several, one per assigned PO. UI/UX and any
"ride loading summary" view built against the old one-sheet-per-ride assumption needs rework —
not designed here.
4. **`order_invoice_lifecycle.md`'s Open Question 8** (does editing PO items post-confirm re-sync the
`LoadingSheet`?) is still open here too — this document doesn't resolve what happens to an
`Assigned` or later-stage sheet if its source PO's items change afterward.
1. A `LoadingSheet` is created manually by a dispatcher — not automatically. `rideId` and at least
one PO filter (routeId, agentId, or shopId) are required.
2. The ride (`FK_RideId`) is set at populate time — there is no separate assign-ride step.
3. Only Confirmed POs (locked items) are included in population — Shop POs are excluded.
4. One `LoadingSheet` aggregates all matching Confirmed POs — not one sheet per PO.
5. A ride may have more than one `LoadingSheet` — each progresses independently.
6. The target ride must be `Open` at populate time.
7. Each `LoadingSheetItem` carries its own `FK_WarehouseId` — items on the same sheet can come
from different warehouses. There is no sheet-level source warehouse.
8. Items are editable (versioned via `IsActive`) while `Planned` — frozen at `Confirmed`.
9. Every item edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`) and writes
a fresh set. Active rows = latest version; soft-deleted = history.
10. Stock movements are created at `Confirmed` — one debit per item's warehouse, one credit to the
vehicle — all in one transaction.
11. A `Planned` sheet stays `Planned` until confirmed — no timeout or auto-cancellation.
12. No `Ride.Status` transition is triggered at any point in this flow.
13. All list endpoints return paginated responses.
14. Vehicle capacity check is not enforced in this step.
---
@@ -194,57 +186,49 @@ regardless of how quickly it moves through Pending → Draft → Finalized.
| Code | Who |
|---|---|
| `loading.view` | All roles |
| **`loading.assign`** | **New.** Dispatcher and above |
| `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above |
| **`loading.populate`** | **New.** Dispatcher — trigger population, edit items |
| **`loading.confirm`** | **New.** TBD — who can confirm the sheet (see Open Questions) |
---
## Fraud / Control Considerations
Unchanged from `04_loading_event.md`:
- Discrepancies are immediately visible and block further progress.
- `QuantityResolved` is permanently stored per line for audit purposes.
Additionally: `AssignedAt`/`AssignedBy` gives an audit trail for who committed a planned PO load to
a specific ride/vehicle, which didn't exist as a separate step before.
- `PopulatedBy`/`PopulatedAt` gives an audit trail for who triggered population and when.
- `ConfirmedBy`/`ConfirmedAt` records who committed stock movements.
- `FK_OrderItemId` on each item traces the exact source PO line for full audit.
- `IsActive = false` rows preserve the full edit history of the sheet before confirmation.
---
## Open Questions
1. **Who is authorized to assign a ride to a Planned sheet?** — a new "dispatcher" role/permission is
introduced (`loading.assign`) but not otherwise defined in this pass.
2. **What happens to a `Planned` sheet if its PO is never assigned to a ride?** — no timeout, retry,
or cancellation path is designed here.
3. **Does editing a PO's items after its sheet is `Planned`/`Assigned` re-sync the sheet?** — carried
over from `order_invoice_lifecycle.md`'s Open Question 8, still unresolved here.
4. **Should multiple `Planned` sheets ever merge into one `Assigned` sheet** (e.g. one dispatch
covering several stores' POs on the same ride), rather than staying as separate per-PO sheets that
each go through their own dispatch/receipt cycle? Not designed here — this document keeps them as
independent per-PO sheets (see Business Rule 2).
5. Vehicle capacity check — still deferred, unchanged from `04_loading_event.md`.
1. **Who confirms the loading sheet?** — dispatcher, warehouse manager, or either? TBD. Controls
the `loading.confirm` permission assignment.
2. **What happens to a `Planned` sheet if its source PO is cancelled?** — if a Confirmed PO is
cancelled after a loading sheet has been populated from it, the sheet's items sourced from that
PO become invalid. No cascade behaviour designed here.
3. Vehicle capacity check — still deferred.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Add `FK_OrderId` (required), `AssignedAt`, `AssignedBy` to `Models/LoadingSheet.cs`; make
`FK_RideId` and `FK_SourceWarehouseId` nullable.
- Replace `LoadingSheetStatus.Pending` with `Planned` and `Assigned`; update all status checks in
`LoadingSheetService`/`LoadingSheetRepository` accordingly.
- Remove `POST /api/loading-sheets` (manual create); wire automatic `LoadingSheet` creation into the
PO-confirm action from [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)'s migration
notes.
- Add `POST /{id}/assign-ride` endpoint/service method, validating the target ride's `Status == Open`
(per [ride_lifecycle.md](./ride_lifecycle.md)'s narrowed `RideStatus`).
- Remove the `confirm-receipt`/`resolve` code paths' calls into ride-activation logic — there is no
`Ride.Status` transition to trigger anymore.
- Remove the ride-route-based item population path entirely (`Order.Store.FK_RouteId` aggregation);
replace with direct `FK_OrderId`-based population at sheet-creation time.
- Seed the new `loading.assign` permission.
- Resolve Open Questions 1 and 4 before finalizing the `assign-ride` endpoint's authorization rules
and the one-sheet-per-PO assumption, respectively.
- Remove `FK_OrderId`, `FK_SourceWarehouseId`, `AssignedAt`, `AssignedBy`,
`DispatchConfirmedAt`, `DispatchConfirmedBy`, `ReceiptConfirmedAt`, `ReceiptConfirmedBy` from
`Models/LoadingSheet.cs`. Make `FK_RideId` required. Add `PopulatedBy`, `PopulatedAt`,
`ConfirmedAt`, `ConfirmedBy`.
- Add `Models/LoadingSheetOrder.cs` (`FK_LoadingSheetId`, `FK_OrderId`) with composite PK.
- Add `FK_WarehouseId` (required), `FK_OrderItemId` (nullable), `IsActive` to
`Models/LoadingSheetItem.cs`. Remove `QuantityReceived`, `QuantityResolved`.
- Replace `LoadingSheetStatus` entirely with `Planned` and `Confirmed`; remove all other values
and update all status checks in `LoadingSheetService`/`LoadingSheetRepository`.
- Remove `POST /api/loading-sheets` (old manual create), `POST /{id}/assign-ride`,
`POST /{id}/confirm-dispatch`, `PUT /{id}/receipt-items`, `POST /{id}/confirm-receipt`,
`POST /{id}/resolve`; add `POST /api/loading-sheets/populate` and `POST /{id}/confirm`.
- Wire stock movements into `POST /{id}/confirm` — one debit per `LoadingSheetItem.FK_WarehouseId`,
one credit to the ride's vehicle, in a single transaction.
- Remove ride-route-based item population (`GetAggregatedOrderItemsByRouteIdAsync`); replace with
filter-based multi-PO aggregation.
- Remove all calls to ride-activation logic — no `Ride.Status` transition exists anymore.
- Seed `loading.populate` and `loading.confirm` permissions.
- Resolve Open Question 1 (who confirms) before assigning `loading.confirm` permission.
@@ -1,39 +1,113 @@
# [Refactor] Order/Invoice Lifecycle — Unified PO → Invoice Pipeline
**Status**: Proposed (design only — no code changes yet)
**Status**: Partially implemented — schema, API, and DB migration live on branch
`feat/order-invoice-lifecycle`. Frontend, `LoadingSheet` wiring, and the cancellation cascade
(Open Question 5) are not done. See [Implementation Status](#implementation-status) below for the
as-built schema, which differs in a few small ways from the original design further down this
document.
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and
[../06_sale_ride.md](../06_sale_ride.md) (both kept as-is, this is the target design)
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md) — that refactor removes
`RideType`/`Route`/`Status` from `Ride`; this document does not attempt to resolve most of its open
questions, and this document's design does not depend on it landing first. One exception: this
document's automatic `LoadingSheet` creation on PO confirm (see below) partially addresses
`ride_lifecycle.md`'s "loading sheet auto-population" gap — the `LoadingSheet` is now generated from
the PO/Invoice directly, not from a Route. How that `LoadingSheet` later attaches to an actual Ride
(vehicle) is resolved in [loading_event.md](./loading_event.md).
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
---
## Implementation Status
Everything below reflects what is actually in the database and codebase right now, not just the
plan. The rest of this document (Data Model Changes onward) is kept as the original design record —
where the two disagree, this section wins.
### `Orders` (existing table, altered)
| Field | Type | Notes |
|---|---|---|
| `FK_SaleId` | `char(36)`, nullable | Changed from required to nullable, as designed. |
| `CreatedByUserId` | `char(36)`, required | **Added — not in the original diagram below.** The
original design had no way to know who created a PO or to answer the `agentId` filter Mode B's own
endpoints require once a PO can exist without a Sale. Backfilled for every pre-existing row from its
linked Sale's `CreatedByUserId`. FK → `Users`. |
| `ConfirmedAt` | `datetime`, nullable | **Added — not in the original diagram below.** The design
never named a field for "is this PO confirmed" independent of an Invoice existing (Mode B confirms
with no Invoice at all), so this was added to actually gate item-editing per Business Rule 1. Null =
Shop PO, set = Confirmed PO. |
| `ConfirmedByUserId` | `char(36)`, nullable | **Added**, alongside `ConfirmedAt`. FK → `Users`. |
| `InvoiceId` | `char(36)`, nullable | As designed — plain column, no DB-level FK constraint. |
### `OrderItems` (existing table, altered)
| Change | Notes |
|---|---|
| Primary key | Changed from the composite `(FK_OrderId, FK_ItemId)` to the single inherited `Id`
column. **Required for versioning to work at all** — the original composite key made it impossible
for two rows to exist for the same PO+item (one soft-deleted, one active), which item-editing needs.
Not called out explicitly in the original design below. |
| `IsActive` | Already existed via the `Template` base class; now actually used to mark old versions
inactive on every edit. |
| New index | `IX_OrderItems_FK_OrderId` (standalone — previously implied by the composite key). |
### `Invoices` (new table)
| Field | Type | Notes |
|---|---|---|
| `Id` | `char(36)` | PK |
| `FK_OrderId` | `char(36)`, required | FK → `Orders` |
| `Status` | `int` (`InvoiceStatus`) | `Draft` / `Printed` / `Finalized` |
| `CreatedByUserId` | `char(36)`, required | FK → `Users`. Named `CreatedByUserId` in code, not
`CreatedBy` as in the diagram below — matches this codebase's existing naming convention
(`Sale.CreatedByUserId`, etc.). |
| `PrintedAt` | `datetime`, nullable | |
| `PrintedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
| `FinalizedAt` | `datetime`, nullable | |
| `FinalizedByUserId` | `char(36)`, nullable | FK → `Users`. Same naming note as above. |
| `TotalAmount` | `decimal` | |
| `IsActive`, `CreatedAt` | from `Template` | |
### API endpoints implemented
`POST /api/orders` (independent PO creation, replacing the old "created from Sale" behaviour),
`PUT /api/orders/{id}/items` (versioned rewrite), `POST /api/orders/{id}/confirm` (Mode B),
`POST /api/orders/{id}/confirm-and-create-invoice` (Mode A), `GET /api/orders/confirmed`
(filtered by `agentId`/`routeId`/`shopId`), `POST /api/orders/{id}/cancel`; `POST /api/invoices`
(Mode B), `POST /api/invoices/{id}/print`, `POST /api/invoices/{id}/finalize` (using the resolved
payload from Open Question 6 below). Both modes exist side by side, gated at runtime by the
`OrderInvoice:InvoiceCreationMode` config flag (`appsettings.json`, defaulted to `"B"`).
### Not implemented yet
- Frontend (no UI changes made).
- `LoadingSheet` population still needs its own wiring per [loading_event.md](./loading_event.md) —
untouched by this pass.
- Cancellation cascade to `Invoice`/`LoadingSheet` (Open Question 5) — still unresolved; cancelling a
Confirmed PO with a linked Invoice today leaves the Invoice untouched.
- `Payment`/`Invoice` wiring from [invoice_collection.md](./invoice_collection.md) — separate doc,
not started.
---
## Goal
Replace the Delivery-vs-Sale ride-type split as the organizing concept for how a store's order gets
turned into money. Today `05_delivery_ride.md` (rep takes an order now, delivers days later) and
`06_sale_ride.md` (rep+driver visit together and sell on the spot) are two separate flows. Underneath,
both are the same shape: a rep collects a **PO** from the shop — whether fulfilled immediately or
days later — and once the PO exists, an **Invoice** is generated from it. That invoice always moves
through the same three statuses regardless of which scenario produced it:
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
two separate but linked concerns — the PO tracks what the shop ordered; the Invoice is the billing
document that goes with the delivery.
1. **Pending** — invoice just created from the PO; the PO's line items are freely editable.
2. **Draft** — a PO-confirm action moves it out of Pending (no customer signature/OTP — see
[State Machine](#state-machine)). Confirming also automatically generates a `LoadingSheet` from
the PO's current line items. The PO's line items **remain editable** in this state too — Draft is
a working document, not a lock.
3. **Finalize** — the reconciliation step: item add/delete and amount deductions (returns, damage,
short-delivery, discounts) get applied here, producing the final, locked invoice amount — this is
what the shop actually received and paid, and is the record used for day-end calculations. This
is the only state where free-form editing stops.
### PO lifecycle (on `Order`)
The two ride scenarios differ only in *timing* through this one pipeline, not in structure — no
ride-type-specific branching is needed in the PO/Invoice model itself.
1. **Shop PO** — PO created by sales agent, line items freely editable (versioned via `IsActive`).
2. **Confirmed PO** — office user confirms the PO, locking its items. Invoice creation depends on
the system's active mode (see [Invoice Creation Modes](#invoice-creation-modes) below).
### Invoice lifecycle (on `Invoice`)
An Invoice is **not** auto-created when a PO is created. It is created by an office user action —
either combined with PO confirmation (Mode A) or as a separate step after confirmation (Mode B).
Once created, the Invoice always moves through these states:
1. **Draft** — Invoice created, ready to be printed.
2. **Printed** — Invoice physically printed and handed to the agent for the field. The agent makes
handwritten edits on the paper copy during the ride.
3. **Finalized** — after the ride or day-end, the agent returns the printed invoice. The office
worker enters any edits the agent made into the system and submits — locking the invoice as the
final billing record.
---
@@ -54,198 +128,212 @@ name as `Order` in code.
Order ("PO")
├── Id (Guid) — PK, from Template
├── FK_StoreId (Guid) — required, unchanged
├── FK_SaleId (Guid?) — now OPTIONAL (was required). See Impact section — a PO can now
│ exist before any Sale/payment event completes.
├── TotalAmount (decimal) — unchanged, though only authoritative once the linked Invoice is
│ Finalized (see below)
├── FK_SaleId (Guid?) — now OPTIONAL (was required). A PO can exist before any Sale.
├── InvoiceId (Guid?) — NOT a FK constraint. Set when the Invoice is created for this PO
│ (Mode A or B). Null until then. No referential enforcement at DB
│ level — configured explicitly in Fluent API.
├── TotalAmount (decimal) — only authoritative once the linked Invoice is Finalized
├── Status (OrderStatus) — unchanged: Pending, PartiallyFulfilled, Fulfilled, Cancelled.
│ This tracks fulfillment progress, a separate axis from the new
│ InvoiceStatus below — not merged in this pass.
│ Tracks fulfillment progress — separate axis from InvoiceStatus.
├── FulfilledAt (DateTime?) — unchanged
├── IsActive, CreatedAt — from Template
```
`OrderItem` (the PO's line items) is unchanged in shape (`FK_ItemId`, `Quantity`,
`QuantityFulfilled`, `UnitPrice`) — editability is now gated by the linked `Invoice`'s status (see
below), not by `Order.Status` directly.
`OrderItem` (`FK_ItemId`, `Quantity`, `QuantityFulfilled`, `UnitPrice`, `IsActive`, `CreatedAt`) —
editability gated by whether the PO is confirmed. Every edit soft-deletes current rows
(`IsActive = false`) and writes a fresh set. Active rows = latest version; soft-deleted = history.
### New Entity — `Invoice`
```
Invoice
├── Id (Guid) — PK, from Template
├── FK_OrderId (Guid) — required. One PO → one Invoice for v1 (see Open Questions).
├── Status (InvoiceStatus) — Pending, Draft, Finalized
├── ConfirmedAt (DateTime?) — set on Pending → Draft, when the PO is confirmed
├── FinalizedAt (DateTime?) — set on Draft → Finalized
├── FinalizedBy (Guid?) — UserId of whoever finalized it
├── TotalAmount (decimal) — mirrors the PO total while Pending/Draft; becomes the
│ authoritative, locked amount once Finalized
├── FK_OrderId (Guid) — required. One PO → one Invoice (1:1).
├── Status (InvoiceStatus) — Draft, Printed, Finalized
├── CreatedBy (Guid) — UserId of office user who created the invoice
├── PrintedAt (DateTime?) — set on Draft → Printed
├── PrintedBy (Guid?) — UserId of whoever triggered the print
├── FinalizedAt (DateTime?) — set on Printed → Finalized
├── FinalizedBy (Guid?) — UserId of office worker who entered agent edits and submitted
├── TotalAmount (decimal) — reflects current line items through Draft/Printed; locked at Finalized
├── IsActive, CreatedAt — from Template
```
There is no signature/OTP field on `Invoice` — confirming a PO is a plain action, not a captured
customer signature (see [State Machine](#state-machine); this also resolves
[Open Question 1](#open-questions) from the prior pass of this document).
### `LoadingSheet` — now generated from the PO, not the Route
`LoadingSheet` already exists in the codebase (`Features/LoadingEvent/`), previously populated by
aggregating orders on a ride's route (`LoadingSheetRepository.GetAggregatedOrderItemsByRouteIdAsync`
— see `ride_lifecycle.md`'s Impact item 2). Under this design, a `LoadingSheet` is instead created
automatically the moment an `Order`/`Invoice` moves Pending → Draft (PO confirmed), populated from
that PO's `OrderItem` lines directly:
```
LoadingSheet
├── FK_OrderId (Guid) — required. Links the sheet to the PO/Invoice it was generated from.
```
It is **not** linked to a Ride or vehicle at creation time — it exists purely as "what needs to be
loaded for this PO." How/when it later gets attached to an actual Ride for physical stock loading is
left open — see [Open Questions](#open-questions).
### New Enum — `InvoiceStatus`
```csharp
public enum InvoiceStatus { Pending, Draft, Finalized }
public enum InvoiceStatus { Draft, Printed, Finalized }
```
An `Invoice` is created automatically the moment a PO (`Order`) is created, starting at `Pending` —
there is no separate "create invoice" action.
### Invoice Creation Modes
In a given system instance, **exactly one** of the following modes is active. Controlled by a
system-level configuration flag (`InvoiceCreationMode: "A" | "B"`). Both modes cannot be active
at the same time.
**Mode A — Confirm PO + Create Invoice (combined action)**
- Office user presses "Confirm PO & Create Invoice"
- Single endpoint: `POST /api/orders/{id}/confirm-and-create-invoice`
- Confirms the PO (locks `OrderItem` lines) and creates the Invoice at `Draft` in one atomic action.
- `Order.InvoiceId` set to the new Invoice's Id in the same transaction.
**Mode B — Create Invoice from Confirmed POs (separate action)**
- PO confirmation is a separate step: `POST /api/orders/{id}/confirm` — locks items only.
- `Order.InvoiceId` remains null until invoice is created.
- Office user browses Confirmed POs filtered by `agentId`, `routeId`, or `shopId`, then creates
the invoice: `POST /api/invoices { orderId }`.
- `Order.InvoiceId` set to the new Invoice's Id at creation.
### `LoadingSheet` — populated from Confirmed POs, separate flow
Loading sheets are populated from Confirmed POs as a separate dispatcher-triggered flow — not
auto-triggered at PO confirm or invoice creation time. One loading sheet aggregates multiple
Confirmed POs based on route/agent/shop filters. See [loading_event.md](./loading_event.md) and
[dispatch_pipeline.md](./dispatch_pipeline.md) for the full loading sheet lifecycle.
---
## State Machine
```
Pending → Draft → Finalized
```
### PO (`Order`) transitions
No branch/cancel path is designed in this pass (unlike the Ride state machine) — cancellation of a
PO/Invoice is not addressed here; flagged as an open question below.
```
(create) → Shop PO → Confirmed PO
```
| Transition | Trigger | What it does |
|---|---|---|
| (implicit) → Pending | PO (`Order`) created | Invoice is created automatically, `Status = Pending`. `OrderItem` lines fully editable. |
| Pending → Draft | `POST /api/orders/{id}/confirm` | Confirms the PO — a plain action, not a customer signature/OTP. Lives on the `Order` (PO) resource, not the `Invoice`; does **not** add or change any `Order.Status` value — PO confirmation and fulfillment tracking stay separate axes. Sets `Invoice.ConfirmedAt`, `Invoice.Status = Draft`, and automatically creates a `LoadingSheet` from the PO's current `OrderItem` lines. **`OrderItem` lines remain editable** after confirm — Draft is not a lock; whether/how a later edit re-syncs the already-generated `LoadingSheet` is an open question (see below). |
| Draft → Finalized | `POST /api/invoices/{id}/finalize` | Accepts an item-adjustment/deduction payload (add/delete lines, amount deductions for returns/damage/short-delivery/discounts), recomputes and locks `TotalAmount`. This is a distinct, audited action — not the same add/edit/remove path used in Pending/Draft. Once Finalized, the Invoice and its PO's items are fully locked. |
| (create) → Shop PO | `POST /api/orders` | PO created. `OrderItem` lines editable — every edit soft-deletes current rows (`IsActive = false`) and writes a fresh set. Active rows = latest version. No Invoice yet. |
| Shop PO → Confirmed PO | **Mode A:** `POST /api/orders/{id}/confirm-and-create-invoice` | Confirms PO and creates Invoice at `Draft` atomically. `OrderItem` lines locked. `Order.InvoiceId` set. |
| Shop PO → Confirmed PO | **Mode B:** `POST /api/orders/{id}/confirm` | Confirms PO only. `OrderItem` lines locked. `Order.InvoiceId` remains null until invoice created separately. |
### Invoice transitions
```
Draft → Printed → Finalized
```
| Transition | Trigger | What it does |
|---|---|---|
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
---
## Business Rules
1. `OrderItem` add/edit/remove is allowed while the linked `Invoice.Status` is `Pending` **or**
`Draft` — both are working-document states.
2. An `Invoice` cannot be `Finalized` without first passing through `Draft` — `Pending → Finalized`
directly is not a valid transition.
3. `Order.TotalAmount`/`Invoice.TotalAmount` is only authoritative once `Finalized`; while
Pending/Draft it reflects the current (still-changeable) line items.
4. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is
untouched and operates on a separate axis from `Invoice.Status` (billing/document lifecycle) —
whether these two should eventually merge is an open question, not resolved here.
5. `Order.FK_SaleId` is now optional — a PO/Invoice can exist and progress through Pending/Draft
before any `Sale` is recorded, covering the take-order-now/deliver-later scenario. See Impact
section for what this touches.
6. Both ride scenarios (take-order-now-deliver-later vs. on-the-spot sale) create a PO the same way
and pass through the same three Invoice statuses — the only difference is how quickly they move
through Pending → Draft → Finalized. No `RideType`-specific branching applies to the PO/Invoice
model.
7. Confirming a PO (Pending → Draft) requires no customer signature or OTP — it is a plain action
available to whoever is authorized to confirm a PO (e.g. the rep/dispatcher), not a
customer-facing sign-off step.
8. A `LoadingSheet` is generated exactly once per PO, automatically, at PO-confirm time — there is no
separate manual "create loading sheet" action.
1. `OrderItem` editing is allowed **only** while the PO is a Shop PO (before confirmation). Every
edit soft-deletes all current `OrderItem` rows (`IsActive = false`) and creates a fresh set —
active rows are the latest version, soft-deleted rows are history. Once confirmed, items are
locked permanently.
2. Invoice is **not** auto-created at PO creation — it is always a deliberate office user action.
3. Exactly one Invoice Creation Mode (A or B) is active per system instance — controlled by
`InvoiceCreationMode` config flag. Both modes cannot be active simultaneously.
4. Mode A only: confirming a PO and creating its Invoice are atomic — one cannot happen without
the other.
5. Mode B only: a PO can be confirmed without an Invoice existing; the Invoice is created later as
a separate action.
6. `Order.InvoiceId` is set (non-null) once the Invoice is created — in the same transaction as
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
reflects the current line items.
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
separate axis from `Invoice.Status` — not merged in this pass.
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
11. Loading sheet population is a separate dispatcher-triggered flow — not triggered by PO
confirmation or invoice creation. See [dispatch_pipeline.md](./dispatch_pipeline.md).
12. **A Shop PO (pre-confirmation) can be cancelled** — `Order.Status → Cancelled`. No Invoice
exists at this stage so no cascade required.
13. **A Confirmed PO can also be cancelled** even after confirmation — `Order.Status → Cancelled`.
If an Invoice was already created for this PO, cascade behaviour (void/cancel the Invoice) and
behaviour for any loading sheet that included this PO are open questions — see Open Questions.
---
## Impact on Dependent Features
Reshaping `Order` and introducing `Invoice` touches several existing behaviors. Each is flagged here
as **known-affected, deferred follow-up work**, not solved in this pass:
1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, and it always
sets a non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path (not
necessarily originating from a `Sale`) needs to exist for the take-order-now/deliver-later
scenario. Any other code that assumes `Order.Sale` is always non-null also needs auditing —
not enumerated exhaustively here.
2. **`Payment` attachment** — per `06_sale_ride.md`, `Payment` currently attaches to `Sale`. Once
`Invoice` exists as the real billing document, `Payment` more naturally attaches to the `Invoice`
instead. Flagged as a design question for [19_finance_accounting.md](../19_finance_accounting.md)
to reconcile — not decided here.
3. **`19_finance_accounting.md`'s proposed Invoice status** (`Issued, PartiallyPaid, Paid`) needs
reconciling with this document's `Pending/Draft/Finalized`. These are likely two different axes
on the same `Invoice` — document lifecycle status (this doc) vs. payment-collection status (the
finance doc) — but that reconciliation is not attempted here.
4. **`DeliveryTask.CustomerConfirmed`** (boolean, from `05_delivery_ride.md`) overlaps conceptually
with the new Pending → Draft transition (both represent "the customer confirmed this"). Whether
they merge into one confirmation event, or stay deliberately separate (delivery confirmation vs.
invoice sign-off, which could happen at different times), is not decided here.
5. **`05_delivery_ride.md` and `06_sale_ride.md`** both go stale the moment this design ships and
need their own rewrite/retirement — tracked as follow-up, not attempted in this pass.
6. **`LoadingSheet` generation moves from route-scoped to PO-scoped.** `LoadingSheetRepository`'s
existing `GetAggregatedOrderItemsByRouteIdAsync` (aggregating by `Order.Store.FK_RouteId`) is
superseded by generating one `LoadingSheet` per PO at confirm time. This directly answers part of
`ride_lifecycle.md`'s Impact item 2 (loading sheet auto-population without a route) — but raises a
new gap: nothing in this design says how/when a PO-scoped `LoadingSheet` gets matched to an actual
Ride/vehicle for physical loading, since `ride_lifecycle.md` also removed `FK_SourceWarehouseId`
and any route/store linkage from `Ride`. Flagged as [Open Question 7](#open-questions), not solved
here.
None of the above are implemented or redesigned by this document. They're called out so the gap is
visible and intentional rather than discovered later as a silent regression.
1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, always with a
non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path needs to exist.
All code that assumes `Order.Sale` is non-null needs auditing.
2. **`Payment` attachment** — `Payment` currently attaches to `Sale`. Once `Invoice` exists as the
real billing document, `Payment` more naturally attaches to `Invoice`. Deferred to
[19_finance_accounting.md](../19_finance_accounting.md).
3. **`05_delivery_ride.md` and `06_sale_ride.md`** go stale when this ships — need rewrite/retirement.
4. **`LoadingSheet` population** moves from route-scoped to dispatcher-triggered, multi-PO
aggregation. See [loading_event.md](./loading_event.md).
---
## Open Questions
1. ~~**Signature/OTP mechanism for the Pending → Draft transition**~~ — **Resolved:** dropped
entirely. Pending → Draft is driven by a plain PO-confirm action (`POST /api/orders/{id}/confirm`),
not a customer signature or OTP. See [State Machine](#state-machine).
2. **One Invoice per PO, or multiple?** — this document assumes a 1:1 `Order`→`Invoice` relationship
for v1. Whether a PO could ever need multiple invoices (e.g. split billing) is not designed here.
3. **Should `Order.Status` and `Invoice.Status` eventually merge into one status axis?** — kept
deliberately separate in this pass (fulfillment progress vs. document/billing lifecycle); not
decided whether that separation should be permanent.
4. **How does `Payment` attach once `Invoice` exists?** — see Impact item 2. Not designed here.
5. **Is there a cancellation/void path for a PO or Invoice?** — the state machine in this document
has no cancel branch (unlike the Ride state machine's `CancelRequested`/`Cancelled`). Not
addressed here.
6. **Does `DeliveryTask.CustomerConfirmed` merge with the Draft transition?** — see Impact item 4.
7. ~~**How does a PO-scoped `LoadingSheet` get matched to an actual Ride/vehicle?**~~ — **Resolved
in [loading_event.md](./loading_event.md):** the sheet starts `Planned` (ride-less), and a new
`POST /{id}/assign-ride` action attaches a `Ride` + source warehouse, moving it to `Assigned`.
8. **Does editing a PO's items after confirm (while still Draft) re-sync the already-generated
`LoadingSheet`?** — this document allows PO edits through Draft, but doesn't decide whether the
`LoadingSheet` regenerates, gets manually re-synced, or is left to drift until Finalize reconciles
it. Not designed here.
1. ~~**Signature/OTP on PO confirmation**~~ — **Resolved:** dropped. Plain action, no OTP.
2. ~~**One Invoice per PO or multiple?**~~ — **Resolved:** 1:1 for v1.
3. **Should `Order.Status` and `Invoice.Status` eventually merge?** — kept separate; not decided.
4. **How does `Payment` attach to `Invoice`?** — see Impact item 2. Not designed here.
5. **Cancellation cascade — Invoice and LoadingSheet** — a Shop PO can be cancelled (no Invoice
exists yet, no cascade). A Confirmed PO can be cancelled even after confirmation. If the PO
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
also unresolved. Both cases need explicit cascade rules before implementation.
6. ~~**Exact adjustment payload for `POST /finalize`**~~ — **Resolved:**
```
POST /api/invoices/{id}/finalize
{
"items": [
{ "orderItemId": Guid, "deliveredQuantity": decimal, "freeQuantity": decimal }
],
"returns": [
{ "itemId": Guid, "quantity": decimal, "reasonCode": ReturnReasonCode }
],
"discount": { "type": "Amount" | "Percentage", "value": decimal, "reason": string? } | null
}
```
- `items[].deliveredQuantity` — what the shop actually received per line, replacing the ordered
`OrderItem.Quantity` for billing purposes. Captures shortages/quantity edits the agent
handwrote on the printed copy.
- `items[].freeQuantity` — **the free-issue field this document previously flagged as missing.**
Units given to the shop at no charge on top of `deliveredQuantity`. Contributes `0` to
`Invoice.TotalAmount` but is still recorded per line for stock/audit — it moves stock the same
as a sold unit, it just isn't billed.
- `returns` — **reuses the existing `SaleReturn`/`SaleReturnItem`/`CreditNote` shape from
[18_returns_claims.md](../18_returns_claims.md) instead of inventing a parallel mechanism.**
Finalize creates a `SaleReturn` (`Mode = CreditDeduction`) keyed to the PO's `Order` with one
`SaleReturnItem` per entry, which produces a `CreditNote` the normal way. "Damage" is not a
separate field — it's `ReasonCode = Damaged` on a return line, since `ReturnReasonCode` already
covers it (`Expired, Damaged, OrderError, Other`).
- `discount` — a single amount-or-percentage adjustment applied to the invoice subtotal, with an
optional free-text reason for audit. Not per-line; one discount per invoice for v1.
- `Invoice.TotalAmount` at Finalize = `Σ(items[].deliveredQuantity × OrderItem.UnitPrice)`
− discount − `Σ(CreditNote.Amount from returns)`. `freeQuantity` never enters this sum.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Make `Order.FK_SaleId` nullable in `Models/Order.cs` and its EF/Fluent configuration; audit all
code paths that currently assume it's non-null.
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `ConfirmedAt`, `FinalizedAt`, `FinalizedBy`,
`TotalAmount`, plus `Template` base fields) and the `InvoiceStatus` enum.
- Make `Order.FK_SaleId` nullable in `Models/Order.cs`; audit all code paths that assume non-null.
- Add `Order.InvoiceId` (Guid?, no FK constraint) to `Models/Order.cs` — configure in Fluent API
to ensure no referential constraint is generated.
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `CreatedBy`, `PrintedAt`, `PrintedBy`,
`FinalizedAt`, `FinalizedBy`, `TotalAmount`, plus `Template` base fields) and the `InvoiceStatus`
enum (`Draft`, `Printed`, `Finalized`).
- New `Features/Invoice/` folder (`InvoiceController`, `InvoiceService`, `IInvoiceRepository`,
`InvoiceRepository`, `InvoiceDTOs`) — do not place this under `Features/PurchaseOrder/` or reuse
any of its DTOs/names, to avoid the supplier-PO naming collision flagged above.
- Add `POST /api/orders/{id}/confirm` on the `Order`/PO feature (not on `Invoice`) — it sets
`Invoice.ConfirmedAt`/`Status = Draft` and creates the `LoadingSheet` in one action; no
`POST /api/invoices/{id}/draft` endpoint and no signature/OTP capture are needed.
- `POST /api/invoices/{id}/finalize` endpoint; the finalize endpoint's adjustment/deduction payload
should reuse the `CreditNote` shape from [18_returns_claims.md](../18_returns_claims.md) where
applicable rather than inventing a parallel mechanism.
- Add `FK_OrderId` to `Models/LoadingSheet.cs` (or equivalent) and change
`LoadingSheetService`/`LoadingSheetRepository` to generate a sheet from a PO's `OrderItem` lines
directly, replacing `GetAggregatedOrderItemsByRouteIdAsync`'s route-based aggregation — wire this
call into the new PO-confirm action.
- A new PO-creation path independent of `SaleService.CreateAsync`, to support creating an
`Order`/`Invoice` before any `Sale` exists (Impact item 1) — needs its own design, not specified
here.
- Resolve Open Question 2 (one Invoice per PO) before finalizing the `FK_OrderId` uniqueness
constraint on `Invoice`; resolve Open Questions 7–8 (LoadingSheet-to-Ride matching, and re-sync on
post-confirm PO edits) before wiring the PO-confirm action into `LoadingSheetService`.
`InvoiceRepository`, `InvoiceDTOs`) — do not place under `Features/PurchaseOrder/`.
- Add system configuration flag `InvoiceCreationMode` (`"A"` or `"B"`) — gate Mode A and Mode B
endpoints accordingly so only the active mode's endpoints are reachable.
- **Mode A endpoint:** `POST /api/orders/{id}/confirm-and-create-invoice` — atomic: locks
`OrderItem` rows, creates `Invoice` at `Draft`, sets `Order.InvoiceId` in one transaction.
- **Mode B endpoints:** `POST /api/orders/{id}/confirm` (locks items only) + `POST /api/invoices`
`{ orderId }` (creates Invoice at `Draft`, sets `Order.InvoiceId`). Add `GET /api/orders` filter
params `agentId`, `routeId`, `shopId` to support the office user's PO selection UI.
- `POST /api/invoices/{id}/print` — sets `PrintedAt`/`PrintedBy`, transitions to `Printed`.
- `POST /api/invoices/{id}/finalize` — accepts adjustment payload; locks `TotalAmount`; sets
`FinalizedAt`/`FinalizedBy`. Resolve Open Question 6 before specifying the exact payload shape.
- `LoadingSheet` population is a separate flow — see [dispatch_pipeline.md](./dispatch_pipeline.md).
- A new PO-creation path independent of `SaleService.CreateAsync` is needed — not specified here.
- Add `POST /api/orders/{id}/cancel` — cancels a Shop PO or Confirmed PO. Resolve Open Question 5
(cascade to Invoice and LoadingSheet) before implementing for the Confirmed PO case.