Compare commits

...

32 Commits

Author SHA1 Message Date
DeepnaPooja 919c76746d Add local Claude settings 2026-09-16 16:26:29 +05:30
DeepnaPooja 76c08855e4 Add invoice model 2026-09-16 16:26:05 +05:30
DeepnaPooja 1ed78b9932 Add invoice lifecycle migration 2026-09-16 16:26:04 +05:30
DeepnaPooja 62de2ddce1 Add invoice lifecycle migration designer 2026-09-16 16:26:03 +05:30
DeepnaPooja 8bc895cbc5 Add invoice service 2026-09-16 16:26:02 +05:30
DeepnaPooja 792d7c0140 Add invoice repository 2026-09-16 16:26:00 +05:30
DeepnaPooja 54931ef110 Add invoice DTOs 2026-09-16 16:25:59 +05:30
DeepnaPooja 8575034714 Add invoice controller 2026-09-16 16:25:59 +05:30
DeepnaPooja 710fc15253 Add invoice repository contract 2026-09-16 16:25:58 +05:30
DeepnaPooja db24c66d77 Document order invoice lifecycle 2026-09-16 16:25:56 +05:30
DeepnaPooja 0cd4508d50 Update invoice collection docs 2026-09-16 16:25:55 +05:30
DeepnaPooja c8f86163f7 Update dispatch pipeline docs 2026-09-16 16:25:54 +05:30
DeepnaPooja 02c673328e Add order invoice settings 2026-09-16 16:25:53 +05:30
DeepnaPooja c137aae7a5 Register invoice services 2026-09-16 16:25:53 +05:30
DeepnaPooja e3d4837379 Add order invoice fields 2026-09-16 16:25:52 +05:30
DeepnaPooja de5dcf99b3 Add invoice enums 2026-09-16 16:25:51 +05:30
DeepnaPooja 2b68e2cc04 Update invoice model snapshot 2026-09-16 16:25:51 +05:30
DeepnaPooja faaa1fe6a5 Link sale invoices 2026-09-16 16:25:50 +05:30
DeepnaPooja f42809a4e3 Update return invoice handling 2026-09-16 16:25:50 +05:30
DeepnaPooja 4b56443d1a Add order invoice lifecycle 2026-09-16 16:25:50 +05:30
DeepnaPooja 72f850ea4d Update order invoice queries 2026-09-16 16:25:49 +05:30
DeepnaPooja 9f04242dc7 Update order invoice DTOs 2026-09-16 16:25:49 +05:30
DeepnaPooja d06d434143 Update order invoice endpoints 2026-09-16 16:25:48 +05:30
DeepnaPooja 248129900d Update order repository contract 2026-09-16 16:25:48 +05:30
DeepnaPooja ea8f952f14 Update delivery task invoice flow 2026-09-16 16:25:47 +05:30
DeepnaPooja 4d63b3e720 Seed invoice operations 2026-09-16 16:25:47 +05:30
DeepnaPooja 2f9661fafc Add invoice context mapping 2026-09-16 16:25:46 +05:30
DeepnaPooja 7d740efabb Update test file list cache 2026-09-16 16:25:46 +05:30
DeepnaPooja 222279b478 Update test compile inputs cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 70fd4a9150 Update test assembly reference cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 15b3e43fe8 Update test assembly inputs cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 8fd31a9b20 Update test assembly info 2026-09-16 16:25:45 +05:30
32 changed files with 5514 additions and 77 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(dotnet build *)",
"PowerShell(Get-Command mysql -ErrorAction SilentlyContinue)",
"PowerShell(Get-Command mysql.exe -ErrorAction SilentlyContinue)"
]
}
}
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6c40421b2d23630d4249841425ec7906d93d5985")]
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
00c7acd66c7d7d5bdbb8d4468924c7202fbdc15259175676556b482252622db8
@@ -1 +1 @@
ce3b0b0b2776e1f5cfcf61d517b9f6ce1c89ed303e7e35ab0b7261f5686d0ddb
b5689108dc90f1afbeded25902110c1295d20ccadf0aaae3dfd42fcd421eba23
@@ -125,3 +125,13 @@ C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\n
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.genruntimeconfig.cache
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\.msCoverageSourceRootsMapping_Distribution.Tests
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\CoverletSourceRootsMapping_Distribution.Tests
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.AssemblyReference.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.GeneratedMSBuildEditorConfig.editorconfig
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfoInputs.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfo.cs
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.CoreCompileInputs.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\refint\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
+51 -1
View File
@@ -20,6 +20,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
@@ -64,8 +65,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// OrderItem uses its inherited Template.Id as PK (no composite key on
// {FK_OrderId, FK_ItemId}) so that editing a PO's items can soft-delete the current
// row and insert a fresh one for the same item — versioning per order_invoice_lifecycle.md.
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
.HasIndex(oi => oi.FK_OrderId);
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -143,8 +147,54 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.CreatedByUser)
.WithMany()
.HasForeignKey(o => o.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Order>()
.HasOne(o => o.ConfirmedByUser)
.WithMany()
.HasForeignKey(o => o.ConfirmedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// Order.InvoiceId is a plain column with no DB-level FK constraint — deliberately
// unmapped as a relationship, per order_invoice_lifecycle.md.
modelBuilder.Entity<Order>()
.Property(o => o.InvoiceId)
.HasColumnName("InvoiceId");
modelBuilder.Entity<Invoice>()
.HasOne(i => i.Order)
.WithMany()
.HasForeignKey(i => i.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.PrintedByUser)
.WithMany()
.HasForeignKey(i => i.PrintedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.FinalizedByUser)
.WithMany()
.HasForeignKey(i => i.FinalizedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
@@ -104,6 +104,7 @@ public static class OperationsDataSeed
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
@@ -116,6 +117,7 @@ public static class OperationsDataSeed
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
TotalAmount = 1200m,
Status = OrderStatus.Pending,
FulfilledAt = null,
@@ -187,9 +187,9 @@ public class DeliveryTaskService(
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
if (fullyDelivered && order.FK_SaleId is Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
var sale = await saleRepo.GetByIdAsync(saleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
@@ -0,0 +1,9 @@
namespace Distribution.Features.Invoice;
public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.Invoice entity);
void Update(Models.Invoice entity);
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateInvoiceRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, CancellationToken ct)
{
try
{
var result = await service.PrintAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/finalize")]
public async Task<IActionResult> Finalize(Guid id, FinalizeInvoiceRequest request, CancellationToken ct)
{
try
{
var result = await service.FinalizeAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,29 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid OrderId);
public record FinalizeInvoiceItemRequest(Guid OrderItemId, decimal DeliveredQuantity, decimal FreeQuantity);
public record FinalizeInvoiceReturnRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
public record FinalizeInvoiceDiscountRequest(DiscountType Type, decimal Value, string? Reason);
public record FinalizeInvoiceRequest(
List<FinalizeInvoiceItemRequest> Items,
List<FinalizeInvoiceReturnRequest>? Returns,
FinalizeInvoiceDiscountRequest? Discount);
public record InvoiceResponse(
Guid Id,
Guid OrderId,
InvoiceStatus Status,
Guid CreatedByUserId,
DateTime? PrintedAt,
Guid? PrintedByUserId,
DateTime? FinalizedAt,
Guid? FinalizedByUserId,
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,19 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices.FindAsync(id);
public async Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId) =>
await context.Invoices.FirstOrDefaultAsync(i => i.FK_OrderId == orderId && i.IsActive);
public async Task AddAsync(Models.Invoice entity) =>
await context.Invoices.AddAsync(entity);
public void Update(Models.Invoice entity) =>
context.Invoices.Update(entity);
}
@@ -0,0 +1,203 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Returns;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IOrderRepository orderRepo,
IReturnRepository returnRepo,
IItemRepository itemRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IConfiguration configuration)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_OrderId, i.Status, i.CreatedByUserId, i.PrintedAt, i.PrintedByUserId,
i.FinalizedAt, i.FinalizedByUserId, i.TotalAmount, i.IsActive, i.CreatedAt);
private bool IsModeB =>
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") != "A";
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
}
/// <summary>Mode B only — creates the Invoice (Draft) for an already-Confirmed PO as a
/// separate action. Mode A creates the Invoice atomically via
/// OrderService.ConfirmAndCreateInvoiceAsync instead.</summary>
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, CancellationToken ct = default)
{
if (!IsModeB)
throw new InvalidOperationException("POST /api/invoices is not enabled — this system uses InvoiceCreationMode A, which creates the Invoice as part of PO confirmation.");
var order = await orderRepo.GetByIdAsync(request.OrderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.ConfirmedAt is null)
throw new InvalidOperationException("Only a Confirmed PO can have an Invoice created for it.");
if (order.InvoiceId is not null)
throw new InvalidOperationException("An Invoice already exists for this PO.");
var invoice = new Models.Invoice
{
FK_OrderId = order.Id,
Status = InvoiceStatus.Draft,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
TotalAmount = order.TotalAmount,
};
order.InvoiceId = invoice.Id;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(invoice);
orderRepo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
/// <summary>Draft → Printed. The agent takes the physical copy to the field and may make
/// handwritten edits during the ride.</summary>
public async Task<InvoiceResponse?> PrintAsync(Guid id, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException("Only a Draft invoice can be printed.");
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedByUserId = currentUser.UserId;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
/// <summary>Printed → Finalized. The office worker enters whatever the agent handwrote on the
/// returned printed copy — delivered/free quantities per line, returns/damage (reusing the
/// SaleReturn/CreditNote shape from 18_returns_claims.md), and an optional invoice-level
/// discount. Payload shape resolved in order_invoice_lifecycle.md Open Question 6.</summary>
public async Task<InvoiceResponse?> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException("An invoice can only be finalized from Printed — Draft → Finalized directly is not a valid transition.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("Finalize must include at least one line item.");
var order = await orderRepo.GetByIdAsync(invoice.FK_OrderId)
?? throw new InvalidOperationException("Order not found for this Invoice.");
var orderItems = (await orderRepo.GetItemsByOrderIdAsync(order.Id)).ToDictionary(oi => oi.Id);
decimal subtotal = 0;
foreach (var line in request.Items)
{
if (!orderItems.TryGetValue(line.OrderItemId, out var orderItem))
throw new InvalidOperationException($"OrderItem {line.OrderItemId} does not belong to this Invoice's PO.");
if (line.DeliveredQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Delivered/free quantity cannot be negative.");
subtotal += line.DeliveredQuantity * orderItem.UnitPrice; // freeQuantity never bills
orderItem.QuantityFulfilled = (int)(line.DeliveredQuantity + line.FreeQuantity);
}
Models.SaleReturn? saleReturn = null;
decimal creditNoteAmount = 0;
if (request.Returns is { Count: > 0 })
{
saleReturn = new Models.SaleReturn
{
FK_OrderId = order.Id,
Mode = ReturnMode.CreditDeduction,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
};
foreach (var line in request.Returns)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var matchingOrderItem = orderItems.Values.FirstOrDefault(oi => oi.FK_ItemId == line.ItemId);
var unitPrice = matchingOrderItem?.UnitPrice
?? (await itemRepo.GetByIdAsync(line.ItemId))?.SalePrice
?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice,
});
}
creditNoteAmount = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = creditNoteAmount;
}
decimal discountAmount = request.Discount switch
{
{ Type: DiscountType.Amount } d => d.Value,
{ Type: DiscountType.Percentage } d => subtotal * d.Value / 100m,
null => 0,
_ => 0,
};
invoice.TotalAmount = Math.Max(0, subtotal - discountAmount - creditNoteAmount);
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedByUserId = currentUser.UserId;
order.TotalAmount = invoice.TotalAmount;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
orderRepo.Update(order);
if (saleReturn is not null)
{
await returnRepo.AddAsync(saleReturn);
await returnRepo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = creditNoteAmount,
});
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
}
@@ -8,8 +8,12 @@ public interface IOrderRepository
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
/// <summary>Confirmed POs (ConfirmedAt set, not cancelled) matching the optional filters —
/// backs Mode B's "office browses Confirmed POs" step per order_invoice_lifecycle.md.</summary>
Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId);
}
+45 -1
View File
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
@@ -10,6 +10,10 @@ public class OrderController(OrderService service) : ControllerBase
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("confirmed")]
public async Task<IActionResult> GetConfirmed([FromQuery] Guid? agentId, [FromQuery] Guid? routeId, [FromQuery] Guid? shopId) =>
Ok(await service.GetConfirmedAsync(agentId, routeId, shopId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -35,6 +39,39 @@ public class OrderController(OrderService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> ReplaceItems(Guid id, UpdateOrderItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.ReplaceItemsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-and-create-invoice")]
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAndCreateInvoiceAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
@@ -46,6 +83,13 @@ public class OrderController(OrderService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
var cancelled = await service.DeleteAsync(id, ct);
return cancelled ? NoContent() : NotFound();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
+10 -3
View File
@@ -2,19 +2,26 @@ using Distribution.Models;
namespace Distribution.Features.Order;
public record CreateOrderRequest(Guid SaleId);
public record CreateOrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice);
public record CreateOrderRequest(Guid StoreId, List<CreateOrderItemRequest> Items);
public record UpdateOrderItemsRequest(List<CreateOrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderItemResponse(Guid Id, Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
Guid? SaleId,
Guid? InvoiceId,
DateTime OrderDate,
Guid StoreId,
Guid CreatedByUserId,
decimal TotalAmount,
OrderStatus Status,
DateTime? ConfirmedAt,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
+16 -3
View File
@@ -28,8 +28,10 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
context.Orders.Update(entity);
}
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId) =>
await context.OrderItems.Where(oi => oi.FK_OrderId == orderId).ToListAsync();
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false) =>
await context.OrderItems
.Where(oi => oi.FK_OrderId == orderId && (includeHistory || oi.IsActive))
.ToListAsync();
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
@@ -40,10 +42,21 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.Include(o => o.OrderItems.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
public async Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId) =>
await context.Orders
.Include(o => o.Store)
.Where(o => o.IsActive
&& o.ConfirmedAt != null
&& o.Status != OrderStatus.Cancelled
&& (agentId == null || o.CreatedByUserId == agentId)
&& (routeId == null || o.Store.FK_RouteId == routeId)
&& (storeId == null || o.FK_StoreId == storeId))
.ToListAsync();
}
+251 -30
View File
@@ -1,11 +1,13 @@
using Distribution.Features.Sale;
using Distribution.Features.Invoice;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Sale;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
@@ -16,19 +18,30 @@ public class OrderService(
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IInvoiceRepository invoiceRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess)
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
new(o.Id, o.FK_SaleId, o.InvoiceId, o.OrderDate, o.FK_StoreId, o.CreatedByUserId, o.TotalAmount, o.Status,
o.ConfirmedAt, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.Id, i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
/// <summary>The active InvoiceCreationMode ("A" = combined confirm+create-invoice,
/// "B" = separate confirm then create-invoice) per order_invoice_lifecycle.md. Exactly one is
/// reachable at a time.</summary>
private InvoiceCreationMode ActiveMode =>
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") == "A"
? InvoiceCreationMode.A
: InvoiceCreationMode.B;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
@@ -76,45 +89,152 @@ public class OrderService(
return result;
}
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
/// <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 sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
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;
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
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 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 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 = 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)
{
// TEMPORARY: see DeleteAsync/SaleService — store-scope enforcement depends on the broken
// auth middleware (testbug.md #28) and would reject every update. Skipped for now.
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 sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
var currentItems = await repo.GetItemsByOrderIdAsync(id);
foreach (var item in currentItems)
item.IsActive = false;
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
var newItems = new List<Models.OrderItem>();
foreach (var line in request.Items)
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
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);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
@@ -127,6 +247,107 @@ public class OrderService(
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)
{
// TEMPORARY: see DeleteAsync/SaleService — store-scope enforcement depends on the broken
// auth middleware (testbug.md #28) and would reject every update. Skipped for now.
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
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,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
}
else
{
await uow.BeginAsync(ct);
try
{
repo.Update(order);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
/// <summary>Cancels a Shop PO or Confirmed PO. If an Invoice was already created for this PO,
/// the Invoice/loading-sheet cascade is an explicitly unresolved open question in
/// order_invoice_lifecycle.md (Open Question 5) — the Invoice is left untouched here.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
@@ -134,7 +355,7 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
var sale = order.FK_SaleId is Guid saleId ? await saleRepo.GetByIdAsync(saleId) : null;
await uow.BeginAsync(ct);
try
@@ -78,7 +78,9 @@ public class ReturnService(
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
if (order.FK_SaleId is null)
throw new InvalidOperationException("Sale not found for this order.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId.Value)
?? throw new InvalidOperationException("Sale not found for this order.");
if (sale.FK_RideId is null)
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
@@ -189,6 +189,7 @@ public class SaleService(
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
CreatedByUserId = userId,
};
foreach (var orderItem in orderItems)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddOrderInvoiceLifecycle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Must run before DropPrimaryKey below: OrderItems.FK_OrderId has a foreign key to
// Orders, and MySQL requires a supporting index on that column at all times. The old
// composite PK (FK_OrderId, FK_ItemId) was providing that support; without this index
// created first, dropping the PK leaves FK_OrderId's constraint without an index and
// MySQL rejects the DROP PRIMARY KEY (errno 150).
migrationBuilder.CreateIndex(
name: "IX_OrderItems_FK_OrderId",
table: "OrderItems",
column: "FK_OrderId");
migrationBuilder.DropPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems");
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
migrationBuilder.AlterColumn<Guid>(
name: "FK_SaleId",
table: "Orders",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "ConfirmedAt",
table: "Orders",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ConfirmedByUserId",
table: "Orders",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "CreatedByUserId",
table: "Orders",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
// Backfill every existing Order's new CreatedByUserId from its linked Sale (Order.FK_SaleId
// was required until this migration, so every pre-existing row has one). Covers every
// row generically, not just the two rows seeded via HasData below — otherwise any Order
// created outside the EF seed data would keep the temporary AddColumn default
// (Guid.Empty) and fail the FK_Orders_Users_CreatedByUserId constraint added at the end
// of this migration.
migrationBuilder.Sql(@"
UPDATE `Orders` o
INNER JOIN `Sales` s ON s.`Id` = o.`FK_SaleId`
SET o.`CreatedByUserId` = s.`CreatedByUserId`
WHERE o.`FK_SaleId` IS NOT NULL;");
migrationBuilder.AddColumn<Guid>(
name: "InvoiceId",
table: "Orders",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems",
column: "Id");
migrationBuilder.CreateTable(
name: "Invoices",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
PrintedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PrintedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FinalizedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalizedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Invoices", x => x.Id);
table.ForeignKey(
name: "FK_Invoices_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Invoices_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Invoices_Users_FinalizedByUserId",
column: x => x.FinalizedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Invoices_Users_PrintedByUserId",
column: x => x.PrintedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_OrderId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("34000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("34000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), true, 24, 24, 50m },
{ new Guid("34000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), true, 10, 0, 120m }
});
migrationBuilder.UpdateData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000001"),
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000002"), null });
migrationBuilder.UpdateData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000002"),
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000003"), null });
migrationBuilder.CreateIndex(
name: "IX_Orders_ConfirmedByUserId",
table: "Orders",
column: "ConfirmedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Orders_CreatedByUserId",
table: "Orders",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_CreatedByUserId",
table: "Invoices",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_FinalizedByUserId",
table: "Invoices",
column: "FinalizedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_FK_OrderId",
table: "Invoices",
column: "FK_OrderId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_PrintedByUserId",
table: "Invoices",
column: "PrintedByUserId");
migrationBuilder.AddForeignKey(
name: "FK_Orders_Users_ConfirmedByUserId",
table: "Orders",
column: "ConfirmedByUserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Orders_Users_CreatedByUserId",
table: "Orders",
column: "CreatedByUserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Orders_Users_ConfirmedByUserId",
table: "Orders");
migrationBuilder.DropForeignKey(
name: "FK_Orders_Users_CreatedByUserId",
table: "Orders");
migrationBuilder.DropTable(
name: "Invoices");
migrationBuilder.DropIndex(
name: "IX_Orders_ConfirmedByUserId",
table: "Orders");
migrationBuilder.DropIndex(
name: "IX_Orders_CreatedByUserId",
table: "Orders");
migrationBuilder.DropPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems");
migrationBuilder.DropIndex(
name: "IX_OrderItems_FK_OrderId",
table: "OrderItems");
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000003"));
migrationBuilder.DropColumn(
name: "ConfirmedAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "ConfirmedByUserId",
table: "Orders");
migrationBuilder.DropColumn(
name: "CreatedByUserId",
table: "Orders");
migrationBuilder.DropColumn(
name: "InvoiceId",
table: "Orders");
migrationBuilder.AlterColumn<Guid>(
name: "FK_SaleId",
table: "Orders",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddPrimaryKey(
name: "PK_OrderItems",
table: "OrderItems",
columns: new[] { "FK_OrderId", "FK_ItemId" });
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
});
}
}
}
@@ -536,6 +536,55 @@ namespace Distribution.Migrations
});
});
modelBuilder.Entity("Distribution.Models.Invoice", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<DateTime?>("FinalizedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("FinalizedByUserId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("PrintedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("PrintedByUserId")
.HasColumnType("char(36)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_OrderId");
b.HasIndex("FinalizedByUserId");
b.HasIndex("PrintedByUserId");
b.ToTable("Invoices");
});
modelBuilder.Entity("Distribution.Models.Item", b =>
{
b.Property<Guid>("Id")
@@ -768,10 +817,19 @@ namespace Distribution.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ConfirmedByUserId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
@@ -780,6 +838,10 @@ namespace Distribution.Migrations
b.Property<DateTime?>("FulfilledAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("InvoiceId")
.HasColumnType("char(36)")
.HasColumnName("InvoiceId");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
@@ -794,6 +856,10 @@ namespace Distribution.Migrations
b.HasKey("Id");
b.HasIndex("ConfirmedByUserId");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_SaleId")
.IsUnique();
@@ -806,6 +872,7 @@ namespace Distribution.Migrations
{
Id = new Guid("33000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
CreatedByUserId = new Guid("29000000-0000-0000-0000-000000000002"),
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000001"),
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000001"),
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified),
@@ -818,6 +885,7 @@ namespace Distribution.Migrations
{
Id = new Guid("33000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
CreatedByUserId = new Guid("29000000-0000-0000-0000-000000000003"),
FK_SaleId = new Guid("32000000-0000-0000-0000-000000000002"),
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000005"),
IsActive = true,
@@ -829,16 +897,17 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.OrderItem", b =>
{
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("Id")
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
@@ -853,19 +922,21 @@ namespace Distribution.Migrations
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
b.HasKey("FK_OrderId", "FK_ItemId");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_OrderId");
b.ToTable("OrderItems");
b.HasData(
new
{
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("34000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000001"),
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true,
Quantity = 30,
QuantityFulfilled = 30,
@@ -873,10 +944,10 @@ namespace Distribution.Migrations
},
new
{
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("34000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000003"),
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000001"),
IsActive = true,
Quantity = 24,
QuantityFulfilled = 24,
@@ -884,10 +955,10 @@ namespace Distribution.Migrations
},
new
{
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000002"),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
Id = new Guid("34000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_OrderId = new Guid("33000000-0000-0000-0000-000000000002"),
IsActive = true,
Quantity = 10,
QuantityFulfilled = 0,
@@ -3300,6 +3371,39 @@ namespace Distribution.Migrations
b.Navigation("PurchaseOrderItem");
});
modelBuilder.Entity("Distribution.Models.Invoice", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "FinalizedByUser")
.WithMany()
.HasForeignKey("FinalizedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.User", "PrintedByUser")
.WithMany()
.HasForeignKey("PrintedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CreatedByUser");
b.Navigation("FinalizedByUser");
b.Navigation("Order");
b.Navigation("PrintedByUser");
});
modelBuilder.Entity("Distribution.Models.Item", b =>
{
b.HasOne("Distribution.Models.Uom", "BaseUom")
@@ -3371,11 +3475,21 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.HasOne("Distribution.Models.User", "ConfirmedByUser")
.WithMany()
.HasForeignKey("ConfirmedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
@@ -3383,6 +3497,10 @@ namespace Distribution.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ConfirmedByUser");
b.Navigation("CreatedByUser");
b.Navigation("Sale");
b.Navigation("Store");
+6
View File
@@ -23,6 +23,12 @@ namespace Distribution.Models
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum InvoiceStatus { Draft, Printed, Finalized }
public enum InvoiceCreationMode { A, B }
public enum DiscountType { Amount, Percentage }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
public enum PaymentMethod { Cash, Credit }
+30
View File
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Invoice : Template
{
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public DateTime? PrintedAt { get; set; }
public Guid? PrintedByUserId { get; set; }
[ForeignKey("PrintedByUserId")]
public User? PrintedByUser { get; set; }
public DateTime? FinalizedAt { get; set; }
public Guid? FinalizedByUserId { get; set; }
[ForeignKey("FinalizedByUserId")]
public User? FinalizedByUser { get; set; }
/// <summary>Reflects current PO line items through Draft/Printed; locked at Finalized.</summary>
public decimal TotalAmount { get; set; }
}
}
+20 -3
View File
@@ -1,12 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Order : Template
{
public Guid FK_SaleId { get; set; }
public Guid? FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public Sale? Sale { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
/// <summary>Set once an Invoice is created for this PO (either mode). No DB-level FK
/// constraint — configured explicitly in Fluent API per order_invoice_lifecycle.md.</summary>
public Guid? InvoiceId { get; set; }
public DateTime OrderDate { get; set; }
public Guid FK_StoreId { get; set; }
@@ -16,6 +24,15 @@ namespace Distribution.Models
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime? FulfilledAt { get; set; }
/// <summary>Null = Shop PO (items editable). Set = Confirmed PO (items locked). Not modeled
/// as an explicit field in order_invoice_lifecycle.md's diagram, but required to implement
/// its Business Rule 1 (item edits gated on confirmation) independent of Invoice existing —
/// Mode B confirms a PO with no Invoice at all.</summary>
public DateTime? ConfirmedAt { get; set; }
public Guid? ConfirmedByUserId { get; set; }
[ForeignKey("ConfirmedByUserId")]
public User? ConfirmedByUser { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
}
}
+3
View File
@@ -10,6 +10,7 @@ using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Item;
using Distribution.Features.Invoice;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
@@ -79,6 +80,7 @@ builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -112,6 +114,7 @@ builder.Services.AddScoped<ClaimService>();
builder.Services.AddScoped<SupplierClaimService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+3
View File
@@ -26,6 +26,9 @@
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
},
"OrderInvoice": {
"InvoiceCreationMode": "B"
},
"AuthService": {
"BaseUrl": ""
}
@@ -160,9 +160,11 @@ enters any edits the agent made into the system and submits.
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`** — the fields the office
worker can edit (returns, damage, discounts) are assumed but not fully specified. "Free issues"
handling (items given to the shop at no charge) is a known case needing its own payload field(s).
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,12 +1,89 @@
# [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), [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 with a single unified pipeline. The PO and Invoice are
@@ -201,9 +278,38 @@ Draft → Printed → Finalized
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`** — quantity adjustments, returns, damage, and
discounts are assumed but the payload shape is not fully specified. "Free issues" handling (items
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
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.
---