Compare commits

...

9 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna 93f2f1bd3b Refactor sales pipeline: ShopPO, Invoice, SysConfig models
Major refactor replacing Sale/Order with ShopPO → Invoice → LoadingSheet flow. Removed legacy Sale/Order models and features; introduced ShopPO, ShopPOItem, Invoice, LoadingSheetOrder, and SysConfig models. Updated related entities, enums, repositories, and controllers. Added new migrations for schema changes and system config. Overhauled business logic and DI registrations. Updated documentation with migration plan.
2026-09-17 10:03:22 +05:30
Kalana B. Thilakarathna 6c40421b2d Revamp dispatch pipeline and PO cancellation docs
Updated documentation to clarify and redesign dispatch pipeline, loading event, and order/invoice lifecycle. Added PO cancellation flow for both Shop and Confirmed POs, introduced new API endpoint for cancellation, and simplified loading sheet model and endpoints. Added new permissions for loading operations, improved auditability, and detailed migration steps. Highlighted open questions on cancellation cascades, permissions, and vehicle capacity checks for future resolution.
2026-09-16 11:30:21 +05:30
Kalana B. Thilakarathna 541f59b15d Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-09-16 10:57:40 +05:30
Kalana B. Thilakarathna b68e5043c7 refactor update 2026-09-16 10:57:31 +05:30
DeepnaPooja 436f7c10a0 Merge pull request 'Add invoice collection plan for cash, cheque, and credit payments post-finalization' (#24) from fixes into dev
Reviewed-on: #24
2026-09-16 05:12:49 +00:00
Kalana B. Thilakarathna 8739e8e1a7 refactor 2026-09-16 10:41:16 +05:30
Kalana B. Thilakarathna 308be2f5ce refactor invoice and po life cycle 2026-09-16 10:39:42 +05:30
DeepnaPooja ca2507a904 Merge pull request 'Refactor order invoice lifecycle and ride lifecycle documentation' (#22) from fixes into dev
Reviewed-on: #22
2026-09-14 06:28:31 +00:00
DeepnaPooja 307055f8e1 Merge pull request 'Fixes' (#21) from fixes into dev
Reviewed-on: #21
2026-09-10 10:30:04 +00:00
86 changed files with 11735 additions and 2865 deletions
+149 -66
View File
@@ -17,9 +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<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { 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; }
@@ -61,11 +62,11 @@ 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)
{
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
// --- Composite PKs ---
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -79,11 +80,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetOrder>()
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
// --- Unique indexes ---
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
@@ -93,14 +93,20 @@ 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()
@@ -108,11 +114,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
// --- DeliveryRun ---
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile)
@@ -120,30 +122,97 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
// --- ShopPO ---
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
.WithMany()
.HasForeignKey(s => s.FK_StoreId)
.HasForeignKey(p => p.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(s => s.CreatedByUserId)
// 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<Sale>()
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.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>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict)
.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)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
// --- SchemeRedemption ---
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
@@ -152,10 +221,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Order)
.HasOne(r => r.ShopPO)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
.HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
@@ -163,6 +233,8 @@ 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()
@@ -191,23 +263,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
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);
// --- PurchaseOrder ---
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
@@ -227,6 +283,11 @@ 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()
@@ -251,6 +312,14 @@ 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()
@@ -269,22 +338,15 @@ 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.Order)
.HasOne(r => r.ShopPO)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
@@ -308,12 +370,16 @@ 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()
@@ -366,6 +432,8 @@ 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()
@@ -373,11 +441,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Order)
.HasOne(t => t.ShopPO)
.WithMany()
.HasForeignKey(t => t.FK_OrderId)
.HasForeignKey(t => t.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
// --- Regional ---
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -400,6 +470,19 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
.HasKey(c => c.Key);
modelBuilder.Entity<SysConfig>()
.Property(c => c.Key)
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
+3 -3
View File
@@ -80,15 +80,15 @@ public static class CommerceSeed
);
modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, 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_ShopPOId = OperationsDataSeed.ShopPO1Id, 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_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 }
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 }
);
modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<SaleReturnItem>().HasData(
+54 -72
View File
@@ -8,15 +8,17 @@ 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 Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-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 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 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 LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -31,16 +33,12 @@ 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
@@ -48,86 +46,71 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ActiveDeliveryRunId,
RideType = RideType.Delivery,
Status = RideStatus.Active,
Status = RideStatus.Open,
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<Sale>().HasData(
new Sale
modelBuilder.Entity<ShopPO>().HasData(
new ShopPO
{
Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
Id = ShopPO1Id,
FK_StoreId = MasterDataSeed.FortStoreId,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId,
RaisedById = MasterDataSeed.SalesRep1UserId,
InvoiceId = Invoice1Id,
TotalAmount = 3000m,
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,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Order
new ShopPO
{
Id = Order2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
Id = ShopPO2Id,
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
TotalAmount = 1200m,
Status = OrderStatus.Pending,
Status = ShopPOStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 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<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<LoadingSheet>().HasData(
@@ -135,20 +118,19 @@ public static class OperationsDataSeed
{
Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId,
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,
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,
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, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
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 }
);
modelBuilder.Entity<DeliveryTask>().HasData(
@@ -156,7 +138,7 @@ public static class OperationsDataSeed
{
Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId,
FK_OrderId = Order2Id,
FK_ShopPOId = ShopPO2Id,
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_OrderId,
Guid FK_ShopPOId,
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.Order)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.Order)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
@@ -1,6 +1,5 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -10,23 +9,22 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IShopPORepository shopPORepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.FK_ShopPOId,
t.ShopPO.FK_StoreId,
t.ShopPO.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
@@ -34,52 +32,8 @@ public class DeliveryTaskService(
i.UnitPrice)).ToList(),
t.CreatedAt);
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 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>> GetByRideIdAsync(Guid rideId)
{
@@ -103,16 +57,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items)
{
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity)
if (input.QuantityFulfilled > shopPOItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled;
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
@@ -135,7 +89,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
if (!task.ShopPO.Items.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)
@@ -143,21 +97,21 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
var shopPO = task.ShopPO;
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
if (fullyDelivered)
order.FulfilledAt = DateTime.Now;
shopPO.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0;
@@ -180,21 +134,12 @@ public class DeliveryTaskService(
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
ReferenceId = task.FK_ShopPOId
});
}
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
shopPORepo.Update(shopPO);
await uow.CommitAsync(ct);
}
@@ -0,0 +1,13 @@
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);
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Models;
namespace Distribution.Features.Invoice;
[ApiController]
[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);
}
}
@@ -0,0 +1,22 @@
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 InvoiceResponse(
Guid Id,
Guid ShopPOId,
string StoreName,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
Guid CreatedBy,
DateTime? PrintedAt,
Guid? PrintedBy,
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,47 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
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);
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<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<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);
}
@@ -0,0 +1,33 @@
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
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);
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
return invoice is null ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
{
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());
}
}
@@ -8,7 +8,6 @@ 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,9 +29,10 @@ 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, ct);
var result = await service.CreateAsync(request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -50,90 +51,13 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(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 }); }
}
[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);
var result = await service.ConfirmAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -5,37 +5,27 @@ namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
Guid PopulatedBy,
DateTime PopulatedAt,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items);
List<LoadingSheetItemResponse> Items,
List<LoadingSheetOrderResponse> Orders);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
decimal? QuantityReceived,
decimal? QuantityResolved);
Guid? FK_ShopPOItemId);
public record LoadingSheetOrderResponse(Guid ShopPOId);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
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);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
@@ -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.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
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)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
@@ -36,19 +36,6 @@ 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, CancellationToken ct = default)
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -34,22 +34,16 @@ public class LoadingSheetService(
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has an active loading sheet");
throw new InvalidOperationException("This ride already has a Planned loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
Status = LoadingSheetStatus.Planned,
PopulatedBy = userId,
PopulatedAt = DateTime.Now
};
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
{
@@ -67,8 +61,8 @@ public class LoadingSheetService(
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
await uow.BeginAsync(ct);
try
@@ -78,6 +72,8 @@ 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);
@@ -88,289 +84,91 @@ public class LoadingSheetService(
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException($"Cannot confirm 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
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, 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);
}
sheet.Status = LoadingSheetStatus.Confirmed;
sheet.ConfirmedAt = DateTime.Now;
sheet.ConfirmedBy = userId;
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
var saved = await repo.GetByIdAsync(id);
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
{
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)
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
{
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;
}
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
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.Quantity -= item.QuantityDispatched;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
FK_ItemId = item.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = qty
Quantity = item.QuantityDispatched
});
}
else
{
vehicleBalance.Quantity += qty;
vehicleBalance.Quantity += item.QuantityDispatched;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FK_ItemId = item.FK_ItemId,
FromLocationId = item.FK_WarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = qty,
Quantity = item.QuantityDispatched,
Type = MovementType.Load,
ReferenceId = sheet.Id,
ReferenceId = sheetId,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
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,
new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
}
@@ -1,15 +0,0 @@
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);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
@@ -1,55 +0,0 @@
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("{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}")]
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 }); }
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
-21
View File
@@ -1,21 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record CreateOrderRequest(Guid SaleId);
public record UpdateOrderRequest(OrderStatus Status);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,49 +0,0 @@
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) =>
await context.OrderItems.Where(oi => oi.FK_OrderId == orderId).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)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
-158
View File
@@ -1,158 +0,0 @@
using Distribution.Features.Sale;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Order;
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
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);
}
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;
}
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
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;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
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;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
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 = await saleRepo.GetByIdAsync(order.FK_SaleId);
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;
}
}
@@ -1,38 +0,0 @@
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,12 +1,13 @@
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? storeId, Guid? saleId, int page, int pageSize);
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize);
Task<int> GetCountAsync(Guid? invoiceId);
Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId);
Task AddAsync(Models.Payment payment);
void Update(Models.Payment payment);
}
@@ -3,22 +3,22 @@
namespace Distribution.Features.Payment;
[ApiController]
[Route("api/sales/{saleId:guid}/payments")]
[Route("api/invoices/{invoiceId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId)
public async Task<IActionResult> GetByInvoice(Guid invoiceId)
{
var result = await service.GetBySaleIdAsync(saleId);
var result = await service.GetByInvoiceIdAsync(invoiceId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
public async Task<IActionResult> Create(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(saleId, request, ct);
var result = await service.CreateAsync(invoiceId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
+2 -10
View File
@@ -1,23 +1,15 @@
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 SaleId,
Guid StoreId,
Guid InvoiceId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -9,29 +9,27 @@ 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? storeId, Guid? saleId, int page, int pageSize) =>
public async Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize) =>
await context.Payments
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.Where(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId))
.OrderByDescending(p => p.CollectedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
public async Task<int> GetCountAsync(Guid? invoiceId) =>
await context.Payments
.Where(p => p.IsActive
&& (storeId == null || p.FK_StoreId == storeId)
&& (saleId == null || p.FK_SaleId == saleId))
.CountAsync();
.CountAsync(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId));
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
public async Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.Where(p => p.FK_InvoiceId == invoiceId && 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);
}
+17 -35
View File
@@ -1,5 +1,4 @@
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Features.Invoice;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,51 +7,35 @@ namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
ISaleRepository saleRepo,
IInvoiceRepository invoiceRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
{
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 invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
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);
var payments = await repo.GetByInvoiceIdAsync(invoiceId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
public async Task<PaymentResponse?> CreateAsync(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct = default)
{
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");
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");
// 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_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
FK_InvoiceId = invoiceId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
@@ -64,17 +47,16 @@ public class PaymentService(
{
await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0
invoice.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < sale.TotalAmount
: total < invoice.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
saleRepo.Update(sale);
invoiceRepo.Update(invoice);
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.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.ShopPO>> 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.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.Orders
public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
await (from o in context.ShopPOs
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.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
&& o.CreatedAt >= periodStart && o.CreatedAt < 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.OrderDate),
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt),
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>> GetByOrderIdAsync(Guid orderId);
Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId);
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) orders.</summary>
/// <summary>Whether this store has ever had this item on any of its (active) ShopPOs.</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,15 +28,13 @@ public class ReturnController(ReturnService service, ICurrentUserService current
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/orders/{id:guid}/returns")]
public async Task<IActionResult> GetByOrder(Guid id) =>
Ok(await service.GetByOrderIdAsync(id));
[HttpGet("api/shop-pos/{id:guid}/returns")]
public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByShopPOIdAsync(id));
[HttpPost("api/orders/{id:guid}/returns")]
[HttpPost("api/shop-pos/{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
@@ -44,6 +42,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
var result = await service.CreateAsync(id, request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ public record SaleReturnItemResponse(
public record SaleReturnResponse(
Guid Id,
Guid OrderId,
Guid ShopPOId,
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.Order)
.Include(r => r.ShopPO)
.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.Order)
.Include(r => r.ShopPO)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
public async Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId) =>
await context.SaleReturns
.Include(r => r.Order)
.Include(r => r.ShopPO)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.FK_OrderId == orderId && r.IsActive)
.Where(r => r.FK_ShopPOId == shopPOId && 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.OrderItems
.Include(oi => oi.Order)
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
await context.ShopPOItems
.Include(oi => oi.ShopPO)
.AnyAsync(oi => oi.ShopPO.FK_StoreId == storeId && oi.ShopPO.IsActive && oi.FK_ItemId == itemId);
}
+7 -143
View File
@@ -1,7 +1,5 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -10,9 +8,7 @@ namespace Distribution.Features.Returns;
public class ReturnService(
IReturnRepository repo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
@@ -21,7 +17,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_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
new(r.Id, r.FK_ShopPOId, r.Mode, r.TotalCreditAmount, r.ShopPO.TotalAmount, r.IsActive, r.CreatedAt,
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
@@ -58,141 +54,9 @@ public class ReturnService(
return Map(r);
}
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
/// <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.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? 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!);
}
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
}
+4 -13
View File
@@ -1,22 +1,13 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Ride;
public interface IRideRepository
{
Task<DeliveryRun?> GetByIdAsync(Guid id);
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<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 AddAsync(DeliveryRun ride);
void Update(DeliveryRun ride);
}
public record OrderSummaryModel(
Guid Id,
Guid StoreId,
string StoreName,
DateTime OrderDate,
decimal TotalAmount);
+12 -91
View File
@@ -1,23 +1,21 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
public class RideController(RideService service) : 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, type, driverProfileId, vehicleId, page, pageSize);
var result = await service.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
return Ok(result);
}
@@ -31,8 +29,12 @@ public class RideController(RideService service, ICurrentUserService currentUser
[HttpPost]
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
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 }); }
}
[HttpPut("{id:guid}/driver")]
@@ -47,32 +49,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
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)
{
@@ -85,70 +61,15 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel-request")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelRideRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RaiseCancelRequestAsync(id, request, userId, ct);
var result = await service.CancelAsync(id, request, 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(); }
}
}
+3 -49
View File
@@ -1,73 +1,27 @@
using Distribution.Models;
using Distribution.Features.Stock;
using Distribution.Models;
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,
Guid RouteId,
Guid? SourceWarehouseId);
int Meterstart);
public record AssignRideDriverRequest(Guid DriverProfileId);
public record ActivateRideRequest(int Meterstart);
public record CloseRideRequest(int Meterend);
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);
public record CancelRideRequest(CancelRequestType TypeCode, string Reason);
+5 -32
View File
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -10,59 +10,32 @@ 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, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) =>
public async Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, 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, RideType? type, Guid? driverProfileId, Guid? vehicleId) =>
public async Task<int> GetCountAsync(RideStatus? status, 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> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
public async Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null) =>
await context.DeliveryRuns.AnyAsync(r =>
r.FK_VehicleId == vehicleId &&
r.Id != excludeRideId &&
(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();
r.Status == RideStatus.Open);
public async Task AddAsync(DeliveryRun ride) =>
await context.DeliveryRuns.AddAsync(ride);
+21 -225
View File
@@ -1,4 +1,4 @@
using Distribution.Features.Stock;
using Distribution.Features.Stock;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Infra.UoW;
@@ -10,21 +10,13 @@ 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,
RideType? type,
Guid? driverProfileId,
Guid? vehicleId,
int page,
int pageSize)
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
{
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
var items = await rideRepo.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -36,21 +28,18 @@ public class RideService(
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
{
var vehicle = await vehicleRepo.GetByIdAsync(request.VehicleId)
?? throw new InvalidOperationException("Vehicle not found");
if (await rideRepo.HasOpenRideForVehicleAsync(request.VehicleId))
throw new InvalidOperationException("Vehicle already has an open ride");
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.SourceWarehouseId;
if (!sourceWarehouseId.HasValue)
throw new InvalidOperationException("Source warehouse is required for this vehicle");
await CheckDriverComplianceAsync(request.DriverProfileId);
var ride = new DeliveryRun
{
RideType = request.RideType,
FK_VehicleId = request.VehicleId,
FK_DriverProfileId = request.DriverProfileId,
FK_RouteId = request.RouteId,
FK_SourceWarehouseId = sourceWarehouseId.Value,
Status = RideStatus.Draft
Status = RideStatus.Open,
Meterstart = request.Meterstart,
StartedAt = DateTime.Now
};
await uow.BeginAsync(ct);
@@ -59,46 +48,19 @@ 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.Draft)
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("Driver can only be changed while the ride is Open");
var driver = await driverRepo.GetByIdAsync(driverProfileId);
if (driver is null || !driver.IsActive)
@@ -114,31 +76,12 @@ public class RideService(
return ToResponse(saved!);
}
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)
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
if (request.Meterend < ride.Meterstart)
@@ -157,162 +100,21 @@ public class RideService(
return ToResponse(ride);
}
public async Task<RideCancelRequestResponse> RaiseCancelRequestAsync(Guid rideId, RaiseCancelRequest request, Guid requestedBy, CancellationToken ct = default)
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest 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 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;
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
ride.Status = RideStatus.Cancelled;
await uow.BeginAsync(ct);
try
{
cancelRepo.Update(cancelRequest);
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
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());
return ToResponse(ride);
}
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
@@ -343,15 +145,9 @@ public class RideService(
}
private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.RideType, r.Status,
new(r.Id, 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.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);
r.StartedAt, r.ClosedAt, r.CreatedAt);
}
@@ -1,15 +0,0 @@
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);
}
@@ -1,51 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(rideId, page, pageSize));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
-36
View File
@@ -1,36 +0,0 @@
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);
@@ -1,45 +0,0 @@
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);
}
-273
View File
@@ -1,273 +0,0 @@
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,
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
// For a van sale against a specific ride, sell whatever quantity is actually on that
// vehicle instead of rejecting the whole sale over one short item — the sale is always a
// clean, completed sale of what's handed over, never a "partially fulfilled" order.
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value)
?? throw new InvalidOperationException("Ride not found");
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
item.Quantity = Math.Min(item.Quantity, (int)(balance?.Quantity ?? 0));
}
}
foreach (var item in orderItems)
item.QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0;
if (request.FulfillImmediately)
{
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
order.Status = OrderStatus.Fulfilled;
order.FulfilledAt = DateTime.Now;
}
else
{
sale.Status = SaleStatus.Pending;
order.Status = OrderStatus.Pending;
}
sale.TotalAmount = orderItems.Sum(i => i.Quantity * i.UnitPrice);
order.TotalAmount = sale.TotalAmount;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems.Where(i => i.QuantityFulfilled > 0))
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.QuantityFulfilled;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ public record SchemeEligibilityResponse(
public record SchemeRedemptionResponse(
Guid Id,
Guid SchemeId,
Guid? OrderId,
Guid? ShopPOId,
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.Order)
.Include(r => r.ShopPO)
.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 orderId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
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<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents
+36 -36
View File
@@ -1,6 +1,6 @@
using System.Text.Json;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.ShopPO;
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,
IOrderRepository orderRepo,
IShopPORepository shopPORepo,
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_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
new(r.Id, r.FK_SchemeId, r.FK_ShopPOId, 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, orderId) ──────────────────────────
// ── evaluateSchemeEligibility(storeId, shopPOId) ──────────────────────────
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid shopPOId)
{
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
if (store is null || !store.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
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 (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
continue;
result.Add(EvaluateRule(scheme, orderItems));
result.Add(EvaluateRule(scheme, items));
}
return result;
}
/// <summary>
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
/// Scope-only check (region, segment, date range as of today) with no ShopPO 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 order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
/// not "this ShopPO'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<OrderItem> orderItems)
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<ShopPOItem> items)
{
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 = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
var boughtQty = items.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; order has {boughtQty}.", null, null, null);
$"Requires {scheme.TriggerQty} of the trigger item; ShopPO 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 => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
.Where(c => items.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,
"Order is missing required bundle components.", null, null, null);
"ShopPO 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 = orderItems.Sum(i => i.Quantity * i.UnitPrice);
var orderTotal = items.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 = orderItems.Sum(i => i.Quantity * i.UnitPrice);
var orderTotal = items.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,
"Order total does not meet any slab threshold.", null, null, null);
"ShopPO 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(orderId, schemeId) ───────────────────────────────
// ── applySchemeDiscount(shopPOId, schemeId) ───────────────────────────────
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid shopPOId, Guid schemeId, CancellationToken ct = default)
{
var scheme = await repo.GetByIdAsync(schemeId);
if (scheme is null || !scheme.IsActive) return null;
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive) return null;
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive) return null;
if (await repo.HasRedemptionAsync(schemeId, orderId))
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var eligibility = EvaluateRule(scheme, orderItems);
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var eligibility = EvaluateRule(scheme, items);
if (!eligibility.Eligible)
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
throw new InvalidOperationException(eligibility.Reason ?? "ShopPO is not eligible for this scheme.");
var redemption = new SchemeRedemption
{
FK_SchemeId = schemeId,
FK_OrderId = orderId,
FK_ShopPOId = shopPOId,
DiscountAmount = eligibility.DiscountAmount
};
var newOrderItems = new List<OrderItem>();
var newItems = new List<ShopPOItem>();
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.");
newOrderItems.Add(new OrderItem
newItems.Add(new ShopPOItem
{
FK_OrderId = orderId,
FK_ShopPOId = shopPOId,
FK_ItemId = freeItem.Id,
Quantity = (int)eligibility.FreeQty.Value,
UnitPrice = 0m
@@ -422,7 +422,7 @@ public class SchemeService(
await uow.BeginAsync(ct);
try
{
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
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.Order is not null)
.Select(r => r.Order!)
.Where(r => r.ShopPO is not null)
.Select(r => r.ShopPO!)
.Distinct()
.Sum(o => o.TotalAmount);
@@ -0,0 +1,18 @@
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);
}
@@ -0,0 +1,28 @@
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);
}
}
@@ -0,0 +1,24 @@
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);
@@ -0,0 +1,91 @@
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);
}
}
@@ -0,0 +1,32 @@
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.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
Task<List<Models.ShopPO>> 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.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)
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)
.ToListAsync();
}
+1 -1
View File
@@ -268,6 +268,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
periodStart.ToString("yyyy-MM"),
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
}
}
@@ -0,0 +1,8 @@
namespace Distribution.Features.SysConfig;
public interface ISysConfigRepository
{
Task<Models.SysConfig?> GetByKeyAsync(string key);
Task<List<Models.SysConfig>> GetAllAsync();
Task UpsertAsync(Models.SysConfig entity);
}
@@ -0,0 +1,53 @@
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 }); }
}
}
@@ -0,0 +1,9 @@
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);
@@ -0,0 +1,29 @@
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);
}
}
}
@@ -0,0 +1,67 @@
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);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,961 @@
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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
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
+1 -11
View File
@@ -4,8 +4,7 @@ namespace Distribution.Models
{
public class DeliveryRun : Template
{
public RideType RideType { get; set; }
public RideStatus Status { get; set; } = RideStatus.Draft;
public RideStatus Status { get; set; } = RideStatus.Open;
public Guid FK_VehicleId { get; set; }
[ForeignKey("FK_VehicleId")]
@@ -15,19 +14,10 @@ namespace Distribution.Models
[ForeignKey("FK_DriverProfileId")]
public DriverProfile DriverProfile { get; set; }
public Guid FK_RouteId { get; set; }
[ForeignKey("FK_RouteId")]
public Route Route { get; set; }
public Guid FK_SourceWarehouseId { get; set; }
[ForeignKey("FK_SourceWarehouseId")]
public Warehouse SourceWarehouse { get; set; }
public int Meterstart { get; set; }
public int Meterend { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? ActivatedAt { get; set; }
public DateTime? ClosedAt { get; set; }
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ namespace Distribution.Models
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
public int Sequence { get; set; }
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
+13 -9
View File
@@ -3,15 +3,14 @@ namespace Distribution.Models
public enum LocationType
{ Warehouse, Vehicle, External }
public enum KycStatus { Pending,Verified, Rejected }
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
public enum KycStatus { Pending, Verified, Rejected }
public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
public enum RideType { Delivery, Sale }
public enum LoadingSheetStatus { Planned, Confirmed }
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
public enum RideStatus { Open, Cancelled, Closed }
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
@@ -19,16 +18,15 @@ namespace Distribution.Models
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum SaleStatus { Pending, Completed, Cancelled }
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum InvoiceStatus { Draft, Printed, Finalized }
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 }
@@ -41,4 +39,10 @@ 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 }
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Invoice : Template
{
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
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; } = new List<Payment>();
}
}
+6 -8
View File
@@ -8,17 +8,15 @@ namespace Distribution.Models
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_SourceWarehouseId { get; set; }
[ForeignKey("FK_SourceWarehouseId")]
public Warehouse SourceWarehouse { get; set; }
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Planned;
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Pending;
public Guid PopulatedBy { get; set; }
public DateTime PopulatedAt { get; set; }
public DateTime? DispatchConfirmedAt { get; set; }
public Guid? DispatchConfirmedBy { get; set; }
public DateTime? ReceiptConfirmedAt { get; set; }
public Guid? ReceiptConfirmedBy { get; set; }
public DateTime? ConfirmedAt { get; set; }
public Guid? ConfirmedBy { get; set; }
public List<LoadingSheetItem> Items { get; set; } = [];
public List<LoadingSheetOrder> Orders { get; set; } = [];
}
}
+9 -5
View File
@@ -2,10 +2,8 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class LoadingSheetItem
public class LoadingSheetItem : Template
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FK_LoadingSheetId { get; set; }
[ForeignKey("FK_LoadingSheetId")]
public LoadingSheet LoadingSheet { get; set; }
@@ -14,8 +12,14 @@ namespace Distribution.Models
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public Guid FK_WarehouseId { get; set; }
[ForeignKey("FK_WarehouseId")]
public Warehouse Warehouse { get; set; }
public Guid? FK_ShopPOItemId { get; set; }
[ForeignKey("FK_ShopPOItemId")]
public ShopPOItem? ShopPOItem { get; set; }
public decimal QuantityDispatched { get; set; }
public decimal? QuantityReceived { get; set; }
public decimal? QuantityResolved { get; set; }
}
}
+15
View File
@@ -0,0 +1,15 @@
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; }
}
}
-21
View File
@@ -1,21 +0,0 @@
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 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; }
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
}
}
+3 -7
View File
@@ -4,13 +4,9 @@ namespace Distribution.Models
{
public class Payment : Template
{
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 Guid FK_InvoiceId { get; set; }
[ForeignKey("FK_InvoiceId")]
public Invoice Invoice { get; set; }
public decimal Amount { get; set; }
public PaymentMethod Method { get; set; }
-27
View File
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Sale : Template
{
public DateTime SaleDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public Guid? FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun? Ride { get; set; }
public decimal TotalAmount { get; set; }
public SaleStatus Status { get; set; } = SaleStatus.Pending;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public bool FulfillImmediately { get; set; }
public DateTime? CompletedAt { get; set; }
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ namespace Distribution.Models
/// </summary>
public class SaleReturn : Template
{
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { get; set; }
public ReturnMode Mode { get; set; }
+3 -3
View File
@@ -10,9 +10,9 @@ namespace Distribution.Models
[ForeignKey("FK_SchemeId")]
public Scheme Scheme { get; set; }
public Guid? FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order? Order { get; set; }
public Guid? FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO? ShopPO { get; set; }
/// <summary>Placeholder for the future field-invoice module — no FieldInvoice entity exists yet.</summary>
public Guid? FieldInvoiceId { get; set; }
+25
View File
@@ -0,0 +1,25 @@
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,15 +1,17 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class OrderItem : Template
public class ShopPOItem : Template
{
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public Guid FK_ShopPOId { get; set; }
[ForeignKey("FK_ShopPOId")]
public ShopPO ShopPO { 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; }
+10
View File
@@ -0,0 +1,10 @@
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; }
}
}
+7 -7
View File
@@ -10,7 +10,8 @@ using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.ShopPO;
using Distribution.Features.Invoice;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
using Distribution.Features.PurchaseReturn;
@@ -22,7 +23,6 @@ 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,6 +32,7 @@ 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;
@@ -52,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<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
@@ -65,7 +66,6 @@ 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>();
@@ -85,8 +85,8 @@ builder.Services.AddScoped<RouteService>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<ShopPOService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>();
@@ -0,0 +1,168 @@
# [Refactor] Dispatch Pipeline — Shop PO to Invoice Finalization
**Status**: Proposed (design only — no code changes yet)
**Depends On**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [loading_event.md](./loading_event.md), [ride_lifecycle.md](./ride_lifecycle.md)
---
## Goal
Define the end-to-end order processing pipeline from a shop's initial PO through to invoice
completion. This replaces the separate Delivery ride and Sale ride flows
([05_delivery_ride.md](../05_delivery_ride.md), [06_sale_ride.md](../06_sale_ride.md)) with a
single unified pipeline.
---
## Pipeline Overview
```
[Stage 1] Shop PO created (editable)
↓
[Stage 2] PO Confirmed + Invoice Created → Draft (Mode A: combined / Mode B: separate)
↓
[Stage 3] Invoice Printed → agent takes to field
↓
[Stage 4] Loading Sheet Population (dispatcher-triggered, separate flow)
↓
[Stage 5] Delivery (TBD)
↓
[Stage 6] Agent returns printed invoice → office worker enters edits → Invoice Finalized
```
---
## Stage 1 — Shop PO
**Business term**: Shop PO
**System state**: `Order` (no Invoice yet)
The entry point to the pipeline. A sales agent creates a PO for a shop capturing line items and
quantities.
**Key rules:**
- Items editable — every edit soft-deletes current `OrderItem` rows (`IsActive = false`) and writes
a fresh set. `IsActive = true` = latest version; `IsActive = false` = history.
- Editing allowed only while Shop PO (before confirmation).
- No Invoice exists yet at this stage.
- A Shop PO can be cancelled (`Order.Status → Cancelled`). No Invoice exists at this point so no
cascade required.
**API:**
| Method | Route | Description |
|---|---|---|
| POST | `/api/orders` | Create Shop PO for a store. |
| PUT | `/api/orders/{id}/items` | Rewrite line items (versioned). |
| GET | `/api/orders/{id}` | Get PO with current active items. |
| POST | `/api/orders/{id}/cancel` | Cancel a Shop PO. |
---
## Stage 2 — Confirmed PO + Invoice Created
**Business term**: Confirmed PO
**System state**: `Order` (items locked) + `Invoice` at `Draft`
PO items are locked and an Invoice is created at `Draft`. How this happens depends on the active
mode — controlled by the system-level `InvoiceCreationMode` config flag.
**Mode A — combined action (one button):**
- `POST /api/orders/{id}/confirm-and-create-invoice`
- Locks PO items and creates Invoice at `Draft` atomically.
**Mode B — separate actions:**
- `POST /api/orders/{id}/confirm` — locks PO items only.
- Office user then browses Confirmed POs (filtered by `agentId`, `routeId`, `shopId`) and creates
the invoice: `POST /api/invoices { orderId }`.
**Key rules:**
- Confirmation requires no signature or OTP.
- `OrderItem` lines are permanently locked after confirmation.
- `Order.Status` (fulfillment tracking) is untouched — separate axis.
- A Confirmed PO can be cancelled even after confirmation (`Order.Status → Cancelled`). If an
Invoice was already created, cascade behaviour (void Invoice, impact on loading sheet items) is
unresolved — see Open Questions.
---
## Stage 3 — Invoice Printed
**Trigger**: `POST /api/invoices/{id}/print`
Office user prints the invoice. The agent takes the physical copy to the field.
**Key rules:**
- Agent may make handwritten edits on the printed copy during the ride (quantity adjustments,
returns, damage, discounts).
- Invoice is locked from system edits between `Printed` and `Finalized`.
---
## Stage 4 — Loading Sheet Population
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` with one or more
filters. The system finds all matching Confirmed POs and aggregates them into **one loading sheet**.
**Filters (at least one required):**
- `routeId` — all Confirmed POs for shops on this route
- `agentId` — all Confirmed POs created by this agent
- `shopId` — all Confirmed POs for this specific shop
**What happens:**
1. System finds all `Order` records matching the filter where items are locked (Confirmed PO)
2. One `LoadingSheet` created at `Planned` status
3. `LoadingSheetItem` rows populated from the active `OrderItem` lines of those POs
4. Each source PO recorded in `LoadingSheetOrder` linking table
5. Each item carries `FK_OrderItemId` pointing back to its source `OrderItem`
**After population — editing:**
- Dispatcher can edit item quantities while the sheet is `Planned` or `Assigned`
- Every edit soft-deletes current `LoadingSheetItem` rows (`IsActive = false`) and writes a fresh
set — same versioning as Shop PO items
**Confirmation and stock movement:**
- Ride is provided at populate time — no separate assign step
- Each item has its own `FK_WarehouseId` — one sheet can load from multiple warehouses
- Dispatcher confirms the sheet (`Planned → Confirmed`) — items frozen
- Stock movements: one debit per item's warehouse, one credit to the ride's vehicle
- Timing: open question (Path A at `Confirmed` vs Path B at `ReceiptConfirmed`)
See [loading_event.md](./loading_event.md) for the full state machine.
---
## Stage 5 — Delivery
**Status**: TBD — to be designed in a subsequent pass.
---
## Stage 6 — Invoice Finalized
**Trigger**: `POST /api/invoices/{id}/finalize`
After the ride or day-end, the agent returns the printed invoice to the office. The office worker
enters any edits the agent made into the system and submits.
**What happens:**
- Office worker enters adjustments (quantity changes, returns, damage, discounts) from the paper copy
- `Invoice.TotalAmount` is recomputed and locked
- `Invoice.Status` → `Finalized`
- `FinalizedAt` / `FinalizedBy` set
---
## Open Questions
1. **Who can confirm a Shop PO?** — the creating sales agent, a supervisor, or either?
2. **Can a Confirmed PO be reverted to Shop PO?** — no un-confirm path designed here.
3. **Cancellation cascade** — both Shop PO and Confirmed PO can be cancelled. If cancelling a
Confirmed PO that already has a linked Invoice: should the Invoice be voided automatically?
If the PO's items were already pulled into a loading sheet: what happens to those loading sheet
items? Cascade rules needed before implementation.
4. **Exact adjustment payload for `POST /api/invoices/{id}/finalize`** — 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).
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.
+141 -157
View File
@@ -1,41 +1,38 @@
# [Refactor] Loading Event — PO-Generated, Route-Free, No Ride Activation
# [Refactor] Loading Event — Dispatcher-Populated, Multi-PO, Multi-Warehouse
**Status**: Proposed (design only — no code changes yet)
**Refactors**: [../04_loading_event.md](../04_loading_event.md) (kept as-is, this is the target design)
**Depends On**: [ride_lifecycle.md](./ride_lifecycle.md), [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)
**Depends On**: [ride_lifecycle.md](./ride_lifecycle.md), [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
---
## Goal
Bring `04_loading_event.md` in line with the other two refactors:
Replace the previous loading event design with a dispatcher-driven, multi-PO model:
1. **No more `Open -> Active`.** [ride_lifecycle.md](./ride_lifecycle.md) collapses `Ride.Status` to
`Open`/`Cancelled`/`Closed` — there is no `Active` stage left to auto-trigger. Confirming a
loading event no longer changes `Ride.Status` at all; the ride simply stays `Open` through
loading, delivery, and everything else, until it's explicitly `Closed` or `Cancelled`.
`LoadingSheet.Status` tracks loading progress entirely on its own.
2. **No more Route or `RideType`-based item population.** `04_loading_event.md`'s "Delivery rides
auto-populate from the ride's route" no longer applies — `Ride` no longer carries a route
([ride_lifecycle.md](./ride_lifecycle.md)) and no longer distinguishes Delivery/Sale. Instead, per
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md), a `LoadingSheet` is generated
automatically from a single PO's items the moment that PO is confirmed (`Invoice` Pending →
Draft) — before any ride exists.
3. **A `LoadingSheet` now starts ride-less.** It's created PO-scoped
(`FK_OrderId`), not ride-scoped, and gets attached to an actual `Ride` + source warehouse later,
via a new explicit action, once a vehicle/trip is planned for it. The rest of the
dispatch/receipt/discrepancy workflow is unchanged in shape — it just now runs per PO-generated
sheet instead of per ride-created sheet.
1. **No more auto-creation.** Loading sheets are no longer generated automatically when a PO is
confirmed. A dispatcher manually triggers population.
2. **No more `Open → Active`.** `Ride.Status` collapses to `Open`/`Cancelled`/`Closed` — no
`Active` stage. Loading sheet confirmation no longer changes `Ride.Status`.
3. **One sheet aggregates multiple POs.** A single loading sheet is created from all Confirmed POs
matching a dispatcher's filter (route/agent/shop). No longer one sheet per PO.
4. **Ride set at populate time.** The ride is provided when the dispatcher triggers population —
no separate assign-ride step.
5. **Warehouse is per item, not per sheet.** Different items on the same loading sheet can be
loaded from different warehouses. Stock movements are generated per item's warehouse.
6. **Item versioning.** Loading sheet items are versioned the same way as Shop PO items — every
edit soft-deletes current rows (`IsActive = false`) and writes a fresh set.
7. **Stock moves at confirmation.** When the dispatcher confirms the sheet, stock moves from each
item's warehouse to the vehicle. No driver receipt confirmation step.
---
## Background / Context
Same physical mechanics as `04_loading_event.md`: a vehicle is a location with
`LocationType.Vehicle` in the stock ledger, and confirming receipt (or resolving a discrepancy)
creates the same two stock movements per item — debit source warehouse, credit vehicle. None of that
changes here. What changes is *when a `LoadingSheet` comes into existence* and *what it takes to
start the dispatch/receipt workflow on it*.
`LocationType.Vehicle` in the stock ledger. Confirming the loading sheet creates stock movements —
debit source warehouse(s), credit vehicle. What changes is *when and how a `LoadingSheet` comes
into existence*, *which POs it covers*, and *how warehouse selection works*.
---
@@ -46,80 +43,104 @@ start the dispatch/receipt workflow on it*.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| **`FK_OrderId`** | Guid | **New, required.** FK → `Order` (PO). Set at creation; never changes. |
| **`FK_RideId`** | Guid? | **Now nullable.** Null while `Planned`; set by the new assign-ride action. |
| **`FK_SourceWarehouseId`** | Guid? | **Now nullable**, for the same reason — set together with `FK_RideId` at assign time, not at creation. |
| Status | LoadingSheetStatus | State machine below — gains a `Planned`/`Assigned` prefix. |
| **`AssignedAt`** | DateTime? | **New.** Set when a Ride + source warehouse are assigned. |
| **`AssignedBy`** | Guid? | **New.** UserId of whoever assigned the ride. |
| DispatchConfirmedAt | DateTime? | Unchanged — set when warehouse confirms dispatch. |
| DispatchConfirmedBy | Guid? | Unchanged. |
| ReceiptConfirmedAt | DateTime? | Unchanged — set when driver confirms receipt. |
| ReceiptConfirmedBy | Guid? | Unchanged. |
| CreatedAt | DateTime | Unchanged. |
| ~~`FK_OrderId`~~ | — | **Removed.** One sheet now aggregates multiple POs — replaced by `LoadingSheetOrder` linking table. |
| **`FK_RideId`** | Guid | **Required. Set at population time** — dispatcher provides the ride when triggering populate. |
| ~~`FK_SourceWarehouseId`~~ | — | **Removed.** Warehouse is tracked per item, not per sheet. |
| Status | LoadingSheetStatus | `Planned` or `Confirmed`. |
| **`PopulatedBy`** | Guid | **New.** UserId of dispatcher who triggered population. |
| **`PopulatedAt`** | DateTime | **New.** When population was triggered. |
| ConfirmedAt | DateTime? | Set when loading sheet is confirmed (items locked, stock moves). |
| ConfirmedBy | Guid? | UserId of whoever confirmed the sheet. TBD — see Open Questions. |
| CreatedAt | DateTime | From Template. |
`LoadingSheetItem` is **unchanged** (`FK_ItemId`, `QuantityDispatched`, `QuantityReceived`,
`QuantityResolved`) — still populated the same way, just from a different source (see
[Item Population](#item-population) below).
### New Entity — `LoadingSheetOrder` (linking table)
One loading sheet aggregates multiple Confirmed POs. This table records which POs were included.
| Column | Type | Notes |
|---|---|---|
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| FK_OrderId | Guid | FK → Order (must be a Confirmed PO at population time) |
### Updated Entity — `LoadingSheetItem`
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| **`FK_WarehouseId`** | Guid | **New. Required.** The warehouse this specific item is loaded from. Different items on the same sheet can come from different warehouses. Set by dispatcher when editing items. |
| **`FK_OrderItemId`** | Guid? | **New.** Traces back to the source `OrderItem` from the Confirmed PO. Null for manually added lines. |
| FK_ItemId | Guid | FK → Item (product) |
| QuantityDispatched | decimal | Quantity to load from `FK_WarehouseId` |
| **`IsActive`** | bool | **Versioning.** Every edit soft-deletes current items (`IsActive = false`) and writes a fresh set. Active items = latest version; soft-deleted = history. |
| CreatedAt | DateTime | From Template |
### Updated Enum — `LoadingSheetStatus`
```csharp
public enum LoadingSheetStatus
{
Planned, // new: created from a confirmed PO, no ride yet
Assigned, // new: ride + source warehouse attached, ready to dispatch
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
Planned, // populated by dispatcher — ride set, items editable and versioned
Confirmed // items locked, stock movements created
}
```
`Pending` from `04_loading_event.md` is replaced by two states (`Planned`, `Assigned`) to make the
new ride-less creation step explicit. `DispatchConfirmed`, `ReceiptConfirmed`,
`DiscrepancyFlagged`, and `Resolved` keep their existing meaning and stock-movement behavior
unchanged.
`Pending`, `Assigned`, `DispatchConfirmed`, `ReceiptConfirmed`, `DiscrepancyFlagged`, and
`Resolved` from `04_loading_event.md` are all removed. Stock moves at `Confirmed` — no driver
receipt confirmation step in this flow.
---
## State Machine
```text
Planned -> Assigned -> DispatchConfirmed -> ReceiptConfirmed
\-> DiscrepancyFlagged -> Resolved
Planned → Confirmed (stock moves)
```
| Transition | Trigger | What it does |
|---|---|---|
| (implicit) -> Planned | PO confirmed (`POST /api/orders/{id}/confirm`, per [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)) | `LoadingSheet` is created automatically with `FK_OrderId` set, items populated from the PO's `OrderItem` lines. No ride, no warehouse yet. |
| Planned -> Assigned | `POST /{id}/assign-ride` | Dispatcher attaches `FK_RideId` (must be an `Open` ride) and `FK_SourceWarehouseId`. Sets `AssignedAt`/`AssignedBy`. This is the new step that replaces `04_loading_event.md`'s ride-scoped sheet creation. |
| Assigned -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Unchanged from `04_loading_event.md` — warehouse manager locks in `QuantityDispatched` per line. **Ride status is untouched** (no more `Open` requirement check beyond what assign-ride already validated). |
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Unchanged — driver's received quantities all match dispatched. Creates stock movements. **No longer triggers any ride transition** (there is no `Active` to trigger). |
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Unchanged — any line mismatch. No stock movements created. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Unchanged mechanics — supervisor enters `QuantityResolved` per line, creates stock movements using resolved quantities. **No longer triggers any ride transition.** |
| (create) → Planned | `POST /api/loading-sheets/populate` | Dispatcher provides `rideId` + filters. System finds all matching Confirmed POs, creates one `LoadingSheet` with `FK_RideId` set, populates `LoadingSheetItem` rows from active `OrderItem` lines, records PO links in `LoadingSheetOrder`. `PopulatedBy`/`PopulatedAt` set. Items editable (versioned via `IsActive`). Dispatcher sets `FK_WarehouseId` per item. |
| Planned → Confirmed | `POST /{id}/confirm` | Locks the sheet — `LoadingSheetItem` rows frozen. Stock movements created: one debit per `LoadingSheetItem.FK_WarehouseId`, one credit to the ride's vehicle. Sets `ConfirmedAt`/`ConfirmedBy`. |
A `Planned` sheet stays as `Planned` indefinitely until the dispatcher confirms it — there is no
timeout or auto-cancellation.
---
## Item Population
Replaces `04_loading_event.md`'s "Item Population by Ride Type" section — there is no more
Delivery/Sale split:
A dispatcher manually triggers population via `POST /api/loading-sheets/populate` providing:
Every `LoadingSheet` is auto-populated on creation (at `Planned`) from the aggregate of its own
PO's (`Order`) `OrderItem` lines — the same mechanism for every ride scenario, since
[order_invoice_lifecycle.md](./order_invoice_lifecycle.md) generates one `LoadingSheet` per
confirmed PO regardless of how that PO originated. `PUT /{id}/items` remains available to override
quantities before dispatch is confirmed (through `Planned` and `Assigned`).
| Input | Notes |
|---|---|
| `rideId` | **Required.** The ride this loading sheet is for. Must be an `Open` ride. |
| `routeId` | Filter — all Confirmed POs for shops on this route |
| `agentId` | Filter — all Confirmed POs created by this agent |
| `shopId` | Filter — all Confirmed POs for this specific shop |
At least one filter must be provided alongside `rideId`. The system finds all matching `Order`
records where items are locked (Confirmed PO), aggregates their active `OrderItem` lines into one
`LoadingSheet`, and records each PO in `LoadingSheetOrder`.
`LoadingSheetItem.FK_OrderItemId` traces each line back to its source `OrderItem` for audit.
After population, the dispatcher sets `FK_WarehouseId` on each item — specifying which warehouse
that item is loaded from. Different items can come from different warehouses. Items are editable
while `Planned` — every edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`)
and writes a fresh set. Latest version = `IsActive = true` rows.
---
## Stock Movements
**Unchanged** from `04_loading_event.md`. Still created exactly once, at `ReceiptConfirmed` or
`Resolved`, still one pair per line item (debit `FK_SourceWarehouseId`, credit the assigned ride's
vehicle), still using `QuantityFinal` = `QuantityReceived` or `QuantityResolved`, still
`Type = Load`.
Created at `Confirmed`. One pair per active `LoadingSheetItem`:
- **Debit**: `LoadingSheetItem.FK_WarehouseId` (the warehouse that item loads from)
- **Credit**: vehicle on the assigned ride (`LoadingSheet.FK_RideId → DeliveryRun.FK_VehicleId`)
- **Type**: `Load`
Since different items can have different `FK_WarehouseId` values, one loading sheet can produce
stock movements from multiple warehouses into one vehicle in a single confirmation action.
---
@@ -129,63 +150,34 @@ Base route: `/api/loading-sheets`
| Method | Route | Who | Description |
|---|---|---|---|
| ~~POST `/`~~ | — | — | **Removed.** Sheets are no longer manually created — they're generated automatically when a PO is confirmed. |
| GET | `/` | Any | List sheets. Filter by `orderId`, `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail with all line items. |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities — allowed through `Planned` and `Assigned`. |
| **POST `/{id}/assign-ride`** | **Dispatcher** | **New.** `{ rideId, sourceWarehouseId }`. Requires the ride to be `Open`. `Planned -> Assigned`. |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | `Assigned -> DispatchConfirmed`. |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line. Unchanged. |
| POST | `/{id}/confirm-receipt` | Driver | `-> ReceiptConfirmed` or `DiscrepancyFlagged`. No ride-status side effect. |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line `-> Resolved`. No ride-status side effect. |
| POST | `/populate` | Dispatcher | `{ rideId, routeId?, agentId?, shopId? }` — `rideId` required, at least one filter required. Creates one `LoadingSheet` at `Planned` with `FK_RideId` set. |
| GET | `/` | Any | List sheets. Filter by `routeId`, `agentId`, `shopId`, `rideId`, `status`. Paginated. `routeId` resolves via `Store.FK_RouteId → Order.FK_StoreId → LoadingSheetOrder.FK_OrderId` server-side. |
| GET | `/{id}` | Any | Get sheet detail. Active items only by default; `?history=true` includes soft-deleted versions. |
| PUT | `/{id}/items` | Dispatcher | Rewrite items while `Planned`. Each item must include `FK_WarehouseId`. Soft-deletes current rows (`IsActive = false`) and writes a fresh set. |
| POST | `/{id}/confirm` | TBD | Lock the sheet. `Planned → Confirmed`. Items frozen. Stock movements created. |
---
## Business Rules
1. A `LoadingSheet` is created automatically, exactly once per PO, when that PO is confirmed — there
is no manual "create sheet" action and no `Open`-ride precondition at creation time (replaces
`04_loading_event.md` rule 1).
2. **A ride may now have more than one `LoadingSheet` assigned to it** — one per PO whose sheet was
assigned there. `04_loading_event.md`'s "a ride can only have one active loading sheet at a time"
no longer holds, since a single ride/trip can carry stock for several stores' POs at once. Each
assigned sheet still progresses through dispatch/receipt/discrepancy independently.
3. `assign-ride` requires the target ride to be `Open`; a `Cancelled` or `Closed` ride cannot receive
a new assignment.
4. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial receipt
confirmation (unchanged from `04_loading_event.md`, minus the "activation" language since there's
no `Active` status anymore).
5. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch or
on assignment. Unchanged.
6. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. Unchanged.
7. All list endpoints return paginated responses. Unchanged.
8. Vehicle capacity check is still not enforced in this step. Unchanged.
9. Loading confirmation and receipt no longer trigger any `Ride.Status` change — replaces
`04_loading_event.md` rules that referenced `Open -> Active`.
`04_loading_event.md`'s rule 8 ("Immediate handover from a Sale ride still creates an order") is
dropped along with `RideType` — every PO already creates its own `Invoice`/`LoadingSheet` uniformly,
regardless of how quickly it moves through Pending → Draft → Finalized.
---
## Impact on Dependent Features
1. **`ride_lifecycle.md`'s Impact item 2** (loading sheet auto-population without a route) and
**`order_invoice_lifecycle.md`'s Impact item 6** are addressed by this document: population now
comes from the PO directly, and the ride-attachment gap (`order_invoice_lifecycle.md`'s
[Open Question 7](../refactor_docs/order_invoice_lifecycle.md#open-questions)) is resolved by the
new `Planned -> Assigned` step and `POST /{id}/assign-ride` endpoint.
2. **Dispatcher role/permission is new** — `04_loading_event.md`'s permission table has no
"dispatcher" concept; `assign-ride` needs a new permission (see below), and who is authorized to
perform it (a route/trip planner role?) is not fully specified here.
3. **Multiple sheets per ride** changes how a driver/warehouse experiences dispatch — instead of one
confirm-dispatch per ride, there may now be several, one per assigned PO. UI/UX and any
"ride loading summary" view built against the old one-sheet-per-ride assumption needs rework —
not designed here.
4. **`order_invoice_lifecycle.md`'s Open Question 8** (does editing PO items post-confirm re-sync the
`LoadingSheet`?) is still open here too — this document doesn't resolve what happens to an
`Assigned` or later-stage sheet if its source PO's items change afterward.
1. A `LoadingSheet` is created manually by a dispatcher — not automatically. `rideId` and at least
one PO filter (routeId, agentId, or shopId) are required.
2. The ride (`FK_RideId`) is set at populate time — there is no separate assign-ride step.
3. Only Confirmed POs (locked items) are included in population — Shop POs are excluded.
4. One `LoadingSheet` aggregates all matching Confirmed POs — not one sheet per PO.
5. A ride may have more than one `LoadingSheet` — each progresses independently.
6. The target ride must be `Open` at populate time.
7. Each `LoadingSheetItem` carries its own `FK_WarehouseId` — items on the same sheet can come
from different warehouses. There is no sheet-level source warehouse.
8. Items are editable (versioned via `IsActive`) while `Planned` — frozen at `Confirmed`.
9. Every item edit soft-deletes all current `LoadingSheetItem` rows (`IsActive = false`) and writes
a fresh set. Active rows = latest version; soft-deleted = history.
10. Stock movements are created at `Confirmed` — one debit per item's warehouse, one credit to the
vehicle — all in one transaction.
11. A `Planned` sheet stays `Planned` until confirmed — no timeout or auto-cancellation.
12. No `Ride.Status` transition is triggered at any point in this flow.
13. All list endpoints return paginated responses.
14. Vehicle capacity check is not enforced in this step.
---
@@ -194,57 +186,49 @@ regardless of how quickly it moves through Pending → Draft → Finalized.
| Code | Who |
|---|---|
| `loading.view` | All roles |
| **`loading.assign`** | **New.** Dispatcher and above |
| `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above |
| **`loading.populate`** | **New.** Dispatcher — trigger population, edit items |
| **`loading.confirm`** | **New.** TBD — who can confirm the sheet (see Open Questions) |
---
## Fraud / Control Considerations
Unchanged from `04_loading_event.md`:
- Discrepancies are immediately visible and block further progress.
- `QuantityResolved` is permanently stored per line for audit purposes.
Additionally: `AssignedAt`/`AssignedBy` gives an audit trail for who committed a planned PO load to
a specific ride/vehicle, which didn't exist as a separate step before.
- `PopulatedBy`/`PopulatedAt` gives an audit trail for who triggered population and when.
- `ConfirmedBy`/`ConfirmedAt` records who committed stock movements.
- `FK_OrderItemId` on each item traces the exact source PO line for full audit.
- `IsActive = false` rows preserve the full edit history of the sheet before confirmation.
---
## Open Questions
1. **Who is authorized to assign a ride to a Planned sheet?** — a new "dispatcher" role/permission is
introduced (`loading.assign`) but not otherwise defined in this pass.
2. **What happens to a `Planned` sheet if its PO is never assigned to a ride?** — no timeout, retry,
or cancellation path is designed here.
3. **Does editing a PO's items after its sheet is `Planned`/`Assigned` re-sync the sheet?** — carried
over from `order_invoice_lifecycle.md`'s Open Question 8, still unresolved here.
4. **Should multiple `Planned` sheets ever merge into one `Assigned` sheet** (e.g. one dispatch
covering several stores' POs on the same ride), rather than staying as separate per-PO sheets that
each go through their own dispatch/receipt cycle? Not designed here — this document keeps them as
independent per-PO sheets (see Business Rule 2).
5. Vehicle capacity check — still deferred, unchanged from `04_loading_event.md`.
1. **Who confirms the loading sheet?** — dispatcher, warehouse manager, or either? TBD. Controls
the `loading.confirm` permission assignment.
2. **What happens to a `Planned` sheet if its source PO is cancelled?** — if a Confirmed PO is
cancelled after a loading sheet has been populated from it, the sheet's items sourced from that
PO become invalid. No cascade behaviour designed here.
3. Vehicle capacity check — still deferred.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Add `FK_OrderId` (required), `AssignedAt`, `AssignedBy` to `Models/LoadingSheet.cs`; make
`FK_RideId` and `FK_SourceWarehouseId` nullable.
- Replace `LoadingSheetStatus.Pending` with `Planned` and `Assigned`; update all status checks in
`LoadingSheetService`/`LoadingSheetRepository` accordingly.
- Remove `POST /api/loading-sheets` (manual create); wire automatic `LoadingSheet` creation into the
PO-confirm action from [order_invoice_lifecycle.md](./order_invoice_lifecycle.md)'s migration
notes.
- Add `POST /{id}/assign-ride` endpoint/service method, validating the target ride's `Status == Open`
(per [ride_lifecycle.md](./ride_lifecycle.md)'s narrowed `RideStatus`).
- Remove the `confirm-receipt`/`resolve` code paths' calls into ride-activation logic — there is no
`Ride.Status` transition to trigger anymore.
- Remove the ride-route-based item population path entirely (`Order.Store.FK_RouteId` aggregation);
replace with direct `FK_OrderId`-based population at sheet-creation time.
- Seed the new `loading.assign` permission.
- Resolve Open Questions 1 and 4 before finalizing the `assign-ride` endpoint's authorization rules
and the one-sheet-per-PO assumption, respectively.
- Remove `FK_OrderId`, `FK_SourceWarehouseId`, `AssignedAt`, `AssignedBy`,
`DispatchConfirmedAt`, `DispatchConfirmedBy`, `ReceiptConfirmedAt`, `ReceiptConfirmedBy` from
`Models/LoadingSheet.cs`. Make `FK_RideId` required. Add `PopulatedBy`, `PopulatedAt`,
`ConfirmedAt`, `ConfirmedBy`.
- Add `Models/LoadingSheetOrder.cs` (`FK_LoadingSheetId`, `FK_OrderId`) with composite PK.
- Add `FK_WarehouseId` (required), `FK_OrderItemId` (nullable), `IsActive` to
`Models/LoadingSheetItem.cs`. Remove `QuantityReceived`, `QuantityResolved`.
- Replace `LoadingSheetStatus` entirely with `Planned` and `Confirmed`; remove all other values
and update all status checks in `LoadingSheetService`/`LoadingSheetRepository`.
- Remove `POST /api/loading-sheets` (old manual create), `POST /{id}/assign-ride`,
`POST /{id}/confirm-dispatch`, `PUT /{id}/receipt-items`, `POST /{id}/confirm-receipt`,
`POST /{id}/resolve`; add `POST /api/loading-sheets/populate` and `POST /{id}/confirm`.
- Wire stock movements into `POST /{id}/confirm` — one debit per `LoadingSheetItem.FK_WarehouseId`,
one credit to the ride's vehicle, in a single transaction.
- Remove ride-route-based item population (`GetAggregatedOrderItemsByRouteIdAsync`); replace with
filter-based multi-PO aggregation.
- Remove all calls to ride-activation logic — no `Ride.Status` transition exists anymore.
- Seed `loading.populate` and `loading.confirm` permissions.
- Resolve Open Question 1 (who confirms) before assigning `loading.confirm` permission.
@@ -3,37 +3,34 @@
**Status**: Proposed (design only — no code changes yet)
**Refactors**: [../05_delivery_ride.md](../05_delivery_ride.md) and
[../06_sale_ride.md](../06_sale_ride.md) (both kept as-is, this is the target design)
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md) — that refactor removes
`RideType`/`Route`/`Status` from `Ride`; this document does not attempt to resolve most of its open
questions, and this document's design does not depend on it landing first. One exception: this
document's automatic `LoadingSheet` creation on PO confirm (see below) partially addresses
`ride_lifecycle.md`'s "loading sheet auto-population" gap — the `LoadingSheet` is now generated from
the PO/Invoice directly, not from a Route. How that `LoadingSheet` later attaches to an actual Ride
(vehicle) is resolved in [loading_event.md](./loading_event.md).
**Related but independent**: [ride_lifecycle.md](./ride_lifecycle.md), [dispatch_pipeline.md](./dispatch_pipeline.md)
---
## Goal
Replace the Delivery-vs-Sale ride-type split as the organizing concept for how a store's order gets
turned into money. Today `05_delivery_ride.md` (rep takes an order now, delivers days later) and
`06_sale_ride.md` (rep+driver visit together and sell on the spot) are two separate flows. Underneath,
both are the same shape: a rep collects a **PO** from the shop — whether fulfilled immediately or
days later — and once the PO exists, an **Invoice** is generated from it. That invoice always moves
through the same three statuses regardless of which scenario produced it:
Replace the Delivery-vs-Sale ride-type split with a single unified pipeline. The PO and Invoice are
two separate but linked concerns — the PO tracks what the shop ordered; the Invoice is the billing
document that goes with the delivery.
1. **Pending** — invoice just created from the PO; the PO's line items are freely editable.
2. **Draft** — a PO-confirm action moves it out of Pending (no customer signature/OTP — see
[State Machine](#state-machine)). Confirming also automatically generates a `LoadingSheet` from
the PO's current line items. The PO's line items **remain editable** in this state too — Draft is
a working document, not a lock.
3. **Finalize** — the reconciliation step: item add/delete and amount deductions (returns, damage,
short-delivery, discounts) get applied here, producing the final, locked invoice amount — this is
what the shop actually received and paid, and is the record used for day-end calculations. This
is the only state where free-form editing stops.
### PO lifecycle (on `Order`)
The two ride scenarios differ only in *timing* through this one pipeline, not in structure — no
ride-type-specific branching is needed in the PO/Invoice model itself.
1. **Shop PO** — PO created by sales agent, line items freely editable (versioned via `IsActive`).
2. **Confirmed PO** — office user confirms the PO, locking its items. Invoice creation depends on
the system's active mode (see [Invoice Creation Modes](#invoice-creation-modes) below).
### Invoice lifecycle (on `Invoice`)
An Invoice is **not** auto-created when a PO is created. It is created by an office user action —
either combined with PO confirmation (Mode A) or as a separate step after confirmation (Mode B).
Once created, the Invoice always moves through these states:
1. **Draft** — Invoice created, ready to be printed.
2. **Printed** — Invoice physically printed and handed to the agent for the field. The agent makes
handwritten edits on the paper copy during the ride.
3. **Finalized** — after the ride or day-end, the agent returns the printed invoice. The office
worker enters any edits the agent made into the system and submits — locking the invoice as the
final billing record.
---
@@ -54,198 +51,183 @@ name as `Order` in code.
Order ("PO")
├── Id (Guid) — PK, from Template
├── FK_StoreId (Guid) — required, unchanged
├── FK_SaleId (Guid?) — now OPTIONAL (was required). See Impact section — a PO can now
│ exist before any Sale/payment event completes.
├── TotalAmount (decimal) — unchanged, though only authoritative once the linked Invoice is
│ Finalized (see below)
├── FK_SaleId (Guid?) — now OPTIONAL (was required). A PO can exist before any Sale.
├── InvoiceId (Guid?) — NOT a FK constraint. Set when the Invoice is created for this PO
│ (Mode A or B). Null until then. No referential enforcement at DB
│ level — configured explicitly in Fluent API.
├── TotalAmount (decimal) — only authoritative once the linked Invoice is Finalized
├── Status (OrderStatus) — unchanged: Pending, PartiallyFulfilled, Fulfilled, Cancelled.
│ This tracks fulfillment progress, a separate axis from the new
│ InvoiceStatus below — not merged in this pass.
│ Tracks fulfillment progress — separate axis from InvoiceStatus.
├── FulfilledAt (DateTime?) — unchanged
├── IsActive, CreatedAt — from Template
```
`OrderItem` (the PO's line items) is unchanged in shape (`FK_ItemId`, `Quantity`,
`QuantityFulfilled`, `UnitPrice`) — editability is now gated by the linked `Invoice`'s status (see
below), not by `Order.Status` directly.
`OrderItem` (`FK_ItemId`, `Quantity`, `QuantityFulfilled`, `UnitPrice`, `IsActive`, `CreatedAt`) —
editability gated by whether the PO is confirmed. Every edit soft-deletes current rows
(`IsActive = false`) and writes a fresh set. Active rows = latest version; soft-deleted = history.
### New Entity — `Invoice`
```
Invoice
├── Id (Guid) — PK, from Template
├── FK_OrderId (Guid) — required. One PO → one Invoice for v1 (see Open Questions).
├── Status (InvoiceStatus) — Pending, Draft, Finalized
├── ConfirmedAt (DateTime?) — set on Pending → Draft, when the PO is confirmed
├── FinalizedAt (DateTime?) — set on Draft → Finalized
├── FinalizedBy (Guid?) — UserId of whoever finalized it
├── TotalAmount (decimal) — mirrors the PO total while Pending/Draft; becomes the
│ authoritative, locked amount once Finalized
├── FK_OrderId (Guid) — required. One PO → one Invoice (1:1).
├── Status (InvoiceStatus) — Draft, Printed, Finalized
├── CreatedBy (Guid) — UserId of office user who created the invoice
├── PrintedAt (DateTime?) — set on Draft → Printed
├── PrintedBy (Guid?) — UserId of whoever triggered the print
├── FinalizedAt (DateTime?) — set on Printed → Finalized
├── FinalizedBy (Guid?) — UserId of office worker who entered agent edits and submitted
├── TotalAmount (decimal) — reflects current line items through Draft/Printed; locked at Finalized
├── IsActive, CreatedAt — from Template
```
There is no signature/OTP field on `Invoice` — confirming a PO is a plain action, not a captured
customer signature (see [State Machine](#state-machine); this also resolves
[Open Question 1](#open-questions) from the prior pass of this document).
### `LoadingSheet` — now generated from the PO, not the Route
`LoadingSheet` already exists in the codebase (`Features/LoadingEvent/`), previously populated by
aggregating orders on a ride's route (`LoadingSheetRepository.GetAggregatedOrderItemsByRouteIdAsync`
— see `ride_lifecycle.md`'s Impact item 2). Under this design, a `LoadingSheet` is instead created
automatically the moment an `Order`/`Invoice` moves Pending → Draft (PO confirmed), populated from
that PO's `OrderItem` lines directly:
```
LoadingSheet
├── FK_OrderId (Guid) — required. Links the sheet to the PO/Invoice it was generated from.
```
It is **not** linked to a Ride or vehicle at creation time — it exists purely as "what needs to be
loaded for this PO." How/when it later gets attached to an actual Ride for physical stock loading is
left open — see [Open Questions](#open-questions).
### New Enum — `InvoiceStatus`
```csharp
public enum InvoiceStatus { Pending, Draft, Finalized }
public enum InvoiceStatus { Draft, Printed, Finalized }
```
An `Invoice` is created automatically the moment a PO (`Order`) is created, starting at `Pending` —
there is no separate "create invoice" action.
### Invoice Creation Modes
In a given system instance, **exactly one** of the following modes is active. Controlled by a
system-level configuration flag (`InvoiceCreationMode: "A" | "B"`). Both modes cannot be active
at the same time.
**Mode A — Confirm PO + Create Invoice (combined action)**
- Office user presses "Confirm PO & Create Invoice"
- Single endpoint: `POST /api/orders/{id}/confirm-and-create-invoice`
- Confirms the PO (locks `OrderItem` lines) and creates the Invoice at `Draft` in one atomic action.
- `Order.InvoiceId` set to the new Invoice's Id in the same transaction.
**Mode B — Create Invoice from Confirmed POs (separate action)**
- PO confirmation is a separate step: `POST /api/orders/{id}/confirm` — locks items only.
- `Order.InvoiceId` remains null until invoice is created.
- Office user browses Confirmed POs filtered by `agentId`, `routeId`, or `shopId`, then creates
the invoice: `POST /api/invoices { orderId }`.
- `Order.InvoiceId` set to the new Invoice's Id at creation.
### `LoadingSheet` — populated from Confirmed POs, separate flow
Loading sheets are populated from Confirmed POs as a separate dispatcher-triggered flow — not
auto-triggered at PO confirm or invoice creation time. One loading sheet aggregates multiple
Confirmed POs based on route/agent/shop filters. See [loading_event.md](./loading_event.md) and
[dispatch_pipeline.md](./dispatch_pipeline.md) for the full loading sheet lifecycle.
---
## State Machine
```
Pending → Draft → Finalized
```
### PO (`Order`) transitions
No branch/cancel path is designed in this pass (unlike the Ride state machine) — cancellation of a
PO/Invoice is not addressed here; flagged as an open question below.
```
(create) → Shop PO → Confirmed PO
```
| Transition | Trigger | What it does |
|---|---|---|
| (implicit) → Pending | PO (`Order`) created | Invoice is created automatically, `Status = Pending`. `OrderItem` lines fully editable. |
| Pending → Draft | `POST /api/orders/{id}/confirm` | Confirms the PO — a plain action, not a customer signature/OTP. Lives on the `Order` (PO) resource, not the `Invoice`; does **not** add or change any `Order.Status` value — PO confirmation and fulfillment tracking stay separate axes. Sets `Invoice.ConfirmedAt`, `Invoice.Status = Draft`, and automatically creates a `LoadingSheet` from the PO's current `OrderItem` lines. **`OrderItem` lines remain editable** after confirm — Draft is not a lock; whether/how a later edit re-syncs the already-generated `LoadingSheet` is an open question (see below). |
| Draft → Finalized | `POST /api/invoices/{id}/finalize` | Accepts an item-adjustment/deduction payload (add/delete lines, amount deductions for returns/damage/short-delivery/discounts), recomputes and locks `TotalAmount`. This is a distinct, audited action — not the same add/edit/remove path used in Pending/Draft. Once Finalized, the Invoice and its PO's items are fully locked. |
| (create) → Shop PO | `POST /api/orders` | PO created. `OrderItem` lines editable — every edit soft-deletes current rows (`IsActive = false`) and writes a fresh set. Active rows = latest version. No Invoice yet. |
| Shop PO → Confirmed PO | **Mode A:** `POST /api/orders/{id}/confirm-and-create-invoice` | Confirms PO and creates Invoice at `Draft` atomically. `OrderItem` lines locked. `Order.InvoiceId` set. |
| Shop PO → Confirmed PO | **Mode B:** `POST /api/orders/{id}/confirm` | Confirms PO only. `OrderItem` lines locked. `Order.InvoiceId` remains null until invoice created separately. |
### Invoice transitions
```
Draft → Printed → Finalized
```
| Transition | Trigger | What it does |
|---|---|---|
| (create) → Draft | **Mode A:** combined with PO confirm above | Invoice created at `Draft`. `Order.InvoiceId` set. |
| (create) → Draft | **Mode B:** `POST /api/invoices` `{ orderId }` | Office user selects a Confirmed PO (filtered by `agentId` / `routeId` / `shopId`) and creates the Invoice at `Draft`. `Order.InvoiceId` set. |
| Draft → Printed | `POST /api/invoices/{id}/print` | Invoice is physically printed. `PrintedAt`/`PrintedBy` set. Agent takes the printed copy to the field and makes handwritten edits during the ride. |
| Printed → Finalized | `POST /api/invoices/{id}/finalize` | After ride/day-end, agent returns printed invoice. Office worker enters any edits the agent made (quantity adjustments, returns, damage, discounts) and submits. `TotalAmount` locked. `FinalizedAt`/`FinalizedBy` set. |
---
## Business Rules
1. `OrderItem` add/edit/remove is allowed while the linked `Invoice.Status` is `Pending` **or**
`Draft` — both are working-document states.
2. An `Invoice` cannot be `Finalized` without first passing through `Draft` — `Pending → Finalized`
directly is not a valid transition.
3. `Order.TotalAmount`/`Invoice.TotalAmount` is only authoritative once `Finalized`; while
Pending/Draft it reflects the current (still-changeable) line items.
4. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is
untouched and operates on a separate axis from `Invoice.Status` (billing/document lifecycle) —
whether these two should eventually merge is an open question, not resolved here.
5. `Order.FK_SaleId` is now optional — a PO/Invoice can exist and progress through Pending/Draft
before any `Sale` is recorded, covering the take-order-now/deliver-later scenario. See Impact
section for what this touches.
6. Both ride scenarios (take-order-now-deliver-later vs. on-the-spot sale) create a PO the same way
and pass through the same three Invoice statuses — the only difference is how quickly they move
through Pending → Draft → Finalized. No `RideType`-specific branching applies to the PO/Invoice
model.
7. Confirming a PO (Pending → Draft) requires no customer signature or OTP — it is a plain action
available to whoever is authorized to confirm a PO (e.g. the rep/dispatcher), not a
customer-facing sign-off step.
8. A `LoadingSheet` is generated exactly once per PO, automatically, at PO-confirm time — there is no
separate manual "create loading sheet" action.
1. `OrderItem` editing is allowed **only** while the PO is a Shop PO (before confirmation). Every
edit soft-deletes all current `OrderItem` rows (`IsActive = false`) and creates a fresh set —
active rows are the latest version, soft-deleted rows are history. Once confirmed, items are
locked permanently.
2. Invoice is **not** auto-created at PO creation — it is always a deliberate office user action.
3. Exactly one Invoice Creation Mode (A or B) is active per system instance — controlled by
`InvoiceCreationMode` config flag. Both modes cannot be active simultaneously.
4. Mode A only: confirming a PO and creating its Invoice are atomic — one cannot happen without
the other.
5. Mode B only: a PO can be confirmed without an Invoice existing; the Invoice is created later as
a separate action.
6. `Order.InvoiceId` is set (non-null) once the Invoice is created — in the same transaction as
Invoice creation for both modes. It is stored as a plain column with no DB-level FK constraint.
7. An Invoice cannot skip `Printed` — `Draft → Finalized` directly is not a valid transition.
8. `Invoice.TotalAmount` is only authoritative once `Finalized`; through `Draft`/`Printed` it
reflects the current line items.
9. `Order.Status` (fulfillment tracking: Pending/PartiallyFulfilled/Fulfilled/Cancelled) is a
separate axis from `Invoice.Status` — not merged in this pass.
10. `Order.FK_SaleId` is optional — a PO can exist and be confirmed before any `Sale` is recorded.
11. Loading sheet population is a separate dispatcher-triggered flow — not triggered by PO
confirmation or invoice creation. See [dispatch_pipeline.md](./dispatch_pipeline.md).
12. **A Shop PO (pre-confirmation) can be cancelled** — `Order.Status → Cancelled`. No Invoice
exists at this stage so no cascade required.
13. **A Confirmed PO can also be cancelled** even after confirmation — `Order.Status → Cancelled`.
If an Invoice was already created for this PO, cascade behaviour (void/cancel the Invoice) and
behaviour for any loading sheet that included this PO are open questions — see Open Questions.
---
## Impact on Dependent Features
Reshaping `Order` and introducing `Invoice` touches several existing behaviors. Each is flagged here
as **known-affected, deferred follow-up work**, not solved in this pass:
1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, and it always
sets a non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path (not
necessarily originating from a `Sale`) needs to exist for the take-order-now/deliver-later
scenario. Any other code that assumes `Order.Sale` is always non-null also needs auditing —
not enumerated exhaustively here.
2. **`Payment` attachment** — per `06_sale_ride.md`, `Payment` currently attaches to `Sale`. Once
`Invoice` exists as the real billing document, `Payment` more naturally attaches to the `Invoice`
instead. Flagged as a design question for [19_finance_accounting.md](../19_finance_accounting.md)
to reconcile — not decided here.
3. **`19_finance_accounting.md`'s proposed Invoice status** (`Issued, PartiallyPaid, Paid`) needs
reconciling with this document's `Pending/Draft/Finalized`. These are likely two different axes
on the same `Invoice` — document lifecycle status (this doc) vs. payment-collection status (the
finance doc) — but that reconciliation is not attempted here.
4. **`DeliveryTask.CustomerConfirmed`** (boolean, from `05_delivery_ride.md`) overlaps conceptually
with the new Pending → Draft transition (both represent "the customer confirmed this"). Whether
they merge into one confirmation event, or stay deliberately separate (delivery confirmation vs.
invoice sign-off, which could happen at different times), is not decided here.
5. **`05_delivery_ride.md` and `06_sale_ride.md`** both go stale the moment this design ships and
need their own rewrite/retirement — tracked as follow-up, not attempted in this pass.
6. **`LoadingSheet` generation moves from route-scoped to PO-scoped.** `LoadingSheetRepository`'s
existing `GetAggregatedOrderItemsByRouteIdAsync` (aggregating by `Order.Store.FK_RouteId`) is
superseded by generating one `LoadingSheet` per PO at confirm time. This directly answers part of
`ride_lifecycle.md`'s Impact item 2 (loading sheet auto-population without a route) — but raises a
new gap: nothing in this design says how/when a PO-scoped `LoadingSheet` gets matched to an actual
Ride/vehicle for physical loading, since `ride_lifecycle.md` also removed `FK_SourceWarehouseId`
and any route/store linkage from `Ride`. Flagged as [Open Question 7](#open-questions), not solved
here.
None of the above are implemented or redesigned by this document. They're called out so the gap is
visible and intentional rather than discovered later as a silent regression.
1. **`SaleService.CreateAsync`** — currently the only path that creates an `Order`, always with a
non-null `FK_SaleId`. With `FK_SaleId` now optional, a new PO-creation path needs to exist.
All code that assumes `Order.Sale` is non-null needs auditing.
2. **`Payment` attachment** — `Payment` currently attaches to `Sale`. Once `Invoice` exists as the
real billing document, `Payment` more naturally attaches to `Invoice`. Deferred to
[19_finance_accounting.md](../19_finance_accounting.md).
3. **`05_delivery_ride.md` and `06_sale_ride.md`** go stale when this ships — need rewrite/retirement.
4. **`LoadingSheet` population** moves from route-scoped to dispatcher-triggered, multi-PO
aggregation. See [loading_event.md](./loading_event.md).
---
## Open Questions
1. ~~**Signature/OTP mechanism for the Pending → Draft transition**~~ — **Resolved:** dropped
entirely. Pending → Draft is driven by a plain PO-confirm action (`POST /api/orders/{id}/confirm`),
not a customer signature or OTP. See [State Machine](#state-machine).
2. **One Invoice per PO, or multiple?** — this document assumes a 1:1 `Order`→`Invoice` relationship
for v1. Whether a PO could ever need multiple invoices (e.g. split billing) is not designed here.
3. **Should `Order.Status` and `Invoice.Status` eventually merge into one status axis?** — kept
deliberately separate in this pass (fulfillment progress vs. document/billing lifecycle); not
decided whether that separation should be permanent.
4. **How does `Payment` attach once `Invoice` exists?** — see Impact item 2. Not designed here.
5. **Is there a cancellation/void path for a PO or Invoice?** — the state machine in this document
has no cancel branch (unlike the Ride state machine's `CancelRequested`/`Cancelled`). Not
addressed here.
6. **Does `DeliveryTask.CustomerConfirmed` merge with the Draft transition?** — see Impact item 4.
7. ~~**How does a PO-scoped `LoadingSheet` get matched to an actual Ride/vehicle?**~~ — **Resolved
in [loading_event.md](./loading_event.md):** the sheet starts `Planned` (ride-less), and a new
`POST /{id}/assign-ride` action attaches a `Ride` + source warehouse, moving it to `Assigned`.
8. **Does editing a PO's items after confirm (while still Draft) re-sync the already-generated
`LoadingSheet`?** — this document allows PO edits through Draft, but doesn't decide whether the
`LoadingSheet` regenerates, gets manually re-synced, or is left to drift until Finalize reconciles
it. Not designed here.
1. ~~**Signature/OTP on PO confirmation**~~ — **Resolved:** dropped. Plain action, no OTP.
2. ~~**One Invoice per PO or multiple?**~~ — **Resolved:** 1:1 for v1.
3. **Should `Order.Status` and `Invoice.Status` eventually merge?** — kept separate; not decided.
4. **How does `Payment` attach to `Invoice`?** — see Impact item 2. Not designed here.
5. **Cancellation cascade — Invoice and LoadingSheet** — a Shop PO can be cancelled (no Invoice
exists yet, no cascade). A Confirmed PO can be cancelled even after confirmation. If the PO
already has a linked Invoice, what happens to that Invoice (voided? stays Draft?) is unresolved.
If the PO's items were pulled into a loading sheet, what happens to those loading sheet items is
also unresolved. Both cases need explicit cascade rules before implementation.
6. **Exact adjustment payload for `POST /finalize`** — 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.
---
## Migration Notes (for when this is implemented)
Not part of this docs-only pass, but captured for the eventual implementation step:
- Make `Order.FK_SaleId` nullable in `Models/Order.cs` and its EF/Fluent configuration; audit all
code paths that currently assume it's non-null.
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `ConfirmedAt`, `FinalizedAt`, `FinalizedBy`,
`TotalAmount`, plus `Template` base fields) and the `InvoiceStatus` enum.
- Make `Order.FK_SaleId` nullable in `Models/Order.cs`; audit all code paths that assume non-null.
- Add `Order.InvoiceId` (Guid?, no FK constraint) to `Models/Order.cs` — configure in Fluent API
to ensure no referential constraint is generated.
- Add `Models/Invoice.cs` (`FK_OrderId`, `Status`, `CreatedBy`, `PrintedAt`, `PrintedBy`,
`FinalizedAt`, `FinalizedBy`, `TotalAmount`, plus `Template` base fields) and the `InvoiceStatus`
enum (`Draft`, `Printed`, `Finalized`).
- New `Features/Invoice/` folder (`InvoiceController`, `InvoiceService`, `IInvoiceRepository`,
`InvoiceRepository`, `InvoiceDTOs`) — do not place this under `Features/PurchaseOrder/` or reuse
any of its DTOs/names, to avoid the supplier-PO naming collision flagged above.
- Add `POST /api/orders/{id}/confirm` on the `Order`/PO feature (not on `Invoice`) — it sets
`Invoice.ConfirmedAt`/`Status = Draft` and creates the `LoadingSheet` in one action; no
`POST /api/invoices/{id}/draft` endpoint and no signature/OTP capture are needed.
- `POST /api/invoices/{id}/finalize` endpoint; the finalize endpoint's adjustment/deduction payload
should reuse the `CreditNote` shape from [18_returns_claims.md](../18_returns_claims.md) where
applicable rather than inventing a parallel mechanism.
- Add `FK_OrderId` to `Models/LoadingSheet.cs` (or equivalent) and change
`LoadingSheetService`/`LoadingSheetRepository` to generate a sheet from a PO's `OrderItem` lines
directly, replacing `GetAggregatedOrderItemsByRouteIdAsync`'s route-based aggregation — wire this
call into the new PO-confirm action.
- A new PO-creation path independent of `SaleService.CreateAsync`, to support creating an
`Order`/`Invoice` before any `Sale` exists (Impact item 1) — needs its own design, not specified
here.
- Resolve Open Question 2 (one Invoice per PO) before finalizing the `FK_OrderId` uniqueness
constraint on `Invoice`; resolve Open Questions 7–8 (LoadingSheet-to-Ride matching, and re-sync on
post-confirm PO edits) before wiring the PO-confirm action into `LoadingSheetService`.
`InvoiceRepository`, `InvoiceDTOs`) — do not place under `Features/PurchaseOrder/`.
- Add system configuration flag `InvoiceCreationMode` (`"A"` or `"B"`) — gate Mode A and Mode B
endpoints accordingly so only the active mode's endpoints are reachable.
- **Mode A endpoint:** `POST /api/orders/{id}/confirm-and-create-invoice` — atomic: locks
`OrderItem` rows, creates `Invoice` at `Draft`, sets `Order.InvoiceId` in one transaction.
- **Mode B endpoints:** `POST /api/orders/{id}/confirm` (locks items only) + `POST /api/invoices`
`{ orderId }` (creates Invoice at `Draft`, sets `Order.InvoiceId`). Add `GET /api/orders` filter
params `agentId`, `routeId`, `shopId` to support the office user's PO selection UI.
- `POST /api/invoices/{id}/print` — sets `PrintedAt`/`PrintedBy`, transitions to `Printed`.
- `POST /api/invoices/{id}/finalize` — accepts adjustment payload; locks `TotalAmount`; sets
`FinalizedAt`/`FinalizedBy`. Resolve Open Question 6 before specifying the exact payload shape.
- `LoadingSheet` population is a separate flow — see [dispatch_pipeline.md](./dispatch_pipeline.md).
- A new PO-creation path independent of `SaleService.CreateAsync` is needed — not specified here.
- Add `POST /api/orders/{id}/cancel` — cancels a Shop PO or Confirmed PO. Resolve Open Question 5
(cascade to Invoice and LoadingSheet) before implementing for the Confirmed PO case.
@@ -0,0 +1,704 @@
# 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