Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 919c76746d | |||
| 76c08855e4 | |||
| 1ed78b9932 | |||
| 62de2ddce1 | |||
| 8bc895cbc5 | |||
| 792d7c0140 | |||
| 54931ef110 | |||
| 8575034714 | |||
| 710fc15253 | |||
| db24c66d77 | |||
| 0cd4508d50 | |||
| c8f86163f7 | |||
| 02c673328e | |||
| c137aae7a5 | |||
| e3d4837379 | |||
| de5dcf99b3 | |||
| 2b68e2cc04 | |||
| faaa1fe6a5 | |||
| f42809a4e3 | |||
| 4b56443d1a | |||
| 72f850ea4d | |||
| 9f04242dc7 | |||
| d06d434143 | |||
| 248129900d | |||
| ea8f952f14 | |||
| 4d63b3e720 | |||
| 2f9661fafc | |||
| 7d740efabb | |||
| 222279b478 | |||
| 70fd4a9150 | |||
| 15b3e43fe8 | |||
| 8fd31a9b20 |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build *)",
|
||||
"PowerShell(Get-Command mysql -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(Get-Command mysql.exe -ErrorAction SilentlyContinue)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6c40421b2d23630d4249841425ec7906d93d5985")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
|
||||
00c7acd66c7d7d5bdbb8d4468924c7202fbdc15259175676556b482252622db8
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ce3b0b0b2776e1f5cfcf61d517b9f6ce1c89ed303e7e35ab0b7261f5686d0ddb
|
||||
b5689108dc90f1afbeded25902110c1295d20ccadf0aaae3dfd42fcd421eba23
|
||||
|
||||
@@ -125,3 +125,13 @@ C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\n
|
||||
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
|
||||
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.genruntimeconfig.cache
|
||||
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\.msCoverageSourceRootsMapping_Distribution.Tests
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\CoverletSourceRootsMapping_Distribution.Tests
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.AssemblyReference.cache
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.GeneratedMSBuildEditorConfig.editorconfig
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfoInputs.cache
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfo.cs
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.CoreCompileInputs.cache
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.dll
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\refint\Distribution.Tests.dll
|
||||
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
|
||||
|
||||
+114
-147
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+480
-360
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOrderInvoiceLifecycle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Must run before DropPrimaryKey below: OrderItems.FK_OrderId has a foreign key to
|
||||
// Orders, and MySQL requires a supporting index on that column at all times. The old
|
||||
// composite PK (FK_OrderId, FK_ItemId) was providing that support; without this index
|
||||
// created first, dropping the PK leaves FK_OrderId's constraint without an index and
|
||||
// MySQL rejects the DROP PRIMARY KEY (errno 150).
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrderItems_FK_OrderId",
|
||||
table: "OrderItems",
|
||||
column: "FK_OrderId");
|
||||
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_OrderItems",
|
||||
table: "OrderItems");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
|
||||
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_SaleId",
|
||||
table: "Orders",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)")
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ConfirmedAt",
|
||||
table: "Orders",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ConfirmedByUserId",
|
||||
table: "Orders",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CreatedByUserId",
|
||||
table: "Orders",
|
||||
type: "char(36)",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
// Backfill every existing Order's new CreatedByUserId from its linked Sale (Order.FK_SaleId
|
||||
// was required until this migration, so every pre-existing row has one). Covers every
|
||||
// row generically, not just the two rows seeded via HasData below — otherwise any Order
|
||||
// created outside the EF seed data would keep the temporary AddColumn default
|
||||
// (Guid.Empty) and fail the FK_Orders_Users_CreatedByUserId constraint added at the end
|
||||
// of this migration.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE `Orders` o
|
||||
INNER JOIN `Sales` s ON s.`Id` = o.`FK_SaleId`
|
||||
SET o.`CreatedByUserId` = s.`CreatedByUserId`
|
||||
WHERE o.`FK_SaleId` IS NOT NULL;");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "InvoiceId",
|
||||
table: "Orders",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_OrderItems",
|
||||
table: "OrderItems",
|
||||
column: "Id");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Invoices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
PrintedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
PrintedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
FinalizedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
FinalizedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Invoices", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Invoices_Orders_FK_OrderId",
|
||||
column: x => x.FK_OrderId,
|
||||
principalTable: "Orders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Invoices_Users_CreatedByUserId",
|
||||
column: x => x.CreatedByUserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Invoices_Users_FinalizedByUserId",
|
||||
column: x => x.FinalizedByUserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Invoices_Users_PrintedByUserId",
|
||||
column: x => x.PrintedByUserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrderItems",
|
||||
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_OrderId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("34000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
|
||||
{ new Guid("34000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), true, 24, 24, 50m },
|
||||
{ new Guid("34000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), true, 10, 0, 120m }
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Orders",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
|
||||
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000002"), null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Orders",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "ConfirmedAt", "ConfirmedByUserId", "CreatedByUserId", "InvoiceId" },
|
||||
values: new object[] { null, null, new Guid("29000000-0000-0000-0000-000000000003"), null });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Orders_ConfirmedByUserId",
|
||||
table: "Orders",
|
||||
column: "ConfirmedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Orders_CreatedByUserId",
|
||||
table: "Orders",
|
||||
column: "CreatedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_CreatedByUserId",
|
||||
table: "Invoices",
|
||||
column: "CreatedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_FinalizedByUserId",
|
||||
table: "Invoices",
|
||||
column: "FinalizedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_FK_OrderId",
|
||||
table: "Invoices",
|
||||
column: "FK_OrderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Invoices_PrintedByUserId",
|
||||
table: "Invoices",
|
||||
column: "PrintedByUserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Orders_Users_ConfirmedByUserId",
|
||||
table: "Orders",
|
||||
column: "ConfirmedByUserId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Orders_Users_CreatedByUserId",
|
||||
table: "Orders",
|
||||
column: "CreatedByUserId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Orders_Users_ConfirmedByUserId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Orders_Users_CreatedByUserId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Invoices");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Orders_ConfirmedByUserId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Orders_CreatedByUserId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_OrderItems",
|
||||
table: "OrderItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_OrderItems_FK_OrderId",
|
||||
table: "OrderItems");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34000000-0000-0000-0000-000000000002"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "OrderItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34000000-0000-0000-0000-000000000003"));
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConfirmedAt",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConfirmedByUserId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedByUserId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceId",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_SaleId",
|
||||
table: "Orders",
|
||||
type: "char(36)",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)",
|
||||
oldNullable: true)
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_OrderItems",
|
||||
table: "OrderItems",
|
||||
columns: new[] { "FK_OrderId", "FK_ItemId" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrderItems",
|
||||
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
|
||||
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
-4026
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
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user