Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 919c76746d | |||
| 76c08855e4 | |||
| 1ed78b9932 | |||
| 62de2ddce1 | |||
| 8bc895cbc5 | |||
| 792d7c0140 | |||
| 54931ef110 | |||
| 8575034714 | |||
| 710fc15253 | |||
| db24c66d77 | |||
| 0cd4508d50 | |||
| c8f86163f7 | |||
| 02c673328e | |||
| c137aae7a5 | |||
| e3d4837379 | |||
| de5dcf99b3 | |||
| 2b68e2cc04 | |||
| faaa1fe6a5 | |||
| f42809a4e3 | |||
| 4b56443d1a | |||
| 72f850ea4d | |||
| 9f04242dc7 | |||
| d06d434143 | |||
| 248129900d | |||
| ea8f952f14 | |||
| 4d63b3e720 | |||
| 2f9661fafc | |||
| 7d740efabb | |||
| 222279b478 | |||
| 70fd4a9150 | |||
| 15b3e43fe8 | |||
| 8fd31a9b20 | |||
| 6c40421b2d | |||
| 541f59b15d | |||
| b68e5043c7 | |||
| 436f7c10a0 | |||
| 8739e8e1a7 | |||
| 308be2f5ce | |||
| ca2507a904 | |||
| 307055f8e1 |
@@ -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
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4175
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");
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user