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
94 changed files with 4175 additions and 7474 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
+114 -147
View File
@@ -17,10 +17,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
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<LoadingSheetOrder> LoadingSheetOrders { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
@@ -62,11 +62,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// --- Composite PKs ---
// 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>()
.HasIndex(oi => oi.FK_OrderId);
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -80,10 +83,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<LoadingSheetOrder>()
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
// --- Unique indexes ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
@@ -93,20 +97,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
// --- User ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Vehicle ---
modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>()
.WithMany()
@@ -114,7 +112,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- DeliveryRun ---
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile)
@@ -122,97 +124,76 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
// --- ShopPO ---
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(p => p.FK_StoreId)
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
// InvoiceId is a plain column — no FK constraint
modelBuilder.Entity<ShopPO>()
.Property(p => p.InvoiceId)
.HasColumnName("InvoiceId");
// --- ShopPOItem ---
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.ShopPO)
.WithMany(p => p.Items)
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.Item)
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Invoice ---
modelBuilder.Entity<Invoice>()
.HasOne(i => i.ShopPO)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
.HasForeignKey(p => p.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheet ---
modelBuilder.Entity<LoadingSheet>()
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetOrder ---
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.LoadingSheet)
.WithMany(s => s.Orders)
.HasForeignKey(lo => lo.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.ShopPO)
.WithMany()
.HasForeignKey(lo => lo.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetItem ---
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.LoadingSheet)
.WithMany(s => s.Items)
.HasForeignKey(i => i.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Warehouse)
.WithMany()
.HasForeignKey(i => i.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.SetNull)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SchemeRedemption ---
modelBuilder.Entity<Order>()
.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)
@@ -221,11 +202,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
@@ -233,8 +213,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- Scheme ---
modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem)
.WithMany()
@@ -263,7 +241,23 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
// --- PurchaseOrder ---
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.SourceWarehouse)
.WithMany()
.HasForeignKey(s => s.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
@@ -283,11 +277,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
// --- GoodsReceipt ---
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder)
.WithMany()
@@ -312,14 +301,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder)
.WithMany()
@@ -338,15 +319,22 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
// --- SaleReturn ---
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
@@ -370,16 +358,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
// --- CreditNote ---
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
// --- Claims ---
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
@@ -432,8 +416,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
// --- DeliveryTask ---
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
@@ -441,13 +423,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.ShopPO)
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_ShopPOId)
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
// --- Regional ---
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -470,19 +450,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
.HasKey(c => c.Key);
modelBuilder.Entity<SysConfig>()
.Property(c => c.Key)
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
+3 -3
View File
@@ -80,15 +80,15 @@ public static class CommerceSeed
);
modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
);
modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
);
modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<SaleReturnItem>().HasData(
+74 -54
View File
@@ -8,17 +8,15 @@ public static class OperationsDataSeed
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
// Kept for OperationsExtrasSeed stock movement reference (ReferenceId on MovementType.Sale)
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -33,12 +31,16 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500,
Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
@@ -46,71 +48,88 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ActiveDeliveryRunId,
Status = RideStatus.Open,
RideType = RideType.Delivery,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300,
Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPO>().HasData(
new ShopPO
modelBuilder.Entity<Sale>().HasData(
new Sale
{
Id = ShopPO1Id,
Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
RaisedById = MasterDataSeed.SalesRep1UserId,
InvoiceId = Invoice1Id,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId,
TotalAmount = 3000m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
Status = SaleStatus.Completed,
PaymentStatus = PaymentStatus.Paid,
FulfillImmediately = false,
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Sale
{
Id = Sale2Id,
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
FK_RideId = ActiveDeliveryRunId,
TotalAmount = 1200m,
Status = SaleStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
FulfillImmediately = false,
CompletedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<Order>().HasData(
new Order
{
Id = Order1Id,
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),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new ShopPO
new Order
{
Id = ShopPO2Id,
Id = Order2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
TotalAmount = 1200m,
Status = ShopPOStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
Status = OrderStatus.Pending,
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPOItem>().HasData(
new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<Invoice>().HasData(
new Invoice
{
Id = Invoice1Id,
FK_ShopPOId = ShopPO1Id,
Status = InvoiceStatus.Finalized,
PaymentStatus = PaymentStatus.Paid,
CreatedBy = MasterDataSeed.SalesRep1UserId,
PrintedAt = new DateTime(2026, 8, 25, 8, 45, 0),
PrintedBy = MasterDataSeed.SalesRep1UserId,
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0),
FinalizedBy = MasterDataSeed.Supervisor1UserId,
TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
IsActive = true
}
modelBuilder.Entity<OrderItem>().HasData(
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<LoadingSheet>().HasData(
@@ -118,19 +137,20 @@ public static class OperationsDataSeed
{
Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId,
Status = LoadingSheetStatus.Confirmed,
PopulatedBy = MasterDataSeed.Supervisor1UserId,
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Status = LoadingSheetStatus.ReceiptConfirmed,
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
}
);
modelBuilder.Entity<LoadingSheetItem>().HasData(
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 50m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true }
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
);
modelBuilder.Entity<DeliveryTask>().HasData(
@@ -138,7 +158,7 @@ public static class OperationsDataSeed
{
Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId,
FK_ShopPOId = ShopPO2Id,
FK_OrderId = Order2Id,
Sequence = 1,
Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false,
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_ShopPOId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -8,27 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
@@ -1,5 +1,6 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -9,22 +10,23 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_ShopPOId,
t.ShopPO.FK_StoreId,
t.ShopPO.Store?.StoreName ?? string.Empty,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
@@ -32,8 +34,52 @@ public class DeliveryTaskService(
i.UnitPrice)).ToList(),
t.CreatedAt);
public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
@@ -57,16 +103,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items)
{
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > shopPOItem.Quantity)
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
@@ -89,7 +135,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0))
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
@@ -97,21 +143,21 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var shopPO = task.ShopPO;
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
shopPO.FulfilledAt = DateTime.Now;
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0;
@@ -134,12 +180,21 @@ public class DeliveryTaskService(
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_ShopPOId
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
shopPORepo.Update(shopPO);
orderRepo.Update(order);
if (fullyDelivered && order.FK_SaleId is Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
@@ -1,13 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
Task AddAsync(Models.Invoice invoice);
void Update(Models.Invoice invoice);
Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.Invoice entity);
void Update(Models.Invoice entity);
}
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Invoice;
@@ -7,23 +6,43 @@ namespace Distribution.Features.Invoice;
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
return Ok(result);
}
[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 }); }
}
}
+17 -10
View File
@@ -1,22 +1,29 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
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 ShopPOId,
string StoreName,
Guid OrderId,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
Guid CreatedBy,
Guid CreatedByUserId,
DateTime? PrintedAt,
Guid? PrintedBy,
Guid? PrintedByUserId,
DateTime? FinalizedAt,
Guid? FinalizedBy,
Guid? FinalizedByUserId,
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
@@ -1,5 +1,4 @@
using Distribution.Data;
using Distribution.Models;
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
@@ -7,41 +6,14 @@ namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
await context.Invoices.FindAsync(id);
public async Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId) =>
await context.Invoices
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId) =>
await context.Invoices.FirstOrDefaultAsync(i => i.FK_OrderId == orderId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task AddAsync(Models.Invoice entity) =>
await context.Invoices.AddAsync(entity);
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status));
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice);
public void Update(Models.Invoice entity) =>
context.Invoices.Update(entity);
}
+183 -13
View File
@@ -1,33 +1,203 @@
using Distribution.Features.Stock;
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)
ICurrentUserService currentUser,
IConfiguration configuration)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
i.ShopPO?.Store?.StoreName ?? string.Empty,
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.IsActive, i.CreatedAt);
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 ? null : ToResponse(invoice);
return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
/// <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)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, status);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
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,6 +8,7 @@ public interface ILoadingSheetRepository
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -29,10 +29,9 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
[HttpPost]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(request, userId, ct);
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -51,13 +50,90 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
[HttpPost("{id:guid}/confirm-dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmAsync(id, userId, ct);
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetReceiptItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-receipt")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ResolveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -5,27 +5,37 @@ namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
Guid PopulatedBy,
DateTime PopulatedAt,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items,
List<LoadingSheetOrderResponse> Orders);
List<LoadingSheetItemResponse> Items);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
Guid? FK_ShopPOItemId);
public record LoadingSheetOrderResponse(Guid ShopPOId);
decimal? QuantityReceived,
decimal? QuantityResolved);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -8,20 +8,20 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
{
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Orders)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
.FirstOrDefaultAsync(s =>
s.FK_RideId == rideId &&
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Orders)
.Include(s => s.SourceWarehouse)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
@@ -36,6 +36,19 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status));
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
@@ -24,7 +24,7 @@ public class LoadingSheetService(
return sheet is null ? null : ToResponse(sheet);
}
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -34,16 +34,22 @@ public class LoadingSheetService(
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has a Planned loading sheet");
throw new InvalidOperationException("This ride already has an active loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
Status = LoadingSheetStatus.Planned,
PopulatedBy = userId,
PopulatedAt = DateTime.Now
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
};
if (ride.RideType == RideType.Delivery)
{
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
foreach (var (itemId, qty) in orderItems)
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
}
await uow.BeginAsync(ct);
try
{
@@ -61,8 +67,8 @@ public class LoadingSheetService(
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
await uow.BeginAsync(ct);
try
@@ -72,8 +78,6 @@ public class LoadingSheetService(
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
FK_WarehouseId = i.FK_WarehouseId,
FK_ShopPOItemId = i.FK_ShopPOItemId,
QuantityDispatched = i.Quantity
}));
await uow.CommitAsync(ct);
@@ -84,91 +88,289 @@ public class LoadingSheetService(
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
sheet.DispatchConfirmedAt = DateTime.Now;
sheet.DispatchConfirmedBy = userId;
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
item.QuantityReceived = input.QuantityReceived;
}
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId);
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
sheet.Status = LoadingSheetStatus.Confirmed;
sheet.ConfirmedAt = DateTime.Now;
sheet.ConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return ToResponse(saved!);
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
{
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var input in request.Items)
{
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
warehouseBalance.Quantity -= item.QuantityDispatched;
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
if (warehouseBalance.Quantity < qty)
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
warehouseBalance.Quantity -= qty;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.FK_ItemId,
FK_ItemId = itemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched
Quantity = qty
});
}
else
{
vehicleBalance.Quantity += item.QuantityDispatched;
vehicleBalance.Quantity += qty;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = item.FK_WarehouseId,
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched,
Quantity = qty,
Type = MovementType.Load,
ReferenceId = sheetId,
ReferenceId = sheet.Id,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
s.SourceWarehouse?.Name ?? string.Empty,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
}
@@ -0,0 +1,19 @@
namespace Distribution.Features.Order;
public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
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);
}
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
[ApiController]
[Route("api/orders")]
public class OrderController(OrderService service) : ControllerBase
{
[HttpGet]
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)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
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)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
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)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
+28
View File
@@ -0,0 +1,28 @@
using Distribution.Models;
namespace Distribution.Features.Order;
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 Id, Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid? SaleId,
Guid? InvoiceId,
DateTime OrderDate,
Guid StoreId,
Guid CreatedByUserId,
decimal TotalAmount,
OrderStatus Status,
DateTime? ConfirmedAt,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,62 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
public class OrderRepository(AppDBContext context) : IOrderRepository
{
public async Task<Models.Order?> GetByIdAsync(Guid id) =>
await context.Orders.FindAsync(id);
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
public void Update(Models.Order entity) =>
context.Orders.Update(entity);
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
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();
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.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();
}
+379
View File
@@ -0,0 +1,379 @@
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;
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IInvoiceRepository invoiceRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
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.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;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
public async Task<List<OrderResponse>> GetAllAsync()
{
// Deleted orders stay IsActive=false but remain visible with a Cancelled status —
// Delete never removes the row from the list, only marks it cancelled.
var orders = (await repo.GetAllAsync()).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
orders = orders.Where(o => accessibleStoreIds.Contains(o.FK_StoreId)).ToList();
}
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?> GetByIdAsync(Guid id)
{
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
public async Task<List<OrderResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var orders = await repo.GetByStoreIdAsync(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;
}
/// <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 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)
{
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)
{
// 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
// middleware (testbug.md #28) and would reject every delete. Skipped for now.
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
var sale = order.FK_SaleId is Guid saleId ? await saleRepo.GetByIdAsync(saleId) : null;
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/payments")]
public class FlatPaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? storeId,
[FromQuery] Guid? saleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
return Ok(result);
}
[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(CreateFlatPaymentRequest request, CancellationToken ct)
{
try
{
var inner = new CreatePaymentRequest(request.Amount, request.Method, request.CollectedByUserId);
var result = await service.CreateAsync(request.SaleId, inner, ct);
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,13 +1,12 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<Models.Payment?> GetByIdAsync(Guid id);
Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize);
Task<int> GetCountAsync(Guid? invoiceId);
Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId);
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
void Update(Models.Payment payment);
}
@@ -3,22 +3,22 @@
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/invoices/{invoiceId:guid}/payments")]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetByInvoice(Guid invoiceId)
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetByInvoiceIdAsync(invoiceId);
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct)
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(invoiceId, request, ct);
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
+10 -2
View File
@@ -1,15 +1,23 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Payment;
public record CreatePaymentRequest(
decimal Amount,
PaymentMethod Method,
// TEMPORARY: auth middleware doesn't resolve a logged-in user yet — see testbug.md #28.
Guid? CollectedByUserId = null);
public record CreateFlatPaymentRequest(
Guid SaleId,
decimal Amount,
PaymentMethod Method,
Guid? CollectedByUserId = null);
public record PaymentResponse(
Guid Id,
Guid InvoiceId,
Guid SaleId,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -9,27 +9,29 @@ public class PaymentRepository(AppDBContext context) : IPaymentRepository
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize) =>
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
await context.Payments
.Where(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId))
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.OrderByDescending(p => p.CollectedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? invoiceId) =>
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
await context.Payments
.CountAsync(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId));
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.CountAsync();
public async Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId) =>
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_InvoiceId == invoiceId && p.IsActive)
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
public void Update(Models.Payment payment) =>
context.Payments.Update(payment);
}
+35 -17
View File
@@ -1,4 +1,5 @@
using Distribution.Features.Invoice;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -7,35 +8,51 @@ namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
IInvoiceRepository invoiceRepo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
{
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
var total = await repo.GetCountAsync(storeId, saleId);
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
var payments = await repo.GetByInvoiceIdAsync(invoiceId);
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
{
var payment = await repo.GetByIdAsync(id);
return payment is null ? null : ToResponse(payment);
}
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct = default)
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CollectedByUserId
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
var payment = new Models.Payment
{
FK_InvoiceId = invoiceId,
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
@@ -47,16 +64,17 @@ public class PaymentService(
{
await repo.AddAsync(payment);
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
invoice.PaymentStatus = total == 0
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < invoice.TotalAmount
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
invoiceRepo.Update(invoice);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -42,5 +42,5 @@ public interface IRegionRepository
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -129,11 +129,11 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.ShopPOs
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.Orders
join s in context.Stores on o.FK_StoreId equals s.Id
join r in context.Routes on s.FK_RouteId equals r.Id
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
&& o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
select o).ToListAsync();
}
@@ -219,7 +219,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
budget?.BudgetAmount,
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate),
taxRule?.RuleConfig,
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
}
@@ -4,11 +4,11 @@ public interface IReturnRepository
{
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
Task<List<Models.SaleReturn>> GetAllAsync();
Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.SaleReturn entity);
void Update(Models.SaleReturn entity);
Task AddCreditNoteAsync(Models.CreditNote entity);
/// <summary>Whether this store has ever had this item on any of its (active) ShopPOs.</summary>
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
}
@@ -1,4 +1,4 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
@@ -28,13 +28,15 @@ public class ReturnController(ReturnService service, ICurrentUserService current
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/shop-pos/{id:guid}/returns")]
public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByShopPOIdAsync(id));
[HttpGet("api/orders/{id:guid}/returns")]
public async Task<IActionResult> GetByOrder(Guid id) =>
Ok(await service.GetByOrderIdAsync(id));
[HttpPost("api/shop-pos/{id:guid}/returns")]
[HttpPost("api/orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
try
@@ -42,7 +44,6 @@ public class ReturnController(ReturnService service, ICurrentUserService current
var result = await service.CreateAsync(id, request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ public record SaleReturnItemResponse(
public record SaleReturnResponse(
Guid Id,
Guid ShopPOId,
Guid OrderId,
ReturnMode Mode,
decimal TotalCreditAmount,
decimal OrderTotalAmountAfter,
@@ -7,23 +7,23 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
{
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
await context.SaleReturns
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<Models.SaleReturn>> GetAllAsync() =>
await context.SaleReturns
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
public async Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId) =>
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
await context.SaleReturns
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.FK_ShopPOId == shopPOId && r.IsActive)
.Where(r => r.FK_OrderId == orderId && r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
@@ -37,7 +37,7 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
await context.CreditNotes.AddAsync(entity);
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
await context.ShopPOItems
.Include(oi => oi.ShopPO)
.AnyAsync(oi => oi.ShopPO.FK_StoreId == storeId && oi.ShopPO.IsActive && oi.FK_ItemId == itemId);
await context.OrderItems
.Include(oi => oi.Order)
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
}
+145 -7
View File
@@ -1,5 +1,7 @@
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,7 +10,9 @@ namespace Distribution.Features.Returns;
public class ReturnService(
IReturnRepository repo,
IShopPORepository shopPORepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IRideRepository rideRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
@@ -17,7 +21,7 @@ public class ReturnService(
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
private static SaleReturnResponse Map(SaleReturn r) =>
new(r.Id, r.FK_ShopPOId, r.Mode, r.TotalCreditAmount, r.ShopPO.TotalAmount, r.IsActive, r.CreatedAt,
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
@@ -54,9 +58,143 @@ public class ReturnService(
return Map(r);
}
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
/// <summary>
/// Records a return recognized during the same store visit as an open order and settles it
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
/// A return is only allowed for an item the store has previously ordered.
/// </summary>
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
throw new InvalidOperationException("Cannot record a return against a closed order.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
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.");
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
?? throw new InvalidOperationException("Ride not found.");
var vehicleId = ride.FK_VehicleId;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
var saleReturn = new SaleReturn
{
FK_OrderId = orderId,
Mode = request.Mode,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
? matchingOrderItem.UnitPrice
: item.SalePrice ?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice
});
}
await uow.BeginAsync(ct);
try
{
foreach (var line in saleReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = line.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = line.Quantity
});
}
else
{
balance.Quantity += line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = line.Quantity,
Type = MovementType.CustomerReturn,
ReasonCode = line.ReasonCode.ToString(),
ReferenceId = saleReturn.Id,
CreatedBy = createdByUserId
});
}
await repo.AddAsync(saleReturn);
if (request.Mode == ReturnMode.CreditDeduction)
{
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = total;
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
orderRepo.Update(order);
await repo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = total
});
}
else // ItemExchange
{
foreach (var line in saleReturn.Items)
{
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
continue; // no matching line on this order — nothing to offset
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
orderItem.Quantity -= (int)offsetQty;
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
}
orderRepo.Update(order);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
}
+13 -4
View File
@@ -1,13 +1,22 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Ride;
public interface IRideRepository
{
Task<DeliveryRun?> GetByIdAsync(Guid id);
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId);
Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId);
Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null);
Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId);
Task AddAsync(DeliveryRun ride);
void Update(DeliveryRun ride);
}
public record OrderSummaryModel(
Guid Id,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
+91 -12
View File
@@ -1,21 +1,23 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
public class RideController(RideService service) : ControllerBase
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] RideStatus? status,
[FromQuery] RideType? type,
[FromQuery] Guid? driverProfileId,
[FromQuery] Guid? vehicleId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
var result = await service.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
return Ok(result);
}
@@ -29,12 +31,8 @@ public class RideController(RideService service) : ControllerBase
[HttpPost]
public async Task<IActionResult> Create(CreateRideRequest 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 }); }
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}/driver")]
@@ -49,6 +47,32 @@ public class RideController(RideService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/open")]
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
{
try
{
var result = await service.OpenAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/activate")]
[HttpPost("{id:guid}/activate")]
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
{
try
{
var result = await service.ActivateAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/close")]
[HttpPost("{id:guid}/close")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{
@@ -61,15 +85,70 @@ public class RideController(RideService service) : ControllerBase
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelRideRequest request, CancellationToken ct)
[HttpPost("{id:guid}/cancel-request")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CancelAsync(id, request, ct);
var result = await service.RaiseCancelRequestAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("cancel-requests")]
public async Task<IActionResult> GetCancelRequests(
[FromQuery] CancelRequestStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetCancelRequestsAsync(status, page, pageSize);
return Ok(result);
}
[HttpPost("cancel-requests/{id:guid}/approve")]
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ApproveCancelAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("cancel-requests/{id:guid}/reject")]
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RejectCancelAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("{id:guid}/cancel-request")]
public async Task<IActionResult> GetCancelRequest(Guid id)
{
var result = await service.GetCancelRequestAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("breakdown-assist/{rideId:guid}")]
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
{
try
{
var result = await service.GetBreakdownAssistAsync(rideId);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
+49 -3
View File
@@ -1,27 +1,73 @@
using Distribution.Models;
using Distribution.Models;
using Distribution.Features.Stock;
namespace Distribution.Features.Ride;
// Ride (DeliveryRun)
public record RideResponse(
Guid Id,
RideType RideType,
RideStatus Status,
Guid VehicleId,
string VehicleNumberPlate,
Guid DriverProfileId,
string DriverFullName,
Guid RouteId,
string RouteName,
Guid SourceWarehouseId,
string SourceWarehouseName,
int Meterstart,
int Meterend,
DateTime? StartedAt,
DateTime? ActivatedAt,
DateTime? ClosedAt,
DateTime CreatedAt);
public record CreateRideRequest(
RideType RideType,
Guid VehicleId,
Guid DriverProfileId,
int Meterstart);
Guid RouteId,
Guid? SourceWarehouseId);
public record AssignRideDriverRequest(Guid DriverProfileId);
public record ActivateRideRequest(int Meterstart);
public record CloseRideRequest(int Meterend);
public record CancelRideRequest(CancelRequestType TypeCode, string Reason);
public record RaiseCancelRequest(CancelRequestType TypeCode, string Reason);
// Cancel Request
public record RideCancelRequestResponse(
Guid Id,
Guid RideId,
CancelRequestType TypeCode,
string Reason,
Guid RequestedBy,
DateTime RequestedAt,
CancelRequestStatus Status,
Guid? ApprovedBy,
DateTime? ApprovedAt);
public record BreakdownAssistRideResponse(
Guid RideId,
Guid VehicleId,
string VehicleNumberPlate,
Guid DriverProfileId,
string DriverFullName,
Guid RouteId,
string RouteName,
List<StockBalanceResponse> StockLevels);
public record BreakdownAssistOrderResponse(
Guid OrderId,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
public record BreakdownAssistResponse(
Guid RideId,
List<BreakdownAssistRideResponse> ActiveRides,
List<BreakdownAssistOrderResponse> OpenOrders);
+32 -5
View File
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -10,32 +10,59 @@ public class RideRepository(AppDBContext context) : IRideRepository
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Include(r => r.SourceWarehouse)
.Where(r =>
(status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId) =>
public async Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId) =>
await context.DeliveryRuns
.CountAsync(r =>
(status == null || r.Status == status) &&
(type == null || r.RideType == type) &&
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
(vehicleId == null || r.FK_VehicleId == vehicleId));
public async Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
public async Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
await context.DeliveryRuns.AnyAsync(r =>
r.FK_VehicleId == vehicleId &&
r.Id != excludeRideId &&
r.Status == RideStatus.Open);
(r.Status == RideStatus.Open || r.Status == RideStatus.Active));
public async Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null) =>
await context.DeliveryRuns
.Include(r => r.Vehicle)
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
.Include(r => r.Route)
.Where(r => r.Id != excludeRideId && r.Status == RideStatus.Active)
.ToListAsync();
public async Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Where(o => o.IsActive && o.Store.FK_RouteId == routeId)
.OrderByDescending(o => o.OrderDate)
.Select(o => new OrderSummaryModel(
o.Id,
o.FK_StoreId,
o.Store.StoreName,
o.OrderDate,
o.TotalAmount))
.ToListAsync();
public async Task AddAsync(DeliveryRun ride) =>
await context.DeliveryRuns.AddAsync(ride);
+225 -21
View File
@@ -1,4 +1,4 @@
using Distribution.Features.Stock;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
@@ -10,13 +10,21 @@ public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IRideCancelRequestRepository cancelRepo,
Features.Vehicle.IVehicleRepository vehicleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
public async Task<PagedResponse<RideResponse>> GetAllAsync(
RideStatus? status,
RideType? type,
Guid? driverProfileId,
Guid? vehicleId,
int page,
int pageSize)
{
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -28,18 +36,21 @@ public class RideService(
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
{
if (await rideRepo.HasOpenRideForVehicleAsync(request.VehicleId))
throw new InvalidOperationException("Vehicle already has an open ride");
var vehicle = await vehicleRepo.GetByIdAsync(request.VehicleId)
?? throw new InvalidOperationException("Vehicle not found");
await CheckDriverComplianceAsync(request.DriverProfileId);
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.SourceWarehouseId;
if (!sourceWarehouseId.HasValue)
throw new InvalidOperationException("Source warehouse is required for this vehicle");
var ride = new DeliveryRun
{
RideType = request.RideType,
FK_VehicleId = request.VehicleId,
FK_DriverProfileId = request.DriverProfileId,
Status = RideStatus.Open,
Meterstart = request.Meterstart,
StartedAt = DateTime.Now
FK_RouteId = request.RouteId,
FK_SourceWarehouseId = sourceWarehouseId.Value,
Status = RideStatus.Draft
};
await uow.BeginAsync(ct);
@@ -48,19 +59,46 @@ public class RideService(
await rideRepo.AddAsync(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
catch
{
await uow.RollbackAsync(ct);
throw;
}
var saved = await rideRepo.GetByIdAsync(ride.Id);
return ToResponse(saved!);
}
public async Task<RideResponse> OpenAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Draft)
throw new InvalidOperationException($"Cannot open a ride in {ride.Status} status");
if (await rideRepo.HasOpenOrActiveRideForVehicleAsync(ride.FK_VehicleId, ride.Id))
throw new InvalidOperationException("Vehicle already has an open or active ride");
await CheckDriverComplianceAsync(ride.FK_DriverProfileId);
ride.Status = RideStatus.Open;
ride.StartedAt = DateTime.Now;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("Driver can only be changed while the ride is Open");
if (ride.Status != RideStatus.Draft)
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status");
var driver = await driverRepo.GetByIdAsync(driverProfileId);
if (driver is null || !driver.IsActive)
@@ -76,12 +114,31 @@ public class RideService(
return ToResponse(saved!);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
public async Task<RideResponse> ActivateAsync(Guid rideId, ActivateRideRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot activate a ride in {ride.Status} status");
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
if (request.Meterend < ride.Meterstart)
@@ -100,21 +157,162 @@ public class RideService(
return ToResponse(ride);
}
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default)
public async Task<RideCancelRequestResponse> RaiseCancelRequestAsync(Guid rideId, RaiseCancelRequest request, Guid requestedBy, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException($"Cannot raise a cancel request for a ride in {ride.Status} status");
var existing = await cancelRepo.GetPendingByRideIdAsync(rideId);
if (existing is not null)
throw new InvalidOperationException("There is already a pending cancel request for this ride");
var cancelRequest = new RideCancelRequest
{
FK_RideId = rideId,
TypeCode = request.TypeCode,
Reason = request.Reason,
RequestedBy = requestedBy,
RequestedAt = DateTime.Now,
Status = CancelRequestStatus.Pending
};
ride.Status = RideStatus.CancelRequested;
await uow.BeginAsync(ct);
try
{
await cancelRepo.AddAsync(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<PagedResponse<RideCancelRequestResponse>> GetCancelRequestsAsync(CancelRequestStatus? status, int page, int pageSize)
{
var total = await cancelRepo.GetCountAsync(status);
var items = await cancelRepo.GetAllAsync(status, page, pageSize);
return new PagedResponse<RideCancelRequestResponse>(page, pageSize, total, items.Select(ToCancelResponse).ToList());
}
public async Task<RideCancelRequestResponse> ApproveCancelAsync(Guid cancelRequestId, Guid approvedBy, CancellationToken ct = default)
{
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
?? throw new KeyNotFoundException("Cancel request not found");
if (cancelRequest.Status != CancelRequestStatus.Pending)
throw new InvalidOperationException("Cancel request is not pending");
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.CancelRequested)
throw new InvalidOperationException("Ride is not in CancelRequested status");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
cancelRequest.Status = CancelRequestStatus.Approved;
cancelRequest.ApprovedBy = approvedBy;
cancelRequest.ApprovedAt = DateTime.Now;
ride.Status = RideStatus.Cancelled;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
return ToCancelResponse(cancelRequest);
}
public async Task<RideCancelRequestResponse> RejectCancelAsync(Guid cancelRequestId, Guid rejectedBy, CancellationToken ct = default)
{
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
?? throw new KeyNotFoundException("Cancel request not found");
if (cancelRequest.Status != CancelRequestStatus.Pending)
throw new InvalidOperationException("Cancel request is not pending");
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.CancelRequested)
throw new InvalidOperationException("Ride is not in CancelRequested status");
cancelRequest.Status = CancelRequestStatus.Rejected;
cancelRequest.ApprovedBy = rejectedBy;
cancelRequest.ApprovedAt = DateTime.Now;
// Restore to previous status: Active if ActivatedAt is set, else Open
ride.Status = ride.ActivatedAt.HasValue ? RideStatus.Active : RideStatus.Open;
await uow.BeginAsync(ct);
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToCancelResponse(cancelRequest);
}
public async Task<RideCancelRequestResponse?> GetCancelRequestAsync(Guid rideId)
{
var cancelRequest = await cancelRepo.GetByRideIdAsync(rideId);
return cancelRequest is null ? null : ToCancelResponse(cancelRequest);
}
public async Task<BreakdownAssistResponse> GetBreakdownAssistAsync(Guid rideId)
{
var strandedRide = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
var activeRides = await rideRepo.GetActiveRidesAsync(rideId);
var activeRideResponses = new List<BreakdownAssistRideResponse>();
foreach (var ride in activeRides)
{
var stockLevels = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
activeRideResponses.Add(new BreakdownAssistRideResponse(
ride.Id,
ride.FK_VehicleId,
ride.Vehicle?.NumberPlate ?? string.Empty,
ride.FK_DriverProfileId,
ride.DriverProfile?.User?.FullName ?? string.Empty,
ride.FK_RouteId,
ride.Route?.RouteName ?? string.Empty,
stockLevels.Select(b => new StockBalanceResponse(
b.FK_ItemId,
b.Item.Name,
b.LocationId,
b.LocationType,
b.Quantity,
b.UpdatedAt,
ride.Vehicle?.NumberPlate ?? string.Empty)).ToList()));
}
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
return new BreakdownAssistResponse(
strandedRide.Id,
activeRideResponses,
openOrders.Select(o => new BreakdownAssistOrderResponse(
o.Id,
o.StoreId,
o.StoreName,
o.OrderDate,
o.TotalAmount)).ToList());
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
@@ -145,9 +343,15 @@ public class RideService(
}
private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.Status,
new(r.Id, r.RideType, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
r.FK_RouteId, r.Route?.RouteName ?? string.Empty,
r.FK_SourceWarehouseId, r.SourceWarehouse?.Name ?? string.Empty,
r.Meterstart, r.Meterend,
r.StartedAt, r.ClosedAt, r.CreatedAt);
r.StartedAt, r.ActivatedAt, r.ClosedAt, r.CreatedAt);
private static RideCancelRequestResponse ToCancelResponse(RideCancelRequest c) =>
new(c.Id, c.FK_RideId, c.TypeCode, c.Reason, c.RequestedBy, c.RequestedAt,
c.Status, c.ApprovedBy, c.ApprovedAt);
}
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(rideId, page, pageSize));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+36
View File
@@ -0,0 +1,36 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items,
// TEMPORARY: auth middleware doesn't attach a resolvable user yet (see testbug.md #28), so the
// caller supplies the acting user explicitly. Remove once currentUser.UserId is reliable again.
Guid? CreatedByUserId = null);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,45 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
await context.Sales
.Where(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
await context.Sales
.CountAsync(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+274
View File
@@ -0,0 +1,274 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
{
IEnumerable<Guid>? storeIds = null;
if (!HasGlobalScope && currentUser.UserId is not null)
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
var total = await repo.GetCountAsync(rideId, storeIds);
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
{
// TEMPORARY: see CreateAsync — store-scope enforcement depends on the broken auth
// middleware (testbug.md #28) and would reject every cancel. Skipped for now.
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Sale is already cancelled");
sale.Status = SaleStatus.Cancelled;
await uow.BeginAsync(ct);
try { repo.Update(sale); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
// TEMPORARY: store-scope enforcement and currentUser resolution both depend on the broken
// auth middleware (see testbug.md #28) — currentUser.UserId/RoleLevel are always unset, so
// both checks would reject every sale. Skipped for now; restore once auth is fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId
?? throw new InvalidOperationException("CreatedByUserId is required while auth is unavailable");
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId)
?? throw new InvalidOperationException("Store not found");
if (!store.IsActive)
throw new InvalidOperationException("Store is inactive");
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold)
throw new InvalidOperationException(
$"Store KYC is not verified — sale amount ({request.TotalAmount}) exceeds the unverified-store threshold ({threshold})");
}
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
FulfillImmediately = request.FulfillImmediately
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate)
?? throw new InvalidOperationException(
$"Could not resolve a price for item {item.ItemId} on {request.SaleDate:yyyy-MM-dd} — check the item exists, an active price list covers this date/region, and any supplied unit price is within its min/max range");
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
UnitPrice = unitPrice
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
CreatedByUserId = userId,
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
// For a van sale against a specific ride, sell whatever quantity is actually on that
// vehicle instead of rejecting the whole sale over one short item — the sale is always a
// clean, completed sale of what's handed over, never a "partially fulfilled" order.
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value)
?? throw new InvalidOperationException("Ride not found");
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
item.Quantity = Math.Min(item.Quantity, (int)(balance?.Quantity ?? 0));
}
}
foreach (var item in orderItems)
item.QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0;
if (request.FulfillImmediately)
{
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
order.Status = OrderStatus.Fulfilled;
order.FulfilledAt = DateTime.Now;
}
else
{
sale.Status = SaleStatus.Pending;
order.Status = OrderStatus.Pending;
}
sale.TotalAmount = orderItems.Sum(i => i.Quantity * i.UnitPrice);
order.TotalAmount = sale.TotalAmount;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems.Where(i => i.QuantityFulfilled > 0))
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.QuantityFulfilled;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ public record SchemeEligibilityResponse(
public record SchemeRedemptionResponse(
Guid Id,
Guid SchemeId,
Guid? ShopPOId,
Guid? OrderId,
decimal? DiscountAmount,
Guid? FreeItemId,
string? FreeItemName,
@@ -42,15 +42,15 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
await context.SchemeRedemptions
.Include(r => r.FreeItem)
.Include(r => r.ShopPO)
.Include(r => r.Order)
.Where(r => r.FK_SchemeId == schemeId)
.ToListAsync();
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
await context.SchemeRedemptions.AddAsync(entity);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
+36 -36
View File
@@ -1,6 +1,6 @@
using System.Text.Json;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Order;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.UoW;
@@ -10,7 +10,7 @@ namespace Distribution.Features.Scheme;
public class SchemeService(
ISchemeRepository repo,
IShopPORepository shopPORepo,
IOrderRepository orderRepo,
IItemRepository itemRepo,
IStoreRepository storeRepo,
IStockRepository stockRepo,
@@ -27,7 +27,7 @@ public class SchemeService(
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
new(r.Id, r.FK_SchemeId, r.FK_ShopPOId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
// ── CRUD ───────────────────────────────────────────────────────────────
@@ -243,17 +243,17 @@ public class SchemeService(
return (await GetByIdAsync(schemeId))!;
}
// ── evaluateSchemeEligibility(storeId, shopPOId) ──────────────────────────
// ── evaluateSchemeEligibility(storeId, orderId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid shopPOId)
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var regionId = store.Route?.FK_RegionId;
var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive);
@@ -266,21 +266,21 @@ public class SchemeService(
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
continue;
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, items));
result.Add(EvaluateRule(scheme, orderItems));
}
return result;
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no ShopPO to evaluate line
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
/// (12_store_management.md), which has no order context. Returns null if the store or scheme
/// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store",
/// not "this ShopPO's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// </summary>
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
{
@@ -305,7 +305,7 @@ public class SchemeService(
return (true, null);
}
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
{
switch (scheme.RuleType)
{
@@ -316,10 +316,10 @@ public class SchemeService(
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"Scheme has no free-goods rule configured.", null, null, null);
var boughtQty = items.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
if (boughtQty < scheme.TriggerQty)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
$"Requires {scheme.TriggerQty} of the trigger item; ShopPO has {boughtQty}.", null, null, null);
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null);
var multiplier = boughtQty / scheme.TriggerQty.Value;
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
@@ -332,11 +332,11 @@ public class SchemeService(
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
var missing = scheme.BundleComponents
.Where(c => items.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.ToList();
if (missing.Count > 0)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"ShopPO is missing required bundle components.", null, null, null);
"Order is missing required bundle components.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
null, scheme.FK_FreeItemId, scheme.FreeQty);
@@ -346,7 +346,7 @@ public class SchemeService(
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = items.Sum(i => i.Quantity * i.UnitPrice);
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
}
@@ -355,12 +355,12 @@ public class SchemeService(
if (string.IsNullOrEmpty(scheme.RuleConfig))
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
var cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
var orderTotal = items.Sum(i => i.Quantity * i.UnitPrice);
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
if (tier is null)
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
"ShopPO total does not meet any slab threshold.", null, null, null);
"Order total does not meet any slab threshold.", null, null, null);
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
@@ -370,32 +370,32 @@ public class SchemeService(
}
}
// ── applySchemeDiscount(shopPOId, schemeId) ───────────────────────────────
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid shopPOId, Guid schemeId, CancellationToken ct = default)
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
if (await repo.HasRedemptionAsync(schemeId, orderId))
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var eligibility = EvaluateRule(scheme, items);
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var eligibility = EvaluateRule(scheme, orderItems);
if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "ShopPO is not eligible for this scheme.");
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
var redemption = new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_ShopPOId = shopPOId,
FK_OrderId = orderId,
DiscountAmount = eligibility.DiscountAmount
};
var newItems = new List<ShopPOItem>();
var newOrderItems = new List<OrderItem>();
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
{
@@ -406,9 +406,9 @@ public class SchemeService(
if (availableQty < eligibility.FreeQty.Value)
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
newItems.Add(new ShopPOItem
newOrderItems.Add(new OrderItem
{
FK_ShopPOId = shopPOId,
FK_OrderId = orderId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m
@@ -422,7 +422,7 @@ public class SchemeService(
await uow.BeginAsync(ct);
try
{
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
await repo.AddRedemptionAsync(redemption);
await uow.CommitAsync(ct);
}
@@ -457,8 +457,8 @@ public class SchemeService(
var spend = await GetSpendAsync(schemeId);
var actualSales = redemptions
.Where(r => r.ShopPO is not null)
.Select(r => r.ShopPO!)
.Where(r => r.Order is not null)
.Select(r => r.Order!)
.Distinct()
.Sum(o => o.TotalAmount);
@@ -1,18 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public interface IShopPORepository
{
Task<Models.ShopPO?> GetByIdAsync(Guid id);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed);
Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId);
Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId);
Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.ShopPO entity);
void Update(Models.ShopPO entity);
void Remove(Models.ShopPO entity);
Task AddItemsAsync(IEnumerable<Models.ShopPOItem> items);
Task ReplaceItemsAsync(Guid shopPOId, List<Models.ShopPOItem> newItems);
}
@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ShopPO;
[ApiController]
[Route("api/shop-pos")]
public class ShopPOController(ShopPOService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] bool? confirmed,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,24 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
public record ShopPOResponse(
Guid Id,
Guid StoreId,
string StoreName,
Guid RaisedById,
Guid? InvoiceId,
decimal TotalAmount,
ShopPOStatus Status,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? FulfilledAt,
List<ShopPOItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,91 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ShopPO;
public class ShopPORepository(AppDBContext context) : IShopPORepository
{
public async Task<Models.ShopPO?> GetByIdAsync(Guid id) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) =>
await context.ShopPOs
.Include(p => p.Store)
.Where(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)))
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) =>
await context.ShopPOs
.Include(p => p.Store)
.CountAsync(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)));
public async Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Items.Where(i => i.IsActive))
.Where(p => p.IsActive
&& p.ConfirmedAt != null
&& p.InvoiceId == null
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (shopId == null || p.FK_StoreId == shopId))
.ToListAsync();
public async Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId) =>
await context.ShopPOItems
.Include(i => i.Item)
.Where(i => i.FK_ShopPOId == shopPOId && i.IsActive)
.ToListAsync();
public async Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId) =>
await context.ShopPOs
.Include(p => p.Items.Where(i => i.IsActive))
.Where(p => p.FK_StoreId == storeId && p.IsActive)
.ToListAsync();
public async Task AddAsync(Models.ShopPO entity) =>
await context.ShopPOs.AddAsync(entity);
public void Update(Models.ShopPO entity) =>
context.ShopPOs.Update(entity);
public void Remove(Models.ShopPO entity)
{
entity.IsActive = false;
context.ShopPOs.Update(entity);
}
public async Task AddItemsAsync(IEnumerable<Models.ShopPOItem> items) =>
await context.ShopPOItems.AddRangeAsync(items);
public async Task ReplaceItemsAsync(Guid shopPOId, List<Models.ShopPOItem> newItems)
{
var current = await context.ShopPOItems
.Where(i => i.FK_ShopPOId == shopPOId && i.IsActive)
.ToListAsync();
foreach (var item in current)
{
item.IsActive = false;
context.ShopPOItems.Update(item);
}
await context.ShopPOItems.AddRangeAsync(newItems);
}
}
@@ -1,32 +0,0 @@
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ShopPO;
public class ShopPOService(
IShopPORepository repo,
IUnitOfWork uow)
{
private static ShopPOResponse ToResponse(Models.ShopPO p) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
p.IsActive, p.CreatedAt);
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed);
var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
return po is null ? null : ToResponse(po);
}
}
@@ -12,5 +12,5 @@ public interface IStoreRepository
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -48,8 +48,8 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
context.Stores.Update(entity);
}
public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
await context.ShopPOs
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
await context.Orders
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive)
.ToListAsync();
}
+1 -1
View File
@@ -268,6 +268,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
periodStart.ToString("yyyy-MM"),
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
}
}
@@ -1,8 +0,0 @@
namespace Distribution.Features.SysConfig;
public interface ISysConfigRepository
{
Task<Models.SysConfig?> GetByKeyAsync(string key);
Task<List<Models.SysConfig>> GetAllAsync();
Task UpsertAsync(Models.SysConfig entity);
}
@@ -1,53 +0,0 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SysConfig;
[ApiController]
[Route("api/sys-config")]
public class SysConfigController(SysConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Typed shortcut — must be declared before {key} to take precedence
[HttpGet("invoice-creation-mode")]
public async Task<IActionResult> GetInvoiceCreationMode()
{
var mode = await service.GetInvoiceCreationModeAsync();
return Ok(new { Mode = mode });
}
[HttpPut("invoice-creation-mode")]
public async Task<IActionResult> SetInvoiceCreationMode(SetInvoiceCreationModeRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SetAsync(SysConfigService.KeyInvoiceCreationMode, request.Mode.ToString(), userId, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("{key}")]
public async Task<IActionResult> GetByKey(string key)
{
var result = await service.GetByKeyAsync(key);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{key}")]
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SetAsync(key, request.Value, userId, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,9 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.SysConfig;
public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy);
public record SetSysConfigRequest(string Value);
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
@@ -1,29 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SysConfig;
public class SysConfigRepository(AppDBContext context) : ISysConfigRepository
{
public async Task<Models.SysConfig?> GetByKeyAsync(string key) =>
await context.SysConfigs.FindAsync(key);
public async Task<List<Models.SysConfig>> GetAllAsync() =>
await context.SysConfigs.OrderBy(c => c.Key).ToListAsync();
public async Task UpsertAsync(Models.SysConfig entity)
{
var existing = await context.SysConfigs.FindAsync(entity.Key);
if (existing is null)
{
await context.SysConfigs.AddAsync(entity);
}
else
{
existing.Value = entity.Value;
existing.UpdatedAt = entity.UpdatedAt;
existing.UpdatedBy = entity.UpdatedBy;
context.SysConfigs.Update(existing);
}
}
}
@@ -1,67 +0,0 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.SysConfig;
public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
{
public const string KeyInvoiceCreationMode = "InvoiceCreationMode";
public async Task<List<SysConfigResponse>> GetAllAsync()
{
var configs = await repo.GetAllAsync();
return configs.Select(ToResponse).ToList();
}
public async Task<SysConfigResponse?> GetByKeyAsync(string key)
{
var config = await repo.GetByKeyAsync(key);
return config is null ? null : ToResponse(config);
}
public async Task<SysConfigResponse> SetAsync(string key, string value, Guid userId, CancellationToken ct = default)
{
ValidateValue(key, value);
var config = new Models.SysConfig
{
Key = key,
Value = value,
UpdatedAt = DateTime.Now,
UpdatedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.UpsertAsync(config);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByKeyAsync(key);
return ToResponse(saved!);
}
/// <summary>
/// Returns the current InvoiceCreationMode, defaulting to ModeB if not configured.
/// ModeA = invoice auto-created on ShopPO confirmation.
/// ModeB = invoice created as a separate explicit step.
/// </summary>
public async Task<InvoiceCreationMode> GetInvoiceCreationModeAsync()
{
var config = await repo.GetByKeyAsync(KeyInvoiceCreationMode);
if (config is null) return InvoiceCreationMode.ModeB;
return Enum.TryParse<InvoiceCreationMode>(config.Value, out var mode) ? mode : InvoiceCreationMode.ModeB;
}
private static void ValidateValue(string key, string value)
{
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
throw new InvalidOperationException(
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<InvoiceCreationMode>())}.");
}
private static SysConfigResponse ToResponse(Models.SysConfig c) =>
new(c.Key, c.Value, c.UpdatedAt, c.UpdatedBy);
}
@@ -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 }
});
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,961 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class RefactorSalesToShopPOAndInvoice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_DeliveryRuns_Routes_FK_RouteId",
table: "DeliveryRuns");
migrationBuilder.DropForeignKey(
name: "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropForeignKey(
name: "FK_DeliveryTasks_Orders_FK_OrderId",
table: "DeliveryTasks");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheets_Warehouses_FK_SourceWarehouseId",
table: "LoadingSheets");
migrationBuilder.DropForeignKey(
name: "FK_Payments_Sales_FK_SaleId",
table: "Payments");
migrationBuilder.DropForeignKey(
name: "FK_Payments_Stores_FK_StoreId",
table: "Payments");
migrationBuilder.DropForeignKey(
name: "FK_SaleReturns_Orders_FK_OrderId",
table: "SaleReturns");
migrationBuilder.DropForeignKey(
name: "FK_SchemeRedemptions_Orders_FK_OrderId",
table: "SchemeRedemptions");
migrationBuilder.DropTable(
name: "OrderItems");
migrationBuilder.DropTable(
name: "Orders");
migrationBuilder.DropTable(
name: "Sales");
migrationBuilder.DropIndex(
name: "IX_Payments_FK_SaleId",
table: "Payments");
migrationBuilder.DropIndex(
name: "IX_LoadingSheets_FK_SourceWarehouseId",
table: "LoadingSheets");
migrationBuilder.DropIndex(
name: "IX_DeliveryRuns_FK_RouteId",
table: "DeliveryRuns");
migrationBuilder.DropIndex(
name: "IX_DeliveryRuns_FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_SaleId",
table: "Payments");
migrationBuilder.DropColumn(
name: "DispatchConfirmedAt",
table: "LoadingSheets");
migrationBuilder.DropColumn(
name: "DispatchConfirmedBy",
table: "LoadingSheets");
migrationBuilder.DropColumn(
name: "QuantityReceived",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "QuantityResolved",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "ActivatedAt",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_RouteId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "RideType",
table: "DeliveryRuns");
migrationBuilder.RenameColumn(
name: "FK_OrderId",
table: "SchemeRedemptions",
newName: "FK_ShopPOId");
migrationBuilder.RenameIndex(
name: "IX_SchemeRedemptions_FK_OrderId",
table: "SchemeRedemptions",
newName: "IX_SchemeRedemptions_FK_ShopPOId");
migrationBuilder.RenameColumn(
name: "FK_OrderId",
table: "SaleReturns",
newName: "FK_ShopPOId");
migrationBuilder.RenameIndex(
name: "IX_SaleReturns_FK_OrderId",
table: "SaleReturns",
newName: "IX_SaleReturns_FK_ShopPOId");
migrationBuilder.RenameColumn(
name: "FK_StoreId",
table: "Payments",
newName: "FK_InvoiceId");
migrationBuilder.RenameIndex(
name: "IX_Payments_FK_StoreId",
table: "Payments",
newName: "IX_Payments_FK_InvoiceId");
migrationBuilder.RenameColumn(
name: "ReceiptConfirmedBy",
table: "LoadingSheets",
newName: "ConfirmedBy");
migrationBuilder.RenameColumn(
name: "ReceiptConfirmedAt",
table: "LoadingSheets",
newName: "ConfirmedAt");
migrationBuilder.RenameColumn(
name: "FK_SourceWarehouseId",
table: "LoadingSheets",
newName: "PopulatedBy");
migrationBuilder.RenameColumn(
name: "FK_OrderId",
table: "DeliveryTasks",
newName: "FK_ShopPOId");
migrationBuilder.RenameIndex(
name: "IX_DeliveryTasks_FK_OrderId",
table: "DeliveryTasks",
newName: "IX_DeliveryTasks_FK_ShopPOId");
migrationBuilder.AddColumn<DateTime>(
name: "PopulatedAt",
table: "LoadingSheets",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "LoadingSheetItems",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<Guid>(
name: "FK_ShopPOItemId",
table: "LoadingSheetItems",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "FK_WarehouseId",
table: "LoadingSheetItems",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsActive",
table: "LoadingSheetItems",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "ShopPOs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
RaisedById = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
InvoiceId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ConfirmedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FulfilledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopPOs", x => x.Id);
table.ForeignKey(
name: "FK_ShopPOs_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Invoices",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
PaymentStatus = table.Column<int>(type: "int", nullable: false),
CreatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
PrintedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PrintedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FinalizedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalizedBy = 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_ShopPOs_FK_ShopPOId",
column: x => x.FK_ShopPOId,
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "LoadingSheetOrders",
columns: table => new
{
FK_LoadingSheetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_LoadingSheetOrders", x => new { x.FK_LoadingSheetId, x.FK_ShopPOId });
table.ForeignKey(
name: "FK_LoadingSheetOrders_LoadingSheets_FK_LoadingSheetId",
column: x => x.FK_LoadingSheetId,
principalTable: "LoadingSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_LoadingSheetOrders_ShopPOs_FK_ShopPOId",
column: x => x.FK_ShopPOId,
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "ShopPOItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ShopPOId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
QuantityFulfilled = table.Column<int>(type: "int", nullable: false),
UnitPrice = 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_ShopPOItems", x => x.Id);
table.ForeignKey(
name: "FK_ShopPOItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ShopPOItems_ShopPOs_FK_ShopPOId",
column: x => x.FK_ShopPOId,
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000001"),
column: "Status",
value: 2);
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000002"),
column: "Status",
value: 0);
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000001"),
columns: new[] { "CreatedAt", "FK_ShopPOItemId", "FK_WarehouseId", "IsActive" },
values: new object[] { new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), null, new Guid("27000000-0000-0000-0000-000000000001"), true });
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000002"),
columns: new[] { "CreatedAt", "FK_ShopPOItemId", "FK_WarehouseId", "IsActive" },
values: new object[] { new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), null, new Guid("27000000-0000-0000-0000-000000000001"), true });
migrationBuilder.UpdateData(
table: "LoadingSheets",
keyColumn: "Id",
keyValue: new Guid("35000000-0000-0000-0000-000000000001"),
columns: new[] { "ConfirmedAt", "ConfirmedBy", "PopulatedAt", "PopulatedBy", "Status" },
values: new object[] { new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), 1 });
migrationBuilder.UpdateData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"),
column: "FK_InvoiceId",
value: new Guid("34500000-0000-0000-0000-000000000001"));
migrationBuilder.InsertData(
table: "ShopPOs",
columns: new[] { "Id", "ConfirmedAt", "ConfirmedBy", "CreatedAt", "FK_StoreId", "FulfilledAt", "InvoiceId", "IsActive", "RaisedById", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34500000-0000-0000-0000-000000000001"), true, new Guid("29000000-0000-0000-0000-000000000002"), 2, 3000m },
{ new Guid("33000000-0000-0000-0000-000000000002"), null, null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("26000000-0000-0000-0000-000000000005"), null, null, true, new Guid("29000000-0000-0000-0000-000000000003"), 0, 1200m }
});
migrationBuilder.InsertData(
table: "Invoices",
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ShopPOId", "FinalizedAt", "FinalizedBy", "IsActive", "PaymentStatus", "PrintedAt", "PrintedBy", "Status", "TotalAmount" },
values: new object[] { new Guid("34500000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), true, 2, new DateTime(2026, 8, 25, 8, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), 2, 3000m });
migrationBuilder.InsertData(
table: "ShopPOItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_ShopPOId", "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.CreateIndex(
name: "IX_LoadingSheetItems_FK_ShopPOItemId",
table: "LoadingSheetItems",
column: "FK_ShopPOItemId");
migrationBuilder.CreateIndex(
name: "IX_LoadingSheetItems_FK_WarehouseId",
table: "LoadingSheetItems",
column: "FK_WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_Invoices_FK_ShopPOId",
table: "Invoices",
column: "FK_ShopPOId");
migrationBuilder.CreateIndex(
name: "IX_LoadingSheetOrders_FK_ShopPOId",
table: "LoadingSheetOrders",
column: "FK_ShopPOId");
migrationBuilder.CreateIndex(
name: "IX_ShopPOItems_FK_ItemId",
table: "ShopPOItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_ShopPOItems_FK_ShopPOId",
table: "ShopPOItems",
column: "FK_ShopPOId");
migrationBuilder.CreateIndex(
name: "IX_ShopPOs_FK_StoreId",
table: "ShopPOs",
column: "FK_StoreId");
migrationBuilder.AddForeignKey(
name: "FK_DeliveryTasks_ShopPOs_FK_ShopPOId",
table: "DeliveryTasks",
column: "FK_ShopPOId",
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems",
column: "FK_LoadingSheetId",
principalTable: "LoadingSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_ShopPOItems_FK_ShopPOItemId",
table: "LoadingSheetItems",
column: "FK_ShopPOItemId",
principalTable: "ShopPOItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_Warehouses_FK_WarehouseId",
table: "LoadingSheetItems",
column: "FK_WarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Payments_Invoices_FK_InvoiceId",
table: "Payments",
column: "FK_InvoiceId",
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_SaleReturns_ShopPOs_FK_ShopPOId",
table: "SaleReturns",
column: "FK_ShopPOId",
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_SchemeRedemptions_ShopPOs_FK_ShopPOId",
table: "SchemeRedemptions",
column: "FK_ShopPOId",
principalTable: "ShopPOs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_DeliveryTasks_ShopPOs_FK_ShopPOId",
table: "DeliveryTasks");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_ShopPOItems_FK_ShopPOItemId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_Warehouses_FK_WarehouseId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_Payments_Invoices_FK_InvoiceId",
table: "Payments");
migrationBuilder.DropForeignKey(
name: "FK_SaleReturns_ShopPOs_FK_ShopPOId",
table: "SaleReturns");
migrationBuilder.DropForeignKey(
name: "FK_SchemeRedemptions_ShopPOs_FK_ShopPOId",
table: "SchemeRedemptions");
migrationBuilder.DropTable(
name: "Invoices");
migrationBuilder.DropTable(
name: "LoadingSheetOrders");
migrationBuilder.DropTable(
name: "ShopPOItems");
migrationBuilder.DropTable(
name: "ShopPOs");
migrationBuilder.DropIndex(
name: "IX_LoadingSheetItems_FK_ShopPOItemId",
table: "LoadingSheetItems");
migrationBuilder.DropIndex(
name: "IX_LoadingSheetItems_FK_WarehouseId",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "PopulatedAt",
table: "LoadingSheets");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "FK_ShopPOItemId",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "FK_WarehouseId",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "IsActive",
table: "LoadingSheetItems");
migrationBuilder.RenameColumn(
name: "FK_ShopPOId",
table: "SchemeRedemptions",
newName: "FK_OrderId");
migrationBuilder.RenameIndex(
name: "IX_SchemeRedemptions_FK_ShopPOId",
table: "SchemeRedemptions",
newName: "IX_SchemeRedemptions_FK_OrderId");
migrationBuilder.RenameColumn(
name: "FK_ShopPOId",
table: "SaleReturns",
newName: "FK_OrderId");
migrationBuilder.RenameIndex(
name: "IX_SaleReturns_FK_ShopPOId",
table: "SaleReturns",
newName: "IX_SaleReturns_FK_OrderId");
migrationBuilder.RenameColumn(
name: "FK_InvoiceId",
table: "Payments",
newName: "FK_StoreId");
migrationBuilder.RenameIndex(
name: "IX_Payments_FK_InvoiceId",
table: "Payments",
newName: "IX_Payments_FK_StoreId");
migrationBuilder.RenameColumn(
name: "PopulatedBy",
table: "LoadingSheets",
newName: "FK_SourceWarehouseId");
migrationBuilder.RenameColumn(
name: "ConfirmedBy",
table: "LoadingSheets",
newName: "ReceiptConfirmedBy");
migrationBuilder.RenameColumn(
name: "ConfirmedAt",
table: "LoadingSheets",
newName: "ReceiptConfirmedAt");
migrationBuilder.RenameColumn(
name: "FK_ShopPOId",
table: "DeliveryTasks",
newName: "FK_OrderId");
migrationBuilder.RenameIndex(
name: "IX_DeliveryTasks_FK_ShopPOId",
table: "DeliveryTasks",
newName: "IX_DeliveryTasks_FK_OrderId");
migrationBuilder.AddColumn<Guid>(
name: "FK_SaleId",
table: "Payments",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "DispatchConfirmedAt",
table: "LoadingSheets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "DispatchConfirmedBy",
table: "LoadingSheets",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<decimal>(
name: "QuantityReceived",
table: "LoadingSheetItems",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "QuantityResolved",
table: "LoadingSheetItems",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ActivatedAt",
table: "DeliveryRuns",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "FK_RouteId",
table: "DeliveryRuns",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "FK_SourceWarehouseId",
table: "DeliveryRuns",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<int>(
name: "RideType",
table: "DeliveryRuns",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Sales",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FulfillImmediately = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
PaymentStatus = table.Column<int>(type: "int", nullable: false),
SaleDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sales", x => x.Id);
table.ForeignKey(
name: "FK_Sales_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Orders",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FulfilledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
OrderDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Orders", x => x.Id);
table.ForeignKey(
name: "FK_Orders_Sales_FK_SaleId",
column: x => x.FK_SaleId,
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Orders_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "OrderItems",
columns: table => new
{
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
QuantityFulfilled = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrderItems", x => new { x.FK_OrderId, x.FK_ItemId });
table.ForeignKey(
name: "FK_OrderItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_OrderItems_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000001"),
columns: new[] { "ActivatedAt", "FK_RouteId", "FK_SourceWarehouseId", "RideType", "Status" },
values: new object[] { new DateTime(2026, 8, 25, 8, 15, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 1, 5 });
migrationBuilder.UpdateData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000002"),
columns: new[] { "ActivatedAt", "FK_RouteId", "FK_SourceWarehouseId", "RideType", "Status" },
values: new object[] { new DateTime(2026, 8, 30, 9, 20, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000002"), 0, 2 });
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000001"),
columns: new[] { "QuantityReceived", "QuantityResolved" },
values: new object[] { 50m, null });
migrationBuilder.UpdateData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000002"),
columns: new[] { "QuantityReceived", "QuantityResolved" },
values: new object[] { 30m, null });
migrationBuilder.UpdateData(
table: "LoadingSheets",
keyColumn: "Id",
keyValue: new Guid("35000000-0000-0000-0000-000000000001"),
columns: new[] { "DispatchConfirmedAt", "DispatchConfirmedBy", "FK_SourceWarehouseId", "ReceiptConfirmedAt", "ReceiptConfirmedBy", "Status" },
values: new object[] { new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 14, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), 2 });
migrationBuilder.UpdateData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"),
columns: new[] { "FK_SaleId", "FK_StoreId" },
values: new object[] { new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001") });
migrationBuilder.InsertData(
table: "Sales",
columns: new[] { "Id", "CompletedAt", "CreatedAt", "CreatedByUserId", "FK_RideId", "FK_StoreId", "FulfillImmediately", "IsActive", "PaymentStatus", "SaleDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("32000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), false, true, 2, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 1, 3000m },
{ new Guid("32000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("31000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), false, true, 0, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "Orders",
columns: new[] { "Id", "CreatedAt", "FK_SaleId", "FK_StoreId", "FulfilledAt", "IsActive", "OrderDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), true, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 2, 3000m },
{ new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), null, true, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
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 }
});
migrationBuilder.CreateIndex(
name: "IX_Payments_FK_SaleId",
table: "Payments",
column: "FK_SaleId");
migrationBuilder.CreateIndex(
name: "IX_LoadingSheets_FK_SourceWarehouseId",
table: "LoadingSheets",
column: "FK_SourceWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryRuns_FK_RouteId",
table: "DeliveryRuns",
column: "FK_RouteId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryRuns_FK_SourceWarehouseId",
table: "DeliveryRuns",
column: "FK_SourceWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_OrderItems_FK_ItemId",
table: "OrderItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_Orders_FK_SaleId",
table: "Orders",
column: "FK_SaleId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Orders_FK_StoreId",
table: "Orders",
column: "FK_StoreId");
migrationBuilder.CreateIndex(
name: "IX_Sales_CreatedByUserId",
table: "Sales",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_RideId",
table: "Sales",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_StoreId",
table: "Sales",
column: "FK_StoreId");
migrationBuilder.AddForeignKey(
name: "FK_DeliveryRuns_Routes_FK_RouteId",
table: "DeliveryRuns",
column: "FK_RouteId",
principalTable: "Routes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId",
table: "DeliveryRuns",
column: "FK_SourceWarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_DeliveryTasks_Orders_FK_OrderId",
table: "DeliveryTasks",
column: "FK_OrderId",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems",
column: "FK_LoadingSheetId",
principalTable: "LoadingSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_LoadingSheets_Warehouses_FK_SourceWarehouseId",
table: "LoadingSheets",
column: "FK_SourceWarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Payments_Sales_FK_SaleId",
table: "Payments",
column: "FK_SaleId",
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Payments_Stores_FK_StoreId",
table: "Payments",
column: "FK_StoreId",
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_SaleReturns_Orders_FK_OrderId",
table: "SaleReturns",
column: "FK_OrderId",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_SchemeRedemptions_Orders_FK_OrderId",
table: "SchemeRedemptions",
column: "FK_OrderId",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -1,44 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddSysConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SysConfigs",
columns: table => new
{
Key = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Value = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_SysConfigs", x => x.Key);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.InsertData(
table: "SysConfigs",
columns: new[] { "Key", "UpdatedAt", "UpdatedBy", "Value" },
values: new object[] { "InvoiceCreationMode", new DateTime(2026, 9, 17, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "ModeB" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SysConfigs");
}
}
}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -4,7 +4,8 @@ namespace Distribution.Models
{
public class DeliveryRun : Template
{
public RideStatus Status { get; set; } = RideStatus.Open;
public RideType RideType { get; set; }
public RideStatus Status { get; set; } = RideStatus.Draft;
public Guid FK_VehicleId { get; set; }
[ForeignKey("FK_VehicleId")]
@@ -14,10 +15,19 @@ namespace Distribution.Models
[ForeignKey("FK_DriverProfileId")]
public DriverProfile DriverProfile { get; set; }
public Guid FK_RouteId { get; set; }
[ForeignKey("FK_RouteId")]
public Route Route { get; set; }
public Guid FK_SourceWarehouseId { get; set; }
[ForeignKey("FK_SourceWarehouseId")]
public Warehouse SourceWarehouse { get; set; }
public int Meterstart { get; set; }
public int Meterend { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? ActivatedAt { get; set; }
public DateTime? ClosedAt { get; set; }
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ namespace Distribution.Models
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public int Sequence { get; set; }
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
+14 -12
View File
@@ -3,14 +3,15 @@ namespace Distribution.Models
public enum LocationType
{ Warehouse, Vehicle, External }
public enum KycStatus { Pending,Verified, Rejected }
public enum KycStatus { Pending, Verified, Rejected }
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
public enum LoadingSheetStatus { Planned, Confirmed }
public enum RideType { Delivery, Sale }
public enum RideStatus { Open, Cancelled, Closed }
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
@@ -18,15 +19,22 @@ namespace Distribution.Models
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum SaleStatus { Pending, Completed, Cancelled }
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 }
public enum DeliveryTaskStatus { Pending, Delivered, PartiallyDelivered, Failed }
public enum DeliveryTaskStatus
{ Pending, Delivered, PartiallyDelivered, Failed}
public enum ReturnMode { CreditDeduction, ItemExchange }
@@ -39,10 +47,4 @@ namespace Distribution.Models
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
public enum SupplierClaimResolutionType { Replacement, Credit, Refund, PriceAdjustment }
/// <summary>
/// ModeA: Invoice auto-created when ShopPO is confirmed.
/// ModeB: Invoice created as a separate explicit step.
/// </summary>
public enum InvoiceCreationMode { ModeA, ModeB }
}
+13 -9
View File
@@ -4,23 +4,27 @@ namespace Distribution.Models
{
public class Invoice : Template
{
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public Guid CreatedBy { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public DateTime? PrintedAt { get; set; }
public Guid? PrintedBy { get; set; }
public Guid? PrintedByUserId { get; set; }
[ForeignKey("PrintedByUserId")]
public User? PrintedByUser { get; set; }
public DateTime? FinalizedAt { get; set; }
public Guid? FinalizedBy { 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; }
public ICollection<Payment> Payments { get; set; } = new List<Payment>();
}
}
+8 -6
View File
@@ -8,15 +8,17 @@ namespace Distribution.Models
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Planned;
public Guid FK_SourceWarehouseId { get; set; }
[ForeignKey("FK_SourceWarehouseId")]
public Warehouse SourceWarehouse { get; set; }
public Guid PopulatedBy { get; set; }
public DateTime PopulatedAt { get; set; }
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Pending;
public DateTime? ConfirmedAt { get; set; }
public Guid? ConfirmedBy { get; set; }
public DateTime? DispatchConfirmedAt { get; set; }
public Guid? DispatchConfirmedBy { get; set; }
public DateTime? ReceiptConfirmedAt { get; set; }
public Guid? ReceiptConfirmedBy { get; set; }
public List<LoadingSheetItem> Items { get; set; } = [];
public List<LoadingSheetOrder> Orders { get; set; } = [];
}
}
+5 -9
View File
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class LoadingSheetItem : Template
public class LoadingSheetItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FK_LoadingSheetId { get; set; }
[ForeignKey("FK_LoadingSheetId")]
public LoadingSheet LoadingSheet { get; set; }
@@ -12,14 +14,8 @@ namespace Distribution.Models
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public Guid FK_WarehouseId { get; set; }
[ForeignKey("FK_WarehouseId")]
public Warehouse Warehouse { get; set; }
public Guid? FK_ShopPOItemId { get; set; }
[ForeignKey("FK_ShopPOItemId")]
public ShopPOItem? ShopPOItem { get; set; }
public decimal QuantityDispatched { get; set; }
public decimal? QuantityReceived { get; set; }
public decimal? QuantityResolved { get; set; }
}
}
-15
View File
@@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class LoadingSheetOrder
{
public Guid FK_LoadingSheetId { get; set; }
[ForeignKey("FK_LoadingSheetId")]
public LoadingSheet LoadingSheet { get; set; }
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Order : Template
{
public Guid? FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
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; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal TotalAmount { get; set; }
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>();
}
}
@@ -1,17 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class ShopPOItem : Template
public class OrderItem : Template
{
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; }
+7 -3
View File
@@ -4,9 +4,13 @@ namespace Distribution.Models
{
public class Payment : Template
{
public Guid FK_InvoiceId { get; set; }
[ForeignKey("FK_InvoiceId")]
public Invoice Invoice { get; set; }
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal Amount { get; set; }
public PaymentMethod Method { get; set; }
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Sale : Template
{
public DateTime SaleDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public Guid? FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun? Ride { get; set; }
public decimal TotalAmount { get; set; }
public SaleStatus Status { get; set; } = SaleStatus.Pending;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public bool FulfillImmediately { get; set; }
public DateTime? CompletedAt { get; set; }
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ namespace Distribution.Models
/// </summary>
public class SaleReturn : Template
{
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public ReturnMode Mode { get; set; }
+3 -3
View File
@@ -10,9 +10,9 @@ namespace Distribution.Models
[ForeignKey("FK_SchemeId")]
public Scheme Scheme { get; set; }
public Guid? FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO? ShopPO { get; set; }
public Guid? FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order? Order { get; set; }
/// <summary>Placeholder for the future field-invoice module — no FieldInvoice entity exists yet.</summary>
public Guid? FieldInvoiceId { get; set; }
-25
View File
@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class ShopPO : Template
{
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid RaisedById { get; set; }
public Guid? InvoiceId { get; set; }
public decimal TotalAmount { get; set; }
public ShopPOStatus Status { get; set; } = ShopPOStatus.Pending;
public DateTime? ConfirmedAt { get; set; }
public Guid? ConfirmedBy { get; set; }
public DateTime? FulfilledAt { get; set; }
public ICollection<ShopPOItem> Items { get; set; } = new List<ShopPOItem>();
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace Distribution.Models
{
public class SysConfig
{
public string Key { get; set; }
public string Value { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.Now;
public Guid? UpdatedBy { get; set; }
}
}
+9 -6
View File
@@ -10,8 +10,8 @@ using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Invoice;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
using Distribution.Features.PurchaseReturn;
@@ -23,6 +23,7 @@ using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
using Distribution.Features.Role;
using Distribution.Features.Route;
using Distribution.Features.Sale;
using Distribution.Features.Scheme;
using Distribution.Features.Store;
using Distribution.Features.Store.Kyc;
@@ -32,7 +33,6 @@ using Distribution.Features.Uom;
using Distribution.Features.User;
using Distribution.Features.Vehicle;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Features.Warehouse;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
@@ -53,8 +53,8 @@ builder.Services.AddScoped<IRouteRepository, RouteRepository>();
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
@@ -66,6 +66,7 @@ builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>();
builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestRepository>();
builder.Services.AddScoped<ILoadingSheetRepository, LoadingSheetRepository>();
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
@@ -79,14 +80,15 @@ 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>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<ShopPOService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>();
@@ -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.
---
@@ -1,704 +0,0 @@
# Refactor Implementation Plan
**Status**: Ready to implement
**Design docs**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [loading_event.md](./loading_event.md), [dispatch_pipeline.md](./dispatch_pipeline.md), [ride_lifecycle.md](./ride_lifecycle.md)
---
## Scope
Replace the Sale/Order/LoadingSheet pipeline with a unified ShopPO → Invoice → LoadingSheet flow:
- `Order` entity renamed to `ShopPO` throughout
- `Sale` entity and feature removed entirely
- `Invoice` entity introduced as the billing document
- `Payment` moved from Sale to Invoice
- `LoadingSheet` overhauled: dispatcher-triggered, multi-PO, per-item warehouse
- `DeliveryRun` (Ride) simplified: no route, no type, no Active state
- `SaleReturn` FK renamed only — full redesign is a separate pass
---
## Execution Order
```
Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 (migration)
→ Phase 6, 7, 8, 9, 10 (independent after migration)
→ Phase 11 → Phase 12
```
Phases 6–10 have no dependencies on each other and can be worked in parallel.
---
## Phase 1 — Enums
**File**: `Models/Enums.cs`
| Enum | Action | New Values |
|---|---|---|
| `LoadingSheetStatus` | Replace all values | `Planned`, `Confirmed` |
| `RideStatus` | Remove `Draft`, `Active`, `CancelRequested` | Keep `Open`, `Cancelled`, `Closed` |
| `RideType` | **Delete entire enum** | — |
| `InvoiceStatus` | **Add new** | `Draft`, `Printed`, `Finalized` |
| `OrderStatus` | Rename to `ShopPOStatus` | Keep values: `Pending`, `PartiallyFulfilled`, `Fulfilled`, `Cancelled` |
| `SaleStatus` | **Delete entire enum** | — |
---
## Phase 2 — Remove Sale
Sale has no downstream model dependencies except `Order.FK_SaleId` and `Payment.FK_SaleId` — both handled in Phase 3.
### Delete files
- `Models/Sale.cs`
- `Features/Sale/SaleController.cs`
- `Features/Sale/SaleService.cs`
- `Features/Sale/ISaleRepository.cs`
- `Features/Sale/SaleRepository.cs`
- `Features/Sale/SaleDTOs.cs`
### Update `Data/AppDBContext.cs`
- Remove `DbSet<Sale> Sales`
- Remove all Fluent API config blocks referencing `Sale`
### Update `Program.cs`
- Remove `ISaleRepository` / `SaleRepository` registration
- Remove `SaleService` registration
---
## Phase 3 — Model Changes
### 3.1 Rename `Order` → `ShopPO`
Create `Models/ShopPO.cs`. Delete `Models/Order.cs`.
```csharp
public class ShopPO : Template
{
public Guid FK_StoreId { get; set; }
public Store Store { get; set; }
public Guid RaisedById { get; set; } // agent who created the PO
public Guid? InvoiceId { get; set; } // set when Invoice is created — NO FK constraint
public decimal TotalAmount { get; set; }
public ShopPOStatus Status { get; set; } = ShopPOStatus.Pending;
public DateTime? ConfirmedAt { get; set; } // null = Shop PO, set = Confirmed PO
public Guid? ConfirmedBy { get; set; }
public DateTime? FulfilledAt { get; set; }
public ICollection<ShopPOItem> Items { get; set; }
}
```
Removed from current `Order.cs`: `FK_SaleId`, `OrderDate` (use `CreatedAt` from Template).
### 3.2 Rename `OrderItem` → `ShopPOItem`
Create `Models/ShopPOItem.cs`. Delete `Models/OrderItem.cs`.
```csharp
public class ShopPOItem : Template // Template gives Id, IsActive, CreatedAt
{
public Guid FK_ShopPOId { get; set; }
public ShopPO ShopPO { get; set; }
public Guid FK_ItemId { get; set; }
public Item Item { get; set; }
public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; }
}
```
No property changes beyond the FK rename. The existing `IsActive` (from Template) already supports versioning.
### 3.3 Update `Models/LoadingSheet.cs`
Remove:
- `FK_SourceWarehouseId` and its navigation property
- `DispatchConfirmedAt`, `DispatchConfirmedBy`
- `ReceiptConfirmedAt`, `ReceiptConfirmedBy`
Add:
- `PopulatedBy` (Guid)
- `PopulatedAt` (DateTime)
- `ConfirmedAt` (DateTime?)
- `ConfirmedBy` (Guid?)
`FK_RideId` already exists — no change needed.
### 3.4 Update `Models/LoadingSheetItem.cs`
Make it inherit `Template` (adds `Id` PK, `IsActive`, `CreatedAt`).
Remove:
- `QuantityReceived`
- `QuantityResolved`
Add:
- `FK_WarehouseId` (Guid, required) — warehouse this item is loaded from
- `FK_ShopPOItemId` (Guid?, nullable) — traces back to source `ShopPOItem`
### 3.5 New `Models/LoadingSheetOrder.cs`
```csharp
public class LoadingSheetOrder
{
public Guid FK_LoadingSheetId { get; set; }
public LoadingSheet LoadingSheet { get; set; }
public Guid FK_ShopPOId { get; set; }
public ShopPO ShopPO { get; set; }
}
```
Composite PK on `(FK_LoadingSheetId, FK_ShopPOId)` — configured in DbContext.
### 3.6 Update `Models/DeliveryRun.cs`
Remove:
- `RideType` (enum deleted in Phase 1)
- `FK_RouteId` and its navigation property
- `FK_SourceWarehouseId` and its navigation property
- `ActivatedAt`
Keep: `FK_VehicleId`, `FK_DriverProfileId`, `Status`, `Meterstart`, `Meterend`, `StartedAt`, `ClosedAt`.
### 3.7 Update `Models/Payment.cs`
Remove:
- `FK_SaleId`
- `FK_StoreId` (derivable via Invoice → ShopPO → Store)
Add:
- `FK_InvoiceId` (Guid, required)
### 3.8 New `Models/Invoice.cs`
```csharp
public class Invoice : Template
{
public Guid FK_ShopPOId { get; set; }
public ShopPO ShopPO { get; set; }
public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public Guid CreatedBy { get; set; }
public DateTime? PrintedAt { get; set; }
public Guid? PrintedBy { get; set; }
public DateTime? FinalizedAt { get; set; }
public Guid? FinalizedBy { get; set; }
public decimal TotalAmount { get; set; }
public ICollection<Payment> Payments { get; set; }
}
```
### 3.9 FK Renames on Other Models
These models reference `Order` only — no Sale reference. Rename FK and navigation property only:
| File | Change |
|---|---|
| `Models/DeliveryTask.cs` | `FK_OrderId` → `FK_ShopPOId`, navigation `Order` → `ShopPO` |
| `Models/SchemeRedemption.cs` | `FK_OrderId` → `FK_ShopPOId`, navigation `Order` → `ShopPO` |
| `Models/SaleReturn.cs` | `FK_OrderId` → `FK_ShopPOId`, navigation `Order` → `ShopPO` |
---
## Phase 4 — DbContext (`Data/AppDBContext.cs`)
### DbSet changes
```csharp
// Remove
DbSet<Sale> Sales
DbSet<Order> Orders
DbSet<OrderItem> OrderItems
// Add
DbSet<ShopPO> ShopPOs
DbSet<ShopPOItem> ShopPOItems
DbSet<Invoice> Invoices
DbSet<LoadingSheetOrder> LoadingSheetOrders
```
### Fluent API changes
**`ShopPO`**
- `ShopPO.InvoiceId`: configure as plain column with no FK constraint — `builder.Property(x => x.InvoiceId).HasColumnName("InvoiceId")` (no `HasForeignKey`)
- `ShopPO` → `Store`: Restrict delete
- Remove previous unique index on `FK_SaleId`
**`ShopPOItem`**
- Remove unique composite index on `(FK_OrderId, FK_ItemId)` — versioning requires multiple rows with same combo
- `ShopPOItem` → `ShopPO`: Restrict delete
- `ShopPOItem` → `Item`: Restrict delete
**`Invoice`**
- `Invoice` → `ShopPO`: Restrict delete
- One-to-one: one ShopPO has at most one Invoice
**`LoadingSheetOrder`**
- Composite PK: `(FK_LoadingSheetId, FK_ShopPOId)`
- `LoadingSheetOrder` → `LoadingSheet`: Restrict delete
- `LoadingSheetOrder` → `ShopPO`: Restrict delete
**`LoadingSheet`**
- Remove: Warehouse relation for `FK_SourceWarehouseId`
**`LoadingSheetItem`**
- Add: `FK_WarehouseId` → Warehouse (Restrict delete)
- Add: `FK_ShopPOItemId` → ShopPOItem (SetNull — nullable FK)
**`Payment`**
- Remove: Sale relation for `FK_SaleId`, Store relation for `FK_StoreId`
- Add: `FK_InvoiceId` → Invoice (Restrict delete)
**`DeliveryRun`**
- Remove: Route relation for `FK_RouteId`, Warehouse relation for `FK_SourceWarehouseId`, RideType property
**`DeliveryTask`**, **`SchemeRedemption`**, **`SaleReturn`**
- Update FK navigation references from `Order` → `ShopPO`
---
## Phase 5 — Migration
One EF Core migration: `RefactorSalesToShopPOAndInvoice`
Key DDL generated:
```sql
-- ShopPO (was Orders table)
RENAME TABLE Orders TO ShopPOs;
ALTER TABLE ShopPOs DROP COLUMN FK_SaleId, DROP COLUMN OrderDate;
ALTER TABLE ShopPOs ADD RaisedById CHAR(36) NOT NULL,
InvoiceId CHAR(36) NULL,
ConfirmedAt DATETIME NULL,
ConfirmedBy CHAR(36) NULL;
ALTER TABLE ShopPOs RENAME COLUMN Status TO Status; -- enum value update handled by app
DROP INDEX IX_Orders_FK_SaleId ON ShopPOs;
-- ShopPOItems (was OrderItems table)
RENAME TABLE OrderItems TO ShopPOItems;
ALTER TABLE ShopPOItems RENAME COLUMN FK_OrderId TO FK_ShopPOId;
DROP INDEX IX_OrderItems_FK_OrderId_FK_ItemId ON ShopPOItems; -- remove unique constraint
-- LoadingSheets
ALTER TABLE LoadingSheets DROP COLUMN FK_SourceWarehouseId,
DROP COLUMN DispatchConfirmedAt, DROP COLUMN DispatchConfirmedBy,
DROP COLUMN ReceiptConfirmedAt, DROP COLUMN ReceiptConfirmedBy;
ALTER TABLE LoadingSheets ADD PopulatedBy CHAR(36) NOT NULL,
PopulatedAt DATETIME NOT NULL,
ConfirmedAt DATETIME NULL,
ConfirmedBy CHAR(36) NULL;
-- LoadingSheetItems
ALTER TABLE LoadingSheetItems DROP COLUMN QuantityReceived, DROP COLUMN QuantityResolved;
ALTER TABLE LoadingSheetItems ADD FK_WarehouseId CHAR(36) NOT NULL,
FK_ShopPOItemId CHAR(36) NULL,
IsActive TINYINT(1) NOT NULL DEFAULT 1,
CreatedAt DATETIME NOT NULL;
-- DeliveryRuns
ALTER TABLE DeliveryRuns DROP COLUMN RideType, DROP COLUMN FK_RouteId,
DROP COLUMN FK_SourceWarehouseId, DROP COLUMN ActivatedAt;
-- Payments
ALTER TABLE Payments DROP COLUMN FK_SaleId, DROP COLUMN FK_StoreId;
ALTER TABLE Payments ADD FK_InvoiceId CHAR(36) NOT NULL;
-- DeliveryTasks, SchemeRedemptions, SaleReturns
ALTER TABLE DeliveryTasks RENAME COLUMN FK_OrderId TO FK_ShopPOId;
ALTER TABLE SchemeRedemptions RENAME COLUMN FK_OrderId TO FK_ShopPOId;
ALTER TABLE SaleReturns RENAME COLUMN FK_OrderId TO FK_ShopPOId;
-- New tables
CREATE TABLE Invoices (...);
CREATE TABLE LoadingSheetOrders (FK_LoadingSheetId, FK_ShopPOId, PRIMARY KEY (FK_LoadingSheetId, FK_ShopPOId));
-- Drop Sale table
DROP TABLE Sales;
```
---
## Phase 6 — New Invoice Feature
**New folder**: `Features/Invoice/`
### `InvoiceDTOs.cs`
```csharp
// Mode B create
record CreateInvoiceRequest(Guid ShopPOId);
// Mode A — called internally from ShopPOService, no separate request needed
record InvoiceResponse(Guid Id, Guid ShopPOId, InvoiceStatus Status,
PaymentStatus PaymentStatus, Guid CreatedBy,
DateTime? PrintedAt, Guid? PrintedBy,
DateTime? FinalizedAt, Guid? FinalizedBy,
decimal TotalAmount, DateTime CreatedAt);
// Finalize payload — exact adjustment fields TBD (free issues handling open)
record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note);
```
### `IInvoiceRepository.cs`
- `GetByIdAsync(Guid id)`
- `GetByShopPOIdAsync(Guid shopPOId)`
- `GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)`
- `AddAsync(Invoice invoice)`
- `Update(Invoice invoice)`
### `InvoiceRepository.cs`
EF implementation of above.
### `InvoiceService.cs`
- `CreateAsync(Guid shopPOId, Guid createdBy)` — Mode B; validates ShopPO is Confirmed, no Invoice exists yet; sets `ShopPO.InvoiceId` in same transaction
- `PrintAsync(Guid id)` — `Draft → Printed`; sets `PrintedAt`/`PrintedBy`
- `FinalizeAsync(Guid id, FinalizeInvoiceRequest request)` — `Printed → Finalized`; applies adjustments, locks `TotalAmount`, sets `FinalizedAt`/`FinalizedBy`
### `InvoiceController.cs`
| Method | Route | Mode gate | Description |
|---|---|---|---|
| POST | `/api/invoices` | Mode B only | Create Invoice for a Confirmed ShopPO |
| GET | `/api/invoices` | Any | List with filters |
| GET | `/api/invoices/{id}` | Any | Get detail |
| POST | `/api/invoices/{id}/print` | Any | `Draft → Printed` |
| POST | `/api/invoices/{id}/finalize` | Any | `Printed → Finalized` |
---
## Phase 7 — ShopPO Feature (replaces Order)
**Delete**: `Features/Order/` (all files)
**New folder**: `Features/ShopPO/`
### `ShopPODTOs.cs`
```csharp
record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items); // versioned rewrite
record ShopPOResponse(Guid Id, Guid StoreId, Guid RaisedById,
Guid? InvoiceId, decimal TotalAmount, ShopPOStatus Status,
DateTime? ConfirmedAt, Guid? ConfirmedBy, DateTime? FulfilledAt,
List<ShopPOItemResponse> Items, bool IsActive, DateTime CreatedAt);
record ShopPOItemResponse(Guid Id, Guid ItemId, int Quantity,
int QuantityFulfilled, decimal UnitPrice, bool IsActive);
```
### `IShopPORepository.cs`
- `GetByIdAsync(Guid id)` — active items only by default
- `GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)`
- `GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)` — used by Mode B invoice creation and LoadingSheet population
- `AddAsync(ShopPO po)`
- `ReplaceItemsAsync(Guid shopPOId, List<ShopPOItem> newItems)` — soft-delete current IsActive rows, insert fresh set
- `Update(ShopPO po)`
### `ShopPOService.cs`
- `CreateAsync(CreateShopPORequest request)` — creates ShopPO with `ConfirmedAt = null`; resolves item prices via `PriceListService`
- `UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request)` — only while `ConfirmedAt == null`; calls versioned replace
- `ConfirmAsync(Guid id)` — Mode B: sets `ConfirmedAt`/`ConfirmedBy`; locks items
- `ConfirmAndCreateInvoiceAsync(Guid id)` — Mode A: confirm + create Invoice atomically in one transaction; sets `ShopPO.InvoiceId`
- `CancelAsync(Guid id)` — sets `Status = Cancelled`; throws if linked Invoice exists (cascade rules not yet defined — see Open Items)
- `GetAllAsync(...)`, `GetByIdAsync(...)`
### `ShopPOController.cs`
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/shop-pos` | Agent | Create Shop PO |
| GET | `/api/shop-pos` | Any | List (filter: agentId, routeId, shopId, confirmed) |
| GET | `/api/shop-pos/{id}` | Any | Get detail |
| PUT | `/api/shop-pos/{id}/items` | Agent | Versioned item rewrite (Shop PO only) |
| POST | `/api/shop-pos/{id}/confirm` | Office | Mode B — confirm PO only |
| POST | `/api/shop-pos/{id}/confirm-and-create-invoice` | Office | Mode A — atomic confirm + invoice |
| POST | `/api/shop-pos/{id}/cancel` | Agent/Office | Cancel Shop PO or Confirmed PO |
Mode A endpoint returns `405 Method Not Allowed` when `InvoiceCreationMode = "B"` and vice versa.
---
## Phase 8 — Update Payment Feature
### `Models/Payment.cs`
Already updated in Phase 3 (`FK_InvoiceId`, removed `FK_SaleId` and `FK_StoreId`).
### `PaymentDTOs.cs`
```csharp
// Before: CreatePaymentRequest(Amount, Method) against saleId in route
// After: CreatePaymentRequest(Amount, Method) against invoiceId in route
record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
record PaymentResponse(Guid Id, Guid InvoiceId, decimal Amount,
PaymentMethod Method, DateTime CollectedAt, Guid CollectedBy);
```
### `IPaymentRepository.cs` / `PaymentRepository.cs`
- Replace `GetBySaleIdAsync` → `GetByInvoiceIdAsync(Guid invoiceId)`
- `AddAsync`, `Update` unchanged
### `PaymentService.cs`
On `RecordPaymentAsync(Guid invoiceId, CreatePaymentRequest request)`:
1. Validate Invoice exists and is not `Cancelled`/voided
2. Add `Payment` record with `FK_InvoiceId`
3. Sum all payments for this Invoice
4. Recalculate `Invoice.PaymentStatus`:
- `sum == 0` → `Pending`
- `sum < Invoice.TotalAmount` → `PartiallyPaid`
- `sum >= Invoice.TotalAmount` → `Paid`
5. Update `Invoice.PaymentStatus` in same transaction
### `PaymentController.cs`
Change route from `/api/sales/{saleId}/payments` → `/api/invoices/{invoiceId}/payments`
| Method | Route | Description |
|---|---|---|
| POST | `/api/invoices/{invoiceId}/payments` | Record payment against invoice |
| GET | `/api/invoices/{invoiceId}/payments` | List payments for invoice |
---
## Phase 9 — Overhaul LoadingEvent Feature
Near-complete rewrite of service and controller. See `loading_event.md` for full state machine.
### `LoadingSheetDTOs.cs` — replace with
```csharp
record PopulateRequest(Guid RideId, Guid? RouteId, Guid? AgentId, Guid? ShopId);
record UpdateLoadingSheetItemsRequest(List<LoadingSheetItemInput> Items);
record LoadingSheetItemInput(Guid ItemId, Guid WarehouseId, decimal Quantity, Guid? ShopPOItemId = null);
record LoadingSheetResponse(Guid Id, Guid RideId, LoadingSheetStatus Status,
Guid PopulatedBy, DateTime PopulatedAt,
DateTime? ConfirmedAt, Guid? ConfirmedBy,
List<Guid> SourceShopPOIds,
List<LoadingSheetItemResponse> Items,
DateTime CreatedAt);
record LoadingSheetItemResponse(Guid Id, Guid ItemId, Guid WarehouseId,
decimal QuantityDispatched, Guid? ShopPOItemId, bool IsActive);
```
### `ILoadingSheetRepository.cs` / `LoadingSheetRepository.cs` — replace with
- `PopulateAsync(PopulateRequest request, Guid populatedBy)` — finds all Confirmed ShopPOs matching filters, creates one `LoadingSheet` at `Planned`, populates `LoadingSheetItem` rows from active `ShopPOItem` lines, inserts `LoadingSheetOrder` rows
- `GetAllAsync(Guid? routeId, Guid? agentId, Guid? shopId, Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)` — `routeId` filter joins via `Store.FK_RouteId → ShopPO.FK_StoreId → LoadingSheetOrder.FK_ShopPOId`
- `GetByIdAsync(Guid id, bool includeHistory = false)` — `includeHistory = true` includes `IsActive = false` items
- `ReplaceItemsAsync(Guid sheetId, List<LoadingSheetItemInput> items)` — soft-delete all current `IsActive = true` rows, insert fresh set
- `ConfirmAsync(Guid id, Guid confirmedBy)` — `Planned → Confirmed`; creates stock movements in transaction
### `LoadingSheetService.cs` — replace with
- `PopulateAsync(PopulateRequest request)` — validates ride is `Open`; at least one filter provided; all matching ShopPOs are Confirmed; delegates to repository
- `UpdateItemsAsync(Guid id, UpdateLoadingSheetItemsRequest request)` — validates sheet is `Planned`; calls versioned replace
- `ConfirmAsync(Guid id)` — validates sheet is `Planned`; creates stock movements: one `Load` debit per `LoadingSheetItem.FK_WarehouseId`, one credit to ride's vehicle (`LoadingSheet.FK_RideId → DeliveryRun.FK_VehicleId`); all in one transaction; transitions to `Confirmed`
### `LoadingSheetController.cs` — replace endpoints
| Remove | Add |
|---|---|
| `POST /` | `POST /populate` |
| `POST /{id}/confirm-dispatch` | `POST /{id}/confirm` |
| `PUT /{id}/receipt-items` | *(removed)* |
| `POST /{id}/confirm-receipt` | *(removed)* |
| `POST /{id}/resolve` | *(removed)* |
| `PATCH /{id}/dispatch` | *(removed)* |
| `PATCH /{id}/receipt` | *(removed)* |
| `PATCH /{id}/resolve` | *(removed)* |
| `GET /` | `GET /` (add routeId, agentId, shopId filters) |
| `GET /{id}` | `GET /{id}` (add `?history=true`) |
| `PUT /{id}/items` | `PUT /{id}/items` (versioned, Planned only) |
---
## Phase 10 — Overhaul Ride Feature
See `ride_lifecycle.md` for full state machine. Ride now starts `Open` immediately on creation.
### `RideDTOs.cs`
```csharp
// Remove: RouteId, SourceWarehouseId, RideType from request and response
record CreateRideRequest(Guid VehicleId, Guid DriverProfileId, int Meterstart);
record RideResponse(Guid Id, Guid VehicleId, Guid DriverProfileId,
RideStatus Status, int Meterstart, int? Meterend,
DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt);
```
### `RideService.cs` — replace state machine
| Remove | Replace with |
|---|---|
| `CreateAsync` (creates Draft) | `CreateAsync` (creates at `Open` directly) |
| `OpenAsync` (Draft → Open) | *(merged into create)* |
| `ActivateAsync` (Open → Active) | *(removed)* |
| `RaiseCancelRequestAsync` | *(removed — no approval flow)* |
| `ApproveCancelAsync` | *(removed)* |
| `RejectCancelAsync` | *(removed)* |
| `GetCancelRequestsAsync` | *(removed)* |
| `GetCancelRequestAsync` | *(removed)* |
| `GetBreakdownAssistAsync` | *(removed)* |
| `CloseAsync` (Active → Closed) | `CloseAsync` (Open → Closed) |
| *(no cancel)* | `CancelAsync` (Open → Cancelled, direct) |
`AssignDriverAsync` — keep, remove Draft-status guard.
### `RideController.cs`
Remove endpoints: open, activate, cancel-request, approve-cancel, reject-cancel, breakdown-assist.
Add endpoint: `POST /{id}/cancel`.
### FK renames in other features
- `Features/DeliveryRide/DeliveryTaskService.cs` — rename `FK_OrderId` → `FK_ShopPOId` references; update Active status guard to `Open` (**note**: full delivery redesign is deferred — minimum change only)
- `Features/Returns/ReturnService.cs` — rename `FK_OrderId` → `FK_ShopPOId` references only (full redesign deferred)
---
## Phase 11 — Program.cs + Config
### `Program.cs`
```csharp
// Remove
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestRepository>(); // approval flow removed
// Add
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
builder.Services.AddScoped<IShopPOService, ShopPOService>();
// Update (rename existing registrations)
// IOrderRepository → IShopPORepository (above replaces it)
```
### `appsettings.json` / `appsettings.Development.json`
```json
{
"InvoiceCreationMode": "B"
}
```
Read in `ShopPOController` via `IConfiguration` to gate Mode A vs Mode B endpoints.
---
## Phase 12 — Permission Seeding
Add two permission records (seed migration or data seeder):
| Code | Description | Role |
|---|---|---|
| `loading.populate` | Trigger loading sheet population; edit items | Dispatcher |
| `loading.confirm` | Confirm loading sheet; commits stock movements | TBD (see Open Items) |
---
## File Change Summary
### New files
```
Models/ShopPO.cs
Models/ShopPOItem.cs
Models/Invoice.cs
Models/LoadingSheetOrder.cs
Features/ShopPO/ShopPOController.cs
Features/ShopPO/ShopPOService.cs
Features/ShopPO/IShopPORepository.cs
Features/ShopPO/ShopPORepository.cs
Features/ShopPO/ShopPODTOs.cs
Features/Invoice/InvoiceController.cs
Features/Invoice/InvoiceService.cs
Features/Invoice/IInvoiceRepository.cs
Features/Invoice/InvoiceRepository.cs
Features/Invoice/InvoiceDTOs.cs
```
### Deleted files
```
Models/Sale.cs
Models/Order.cs
Models/OrderItem.cs
Features/Sale/SaleController.cs
Features/Sale/SaleService.cs
Features/Sale/ISaleRepository.cs
Features/Sale/SaleRepository.cs
Features/Sale/SaleDTOs.cs
Features/Order/OrderController.cs
Features/Order/OrderService.cs
Features/Order/IOrderRepository.cs
Features/Order/OrderRepository.cs
Features/Order/OrderDTOs.cs
```
### Modified files
```
Models/Enums.cs
Models/LoadingSheet.cs
Models/LoadingSheetItem.cs
Models/DeliveryRun.cs
Models/Payment.cs
Models/DeliveryTask.cs (FK rename only)
Models/SchemeRedemption.cs (FK rename only)
Models/SaleReturn.cs (FK rename only)
Data/AppDBContext.cs
Features/LoadingEvent/LoadingSheetService.cs
Features/LoadingEvent/LoadingSheetController.cs
Features/LoadingEvent/ILoadingSheetRepository.cs
Features/LoadingEvent/LoadingSheetRepository.cs
Features/LoadingEvent/LoadingSheetDTOs.cs
Features/Payment/PaymentService.cs
Features/Payment/PaymentController.cs
Features/Payment/IPaymentRepository.cs
Features/Payment/PaymentRepository.cs
Features/Payment/PaymentDTOs.cs
Features/Ride/RideService.cs
Features/Ride/RideController.cs
Features/Ride/RideDTOs.cs
Features/DeliveryRide/DeliveryTaskService.cs (FK rename + Active→Open guard)
Features/Returns/ReturnService.cs (FK rename only)
Program.cs
appsettings.json
appsettings.Development.json
```
---
## Open Items (must resolve before coding the relevant phase)
| # | Item | Blocks |
|---|---|---|
| 1 | Who holds `loading.confirm` permission? | Phase 9 — ConfirmAsync auth guard |
| 2 | Cascade when Confirmed ShopPO is cancelled and Invoice already exists | Phase 7 — CancelAsync |
| 3 | Cascade when Confirmed ShopPO is cancelled and it's in a LoadingSheet | Phase 7 — CancelAsync |
| 4 | `FinalizeInvoiceRequest` payload — free issues field shape | Phase 6 — FinalizeAsync |
| 5 | Who can confirm a ShopPO? (agent, office, either?) | Phase 7 — ConfirmAsync permission |
---
## Deferred (not in this refactor)
- `SaleReturn` / `CreditNote` — full redesign is a separate pass
- `DeliveryTask` full redesign (Stage 5 — Delivery) — separate pass
- Vehicle stock reconciliation at ride Close/Cancel — separate pass
- Order attachment mechanism for delivery tasks — separate pass