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.
This commit is contained in:
@@ -17,9 +17,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<UomConversion> UomConversions { get; set; }
|
public DbSet<UomConversion> UomConversions { get; set; }
|
||||||
public DbSet<Item> Items { get; set; }
|
public DbSet<Item> Items { get; set; }
|
||||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||||
public DbSet<Sale> Sales { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<Order> Orders { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
public DbSet<Invoice> Invoices { get; set; }
|
||||||
|
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
public DbSet<Role> Roles { 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<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
||||||
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||||
|
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<OrderItem>()
|
// --- Composite PKs ---
|
||||||
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
|
|
||||||
|
|
||||||
modelBuilder.Entity<RolePermission>()
|
modelBuilder.Entity<RolePermission>()
|
||||||
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
|
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
|
||||||
@@ -79,11 +80,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<UserStoreScope>()
|
modelBuilder.Entity<UserStoreScope>()
|
||||||
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
|
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
|
||||||
|
|
||||||
modelBuilder.Entity<User>()
|
modelBuilder.Entity<LoadingSheetOrder>()
|
||||||
.HasOne(u => u.ReportsTo)
|
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(u => u.FK_ReportsToUserId)
|
// --- Unique indexes ---
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<StockBalance>()
|
modelBuilder.Entity<StockBalance>()
|
||||||
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
|
.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 })
|
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<Order>()
|
|
||||||
.HasIndex(o => o.FK_SaleId)
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<DriverProfile>()
|
modelBuilder.Entity<DriverProfile>()
|
||||||
.HasIndex(dp => dp.FK_UserId)
|
.HasIndex(dp => dp.FK_UserId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
// --- User ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<User>()
|
||||||
|
.HasOne(u => u.ReportsTo)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(u => u.FK_ReportsToUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Vehicle ---
|
||||||
|
|
||||||
modelBuilder.Entity<Vehicle>()
|
modelBuilder.Entity<Vehicle>()
|
||||||
.HasOne<Warehouse>()
|
.HasOne<Warehouse>()
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -108,11 +114,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired(false);
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryRun>()
|
// --- DeliveryRun ---
|
||||||
.HasOne(r => r.SourceWarehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(r => r.FK_SourceWarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryRun>()
|
modelBuilder.Entity<DeliveryRun>()
|
||||||
.HasOne(r => r.DriverProfile)
|
.HasOne(r => r.DriverProfile)
|
||||||
@@ -120,30 +122,97 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(r => r.FK_DriverProfileId)
|
.HasForeignKey(r => r.FK_DriverProfileId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>()
|
// --- ShopPO ---
|
||||||
.HasOne(s => s.Store)
|
|
||||||
|
modelBuilder.Entity<ShopPO>()
|
||||||
|
.HasOne(p => p.Store)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(s => s.FK_StoreId)
|
.HasForeignKey(p => p.FK_StoreId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>()
|
// InvoiceId is a plain column — no FK constraint
|
||||||
.HasOne(s => s.CreatedByUser)
|
modelBuilder.Entity<ShopPO>()
|
||||||
.WithMany()
|
.Property(p => p.InvoiceId)
|
||||||
.HasForeignKey(s => s.CreatedByUserId)
|
.HasColumnName("InvoiceId");
|
||||||
|
|
||||||
|
// --- ShopPOItem ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<ShopPOItem>()
|
||||||
|
.HasOne(i => i.ShopPO)
|
||||||
|
.WithMany(p => p.Items)
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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)
|
.HasOne(s => s.Ride)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(s => s.FK_RideId)
|
.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);
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<Order>()
|
// --- SchemeRedemption ---
|
||||||
.HasOne(o => o.Sale)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(o => o.FK_SaleId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Scheme)
|
.HasOne(r => r.Scheme)
|
||||||
@@ -152,10 +221,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Order)
|
.HasOne(r => r.ShopPO)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(r => r.FK_OrderId)
|
.HasForeignKey(r => r.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.FreeItem)
|
.HasOne(r => r.FreeItem)
|
||||||
@@ -163,6 +233,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(r => r.FK_FreeItemId)
|
.HasForeignKey(r => r.FK_FreeItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Scheme ---
|
||||||
|
|
||||||
modelBuilder.Entity<Scheme>()
|
modelBuilder.Entity<Scheme>()
|
||||||
.HasOne(s => s.TriggerItem)
|
.HasOne(s => s.TriggerItem)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -191,23 +263,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
|
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheet>()
|
// --- PurchaseOrder ---
|
||||||
.HasOne(s => s.SourceWarehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(s => s.FK_SourceWarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheet>()
|
|
||||||
.HasOne(s => s.Ride)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(s => s.FK_RideId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheetItem>()
|
|
||||||
.HasOne(i => i.Item)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(i => i.FK_ItemId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseOrder>()
|
modelBuilder.Entity<PurchaseOrder>()
|
||||||
.HasOne(po => po.Supplier)
|
.HasOne(po => po.Supplier)
|
||||||
@@ -227,6 +283,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ItemId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderId);
|
||||||
|
|
||||||
|
// --- GoodsReceipt ---
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceipt>()
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
.HasOne(gr => gr.PurchaseOrder)
|
.HasOne(gr => gr.PurchaseOrder)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -251,6 +312,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
||||||
|
|
||||||
|
// --- PurchaseReturn ---
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseReturn>()
|
modelBuilder.Entity<PurchaseReturn>()
|
||||||
.HasOne(pr => pr.PurchaseOrder)
|
.HasOne(pr => pr.PurchaseOrder)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -269,22 +338,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ItemId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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>()
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
.HasIndex(i => i.FK_PurchaseReturnId);
|
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||||
|
|
||||||
|
// --- SaleReturn ---
|
||||||
|
|
||||||
modelBuilder.Entity<SaleReturn>()
|
modelBuilder.Entity<SaleReturn>()
|
||||||
.HasOne(r => r.Order)
|
.HasOne(r => r.ShopPO)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(r => r.FK_OrderId)
|
.HasForeignKey(r => r.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<SaleReturn>()
|
modelBuilder.Entity<SaleReturn>()
|
||||||
@@ -308,12 +370,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SaleReturnItem>()
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
.HasIndex(i => i.FK_SaleReturnId);
|
.HasIndex(i => i.FK_SaleReturnId);
|
||||||
|
|
||||||
|
// --- CreditNote ---
|
||||||
|
|
||||||
modelBuilder.Entity<CreditNote>()
|
modelBuilder.Entity<CreditNote>()
|
||||||
.HasOne(n => n.SaleReturn)
|
.HasOne(n => n.SaleReturn)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(n => n.FK_SaleReturnId)
|
.HasForeignKey(n => n.FK_SaleReturnId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Claims ---
|
||||||
|
|
||||||
modelBuilder.Entity<Claim>()
|
modelBuilder.Entity<Claim>()
|
||||||
.HasOne(c => c.AgencyUser)
|
.HasOne(c => c.AgencyUser)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -366,6 +432,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SupplierClaimItem>()
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
.HasIndex(i => i.FK_SupplierClaimId);
|
.HasIndex(i => i.FK_SupplierClaimId);
|
||||||
|
|
||||||
|
// --- DeliveryTask ---
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryTask>()
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
.HasOne(t => t.Ride)
|
.HasOne(t => t.Ride)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -373,11 +441,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryTask>()
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
.HasOne(t => t.Order)
|
.HasOne(t => t.ShopPO)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(t => t.FK_OrderId)
|
.HasForeignKey(t => t.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Regional ---
|
||||||
|
|
||||||
modelBuilder.Entity<RegionalTarget>()
|
modelBuilder.Entity<RegionalTarget>()
|
||||||
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -400,6 +470,19 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(r => r.FK_RegionId)
|
.HasIndex(r => r.FK_RegionId)
|
||||||
.IsUnique();
|
.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.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||||
|
|||||||
@@ -80,15 +80,15 @@ public static class CommerceSeed
|
|||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>().HasData(
|
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(
|
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(
|
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(
|
modelBuilder.Entity<SaleReturnItem>().HasData(
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ public static class OperationsDataSeed
|
|||||||
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
|
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
|
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 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 ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
|
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 ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
|
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
|
||||||
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
|
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");
|
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
@@ -31,16 +33,12 @@ public static class OperationsDataSeed
|
|||||||
new DeliveryRun
|
new DeliveryRun
|
||||||
{
|
{
|
||||||
Id = ClosedSaleRunId,
|
Id = ClosedSaleRunId,
|
||||||
RideType = RideType.Sale,
|
|
||||||
Status = RideStatus.Closed,
|
Status = RideStatus.Closed,
|
||||||
FK_VehicleId = MasterDataSeed.VanVehicleId,
|
FK_VehicleId = MasterDataSeed.VanVehicleId,
|
||||||
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
|
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
|
||||||
FK_RouteId = MasterDataSeed.ColomboRouteId,
|
|
||||||
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
|
||||||
Meterstart = 10500,
|
Meterstart = 10500,
|
||||||
Meterend = 10550,
|
Meterend = 10550,
|
||||||
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
|
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),
|
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
@@ -48,86 +46,71 @@ public static class OperationsDataSeed
|
|||||||
new DeliveryRun
|
new DeliveryRun
|
||||||
{
|
{
|
||||||
Id = ActiveDeliveryRunId,
|
Id = ActiveDeliveryRunId,
|
||||||
RideType = RideType.Delivery,
|
Status = RideStatus.Open,
|
||||||
Status = RideStatus.Active,
|
|
||||||
FK_VehicleId = MasterDataSeed.TruckVehicleId,
|
FK_VehicleId = MasterDataSeed.TruckVehicleId,
|
||||||
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
|
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
|
||||||
FK_RouteId = MasterDataSeed.KandyRouteId,
|
|
||||||
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
|
|
||||||
Meterstart = 20300,
|
Meterstart = 20300,
|
||||||
Meterend = 0,
|
Meterend = 0,
|
||||||
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
|
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
|
||||||
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
|
|
||||||
ClosedAt = null,
|
ClosedAt = null,
|
||||||
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
|
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>().HasData(
|
modelBuilder.Entity<ShopPO>().HasData(
|
||||||
new Sale
|
new ShopPO
|
||||||
{
|
{
|
||||||
Id = Sale1Id,
|
Id = ShopPO1Id,
|
||||||
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
|
||||||
FK_StoreId = MasterDataSeed.FortStoreId,
|
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||||
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
|
RaisedById = MasterDataSeed.SalesRep1UserId,
|
||||||
FK_RideId = ClosedSaleRunId,
|
InvoiceId = Invoice1Id,
|
||||||
TotalAmount = 3000m,
|
TotalAmount = 3000m,
|
||||||
Status = SaleStatus.Completed,
|
Status = ShopPOStatus.Fulfilled,
|
||||||
PaymentStatus = PaymentStatus.Paid,
|
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
|
||||||
FulfillImmediately = false,
|
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
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,
|
|
||||||
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new Order
|
new ShopPO
|
||||||
{
|
{
|
||||||
Id = Order2Id,
|
Id = ShopPO2Id,
|
||||||
FK_SaleId = Sale2Id,
|
|
||||||
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
|
||||||
FK_StoreId = MasterDataSeed.KandyStoreId,
|
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||||
|
RaisedById = MasterDataSeed.SalesRep2UserId,
|
||||||
|
InvoiceId = null,
|
||||||
TotalAmount = 1200m,
|
TotalAmount = 1200m,
|
||||||
Status = OrderStatus.Pending,
|
Status = ShopPOStatus.Pending,
|
||||||
|
ConfirmedAt = null,
|
||||||
|
ConfirmedBy = null,
|
||||||
FulfilledAt = null,
|
FulfilledAt = null,
|
||||||
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<OrderItem>().HasData(
|
modelBuilder.Entity<ShopPOItem>().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 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 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 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 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 }
|
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(
|
modelBuilder.Entity<LoadingSheet>().HasData(
|
||||||
@@ -135,20 +118,19 @@ public static class OperationsDataSeed
|
|||||||
{
|
{
|
||||||
Id = LoadingSheet1Id,
|
Id = LoadingSheet1Id,
|
||||||
FK_RideId = ClosedSaleRunId,
|
FK_RideId = ClosedSaleRunId,
|
||||||
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
Status = LoadingSheetStatus.Confirmed,
|
||||||
Status = LoadingSheetStatus.ReceiptConfirmed,
|
PopulatedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
|
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
|
||||||
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
|
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
|
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheetItem>().HasData(
|
modelBuilder.Entity<LoadingSheetItem>().HasData(
|
||||||
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, 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, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
|
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(
|
modelBuilder.Entity<DeliveryTask>().HasData(
|
||||||
@@ -156,7 +138,7 @@ public static class OperationsDataSeed
|
|||||||
{
|
{
|
||||||
Id = DeliveryTask1Id,
|
Id = DeliveryTask1Id,
|
||||||
FK_RideId = ActiveDeliveryRunId,
|
FK_RideId = ActiveDeliveryRunId,
|
||||||
FK_OrderId = Order2Id,
|
FK_ShopPOId = ShopPO2Id,
|
||||||
Sequence = 1,
|
Sequence = 1,
|
||||||
Status = DeliveryTaskStatus.Pending,
|
Status = DeliveryTaskStatus.Pending,
|
||||||
CustomerConfirmed = false,
|
CustomerConfirmed = false,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.DeliveryRide;
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
|
|||||||
public record DeliveryTaskResponse(
|
public record DeliveryTaskResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid FK_RideId,
|
Guid FK_RideId,
|
||||||
Guid FK_OrderId,
|
Guid FK_ShopPOId,
|
||||||
Guid StoreId,
|
Guid StoreId,
|
||||||
string StoreName,
|
string StoreName,
|
||||||
int Sequence,
|
int Sequence,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -8,27 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
|
|||||||
{
|
{
|
||||||
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
||||||
await context.DeliveryTasks
|
await context.DeliveryTasks
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.Store)
|
.ThenInclude(o => o.Store)
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.OrderItems)
|
.ThenInclude(o => o.Items.Where(i => i.IsActive))
|
||||||
.ThenInclude(i => i.Item)
|
.ThenInclude(i => i.Item)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id);
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
||||||
await context.DeliveryTasks
|
await context.DeliveryTasks
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.Store)
|
.ThenInclude(o => o.Store)
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.OrderItems)
|
.ThenInclude(o => o.Items.Where(i => i.IsActive))
|
||||||
.ThenInclude(i => i.Item)
|
.ThenInclude(i => i.Item)
|
||||||
.Where(t => t.FK_RideId == rideId)
|
.Where(t => t.FK_RideId == rideId)
|
||||||
.OrderBy(t => t.Sequence)
|
.OrderBy(t => t.Sequence)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
|
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
|
||||||
await context.DeliveryTasks
|
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) =>
|
public async Task AddAsync(DeliveryTask task) =>
|
||||||
await context.DeliveryTasks.AddAsync(task);
|
await context.DeliveryTasks.AddAsync(task);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Distribution.Features.Order;
|
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.Sale;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -10,23 +9,22 @@ namespace Distribution.Features.DeliveryRide;
|
|||||||
public class DeliveryTaskService(
|
public class DeliveryTaskService(
|
||||||
IDeliveryTaskRepository repo,
|
IDeliveryTaskRepository repo,
|
||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IOrderRepository orderRepo,
|
IShopPORepository shopPORepo,
|
||||||
ISaleRepository saleRepo,
|
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
new(t.Id,
|
new(t.Id,
|
||||||
t.FK_RideId,
|
t.FK_RideId,
|
||||||
t.FK_OrderId,
|
t.FK_ShopPOId,
|
||||||
t.Order.FK_StoreId,
|
t.ShopPO.FK_StoreId,
|
||||||
t.Order.Store?.StoreName ?? string.Empty,
|
t.ShopPO.Store?.StoreName ?? string.Empty,
|
||||||
t.Sequence,
|
t.Sequence,
|
||||||
t.Status,
|
t.Status,
|
||||||
t.CustomerConfirmed,
|
t.CustomerConfirmed,
|
||||||
t.ConfirmedAt,
|
t.ConfirmedAt,
|
||||||
t.Note,
|
t.Note,
|
||||||
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
|
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
|
||||||
i.FK_ItemId,
|
i.FK_ItemId,
|
||||||
i.Item?.Name ?? string.Empty,
|
i.Item?.Name ?? string.Empty,
|
||||||
i.Quantity,
|
i.Quantity,
|
||||||
@@ -34,52 +32,8 @@ public class DeliveryTaskService(
|
|||||||
i.UnitPrice)).ToList(),
|
i.UnitPrice)).ToList(),
|
||||||
t.CreatedAt);
|
t.CreatedAt);
|
||||||
|
|
||||||
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
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.");
|
||||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
if (ride.Status != RideStatus.Active)
|
|
||||||
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
|
|
||||||
|
|
||||||
if (ride.RideType != RideType.Delivery)
|
|
||||||
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
|
|
||||||
|
|
||||||
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
|
|
||||||
|
|
||||||
var created = new List<DeliveryTask>();
|
|
||||||
int sequence = 1;
|
|
||||||
|
|
||||||
foreach (var order in orders)
|
|
||||||
{
|
|
||||||
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var task = new DeliveryTask
|
|
||||||
{
|
|
||||||
FK_RideId = rideId,
|
|
||||||
FK_OrderId = order.Id,
|
|
||||||
Sequence = sequence++,
|
|
||||||
Status = DeliveryTaskStatus.Pending
|
|
||||||
};
|
|
||||||
created.Add(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (created.Count == 0)
|
|
||||||
return new List<DeliveryTaskResponse>();
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var task in created)
|
|
||||||
await repo.AddAsync(task);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
var saved = await repo.GetByRideIdAsync(rideId);
|
|
||||||
return saved.Select(ToResponse).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
||||||
{
|
{
|
||||||
@@ -103,16 +57,16 @@ public class DeliveryTaskService(
|
|||||||
|
|
||||||
foreach (var input in request.Items)
|
foreach (var input in request.Items)
|
||||||
{
|
{
|
||||||
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
|
||||||
|
|
||||||
if (input.QuantityFulfilled < 0)
|
if (input.QuantityFulfilled < 0)
|
||||||
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
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}");
|
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
||||||
|
|
||||||
orderItem.QuantityFulfilled = input.QuantityFulfilled;
|
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
@@ -135,7 +89,7 @@ public class DeliveryTaskService(
|
|||||||
if (task.Status != DeliveryTaskStatus.Pending)
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
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");
|
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)
|
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
||||||
@@ -143,21 +97,21 @@ public class DeliveryTaskService(
|
|||||||
|
|
||||||
var vehicleId = ride.FK_VehicleId;
|
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.CustomerConfirmed = true;
|
||||||
task.ConfirmedAt = DateTime.Now;
|
task.ConfirmedAt = DateTime.Now;
|
||||||
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
||||||
|
|
||||||
var order = task.Order;
|
var shopPO = task.ShopPO;
|
||||||
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
|
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
|
||||||
if (fullyDelivered)
|
if (fullyDelivered)
|
||||||
order.FulfilledAt = DateTime.Now;
|
shopPO.FulfilledAt = DateTime.Now;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
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 vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
||||||
var availableQuantity = vehicleBalance?.Quantity ?? 0;
|
var availableQuantity = vehicleBalance?.Quantity ?? 0;
|
||||||
@@ -180,21 +134,12 @@ public class DeliveryTaskService(
|
|||||||
ToLocationType = LocationType.External,
|
ToLocationType = LocationType.External,
|
||||||
Quantity = item.QuantityFulfilled,
|
Quantity = item.QuantityFulfilled,
|
||||||
Type = MovementType.Sale,
|
Type = MovementType.Sale,
|
||||||
ReferenceId = task.FK_OrderId
|
ReferenceId = task.FK_ShopPOId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.Update(task);
|
repo.Update(task);
|
||||||
orderRepo.Update(order);
|
shopPORepo.Update(shopPO);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
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<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
|
||||||
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
|
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
|
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
|
||||||
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
|
|
||||||
Task AddAsync(LoadingSheet sheet);
|
Task AddAsync(LoadingSheet sheet);
|
||||||
void Update(LoadingSheet sheet);
|
void Update(LoadingSheet sheet);
|
||||||
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -29,9 +29,10 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
try
|
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);
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -50,90 +51,13 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-dispatch")]
|
[HttpPost("{id:guid}/confirm")]
|
||||||
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
var result = await service.ConfirmAsync(id, userId, ct);
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}/receipt-items")]
|
|
||||||
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.SetReceiptItemsAsync(id, request, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-receipt")]
|
|
||||||
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id:guid}/resolve")]
|
|
||||||
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.ResolveAsync(id, request, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/dispatch")]
|
|
||||||
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/receipt")]
|
|
||||||
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/resolve")]
|
|
||||||
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.PatchResolveAsync(id, userId, ct);
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
|||||||
@@ -5,37 +5,27 @@ namespace Distribution.Features.LoadingEvent;
|
|||||||
public record LoadingSheetResponse(
|
public record LoadingSheetResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid FK_RideId,
|
Guid FK_RideId,
|
||||||
Guid FK_SourceWarehouseId,
|
|
||||||
string WarehouseName,
|
|
||||||
LoadingSheetStatus Status,
|
LoadingSheetStatus Status,
|
||||||
DateTime? DispatchConfirmedAt,
|
Guid PopulatedBy,
|
||||||
Guid? DispatchConfirmedBy,
|
DateTime PopulatedAt,
|
||||||
DateTime? ReceiptConfirmedAt,
|
DateTime? ConfirmedAt,
|
||||||
Guid? ReceiptConfirmedBy,
|
Guid? ConfirmedBy,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<LoadingSheetItemResponse> Items);
|
List<LoadingSheetItemResponse> Items,
|
||||||
|
List<LoadingSheetOrderResponse> Orders);
|
||||||
|
|
||||||
public record LoadingSheetItemResponse(
|
public record LoadingSheetItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid FK_ItemId,
|
Guid FK_ItemId,
|
||||||
string ItemName,
|
string ItemName,
|
||||||
|
Guid FK_WarehouseId,
|
||||||
|
string WarehouseName,
|
||||||
decimal QuantityDispatched,
|
decimal QuantityDispatched,
|
||||||
decimal? QuantityReceived,
|
Guid? FK_ShopPOItemId);
|
||||||
decimal? QuantityResolved);
|
|
||||||
|
public record LoadingSheetOrderResponse(Guid ShopPOId);
|
||||||
|
|
||||||
public record CreateLoadingSheetRequest(Guid FK_RideId);
|
public record CreateLoadingSheetRequest(Guid FK_RideId);
|
||||||
|
|
||||||
public record SetItemsRequest(List<SetItemInput> Items);
|
public record SetItemsRequest(List<SetItemInput> Items);
|
||||||
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
|
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
|
||||||
|
|
||||||
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
|
|
||||||
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
|
|
||||||
|
|
||||||
public record ConfirmReceiptRequest(int Meterstart);
|
|
||||||
|
|
||||||
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
|
||||||
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
|
||||||
|
|
||||||
// Combined PATCH endpoints used by the frontend
|
|
||||||
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
|
|
||||||
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
|
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
|||||||
{
|
{
|
||||||
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
|
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
|
||||||
await context.LoadingSheets
|
await context.LoadingSheets
|
||||||
.Include(s => s.SourceWarehouse)
|
|
||||||
.Include(s => s.Items).ThenInclude(i => i.Item)
|
.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);
|
.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
|
|
||||||
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
|
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
|
||||||
await context.LoadingSheets
|
await context.LoadingSheets
|
||||||
.FirstOrDefaultAsync(s =>
|
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
|
||||||
s.FK_RideId == rideId &&
|
|
||||||
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
|
|
||||||
s.Status != LoadingSheetStatus.Resolved);
|
|
||||||
|
|
||||||
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
|
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
|
||||||
await context.LoadingSheets
|
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 =>
|
.Where(s =>
|
||||||
(rideId == null || s.FK_RideId == rideId) &&
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
(status == null || s.Status == status))
|
(status == null || s.Status == status))
|
||||||
@@ -36,19 +36,6 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
|||||||
(rideId == null || s.FK_RideId == rideId) &&
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
(status == null || s.Status == status));
|
(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) =>
|
public async Task AddAsync(LoadingSheet sheet) =>
|
||||||
await context.LoadingSheets.AddAsync(sheet);
|
await context.LoadingSheets.AddAsync(sheet);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class LoadingSheetService(
|
|||||||
return sheet is null ? null : ToResponse(sheet);
|
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)
|
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
@@ -34,22 +34,16 @@ public class LoadingSheetService(
|
|||||||
|
|
||||||
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
|
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
|
||||||
if (existing is not null)
|
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
|
var sheet = new LoadingSheet
|
||||||
{
|
{
|
||||||
FK_RideId = ride.Id,
|
FK_RideId = ride.Id,
|
||||||
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
|
Status = LoadingSheetStatus.Planned,
|
||||||
Status = LoadingSheetStatus.Pending
|
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);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -67,8 +61,8 @@ public class LoadingSheetService(
|
|||||||
var sheet = await repo.GetByIdAsync(id)
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
if (sheet.Status != LoadingSheetStatus.Pending)
|
if (sheet.Status != LoadingSheetStatus.Planned)
|
||||||
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
|
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
@@ -78,6 +72,8 @@ public class LoadingSheetService(
|
|||||||
{
|
{
|
||||||
FK_LoadingSheetId = sheet.Id,
|
FK_LoadingSheetId = sheet.Id,
|
||||||
FK_ItemId = i.FK_ItemId,
|
FK_ItemId = i.FK_ItemId,
|
||||||
|
FK_WarehouseId = i.FK_WarehouseId,
|
||||||
|
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
||||||
QuantityDispatched = i.Quantity
|
QuantityDispatched = i.Quantity
|
||||||
}));
|
}));
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
@@ -88,289 +84,91 @@ public class LoadingSheetService(
|
|||||||
return ToResponse(saved!);
|
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)
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
if (sheet.Status != LoadingSheetStatus.Pending)
|
if (sheet.Status != LoadingSheetStatus.Planned)
|
||||||
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
|
throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
|
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
|
||||||
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
|
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)
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (hasDiscrepancy)
|
await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId);
|
||||||
{
|
|
||||||
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();
|
sheet.Status = LoadingSheetStatus.Confirmed;
|
||||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
sheet.ConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ConfirmedBy = userId;
|
||||||
ride.Status = RideStatus.Active;
|
|
||||||
ride.ActivatedAt = DateTime.Now;
|
|
||||||
ride.Meterstart = request.Meterstart;
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.Update(sheet);
|
repo.Update(sheet);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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)
|
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
|
||||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
|
||||||
|
|
||||||
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
|
||||||
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
|
||||||
|
|
||||||
foreach (var input in request.Items)
|
|
||||||
{
|
{
|
||||||
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
|
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
|
||||||
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
|
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
|
||||||
if (input.QuantityResolved < 0)
|
|
||||||
throw new InvalidOperationException("Resolved quantity cannot be negative");
|
|
||||||
item.QuantityResolved = input.QuantityResolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
|
if (warehouseBalance.Quantity < item.QuantityDispatched)
|
||||||
throw new InvalidOperationException("All items must have a resolved quantity");
|
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
|
||||||
|
|
||||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
warehouseBalance.Quantity -= item.QuantityDispatched;
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
sheet.Status = LoadingSheetStatus.Resolved;
|
|
||||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
|
||||||
sheet.ReceiptConfirmedBy = userId;
|
|
||||||
|
|
||||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
|
||||||
|
|
||||||
ride.Status = RideStatus.Active;
|
|
||||||
ride.ActivatedAt = DateTime.Now;
|
|
||||||
ride.Meterstart = request.Meterstart;
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
|
|
||||||
repo.Update(sheet);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return ToResponse(sheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var sheet = await repo.GetByIdAsync(id)
|
|
||||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
|
||||||
|
|
||||||
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
|
||||||
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
|
|
||||||
|
|
||||||
foreach (var input in items)
|
|
||||||
{
|
|
||||||
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
|
||||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
|
|
||||||
sheetItem.QuantityReceived = input.QuantityReceived;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (hasDiscrepancy)
|
|
||||||
{
|
|
||||||
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
|
||||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
|
||||||
sheet.ReceiptConfirmedBy = userId;
|
|
||||||
|
|
||||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
|
||||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
|
||||||
|
|
||||||
ride.Status = RideStatus.Active;
|
|
||||||
ride.ActivatedAt = DateTime.Now;
|
|
||||||
ride.Meterstart = 0;
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.Update(sheet);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return ToResponse(sheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var sheet = await repo.GetByIdAsync(id)
|
|
||||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
|
||||||
|
|
||||||
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
|
||||||
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
|
||||||
|
|
||||||
foreach (var item in sheet.Items)
|
|
||||||
{
|
|
||||||
if (!item.QuantityReceived.HasValue)
|
|
||||||
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
|
|
||||||
item.QuantityResolved = item.QuantityReceived.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
sheet.Status = LoadingSheetStatus.Resolved;
|
|
||||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
|
||||||
sheet.ReceiptConfirmedBy = userId;
|
|
||||||
|
|
||||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
|
||||||
|
|
||||||
ride.Status = RideStatus.Active;
|
|
||||||
ride.ActivatedAt = DateTime.Now;
|
|
||||||
ride.Meterstart = 0;
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
|
|
||||||
repo.Update(sheet);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return ToResponse(sheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
|
||||||
{
|
|
||||||
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
|
||||||
{
|
|
||||||
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
|
|
||||||
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
|
|
||||||
if (warehouseBalance.Quantity < qty)
|
|
||||||
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
|
|
||||||
|
|
||||||
warehouseBalance.Quantity -= qty;
|
|
||||||
warehouseBalance.UpdatedAt = DateTime.Now;
|
warehouseBalance.UpdatedAt = DateTime.Now;
|
||||||
stockRepo.UpdateBalance(warehouseBalance);
|
stockRepo.UpdateBalance(warehouseBalance);
|
||||||
|
|
||||||
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
|
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
||||||
if (vehicleBalance is null)
|
if (vehicleBalance is null)
|
||||||
{
|
{
|
||||||
await stockRepo.AddBalanceAsync(new StockBalance
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
{
|
{
|
||||||
FK_ItemId = itemId,
|
FK_ItemId = item.FK_ItemId,
|
||||||
LocationId = vehicleId,
|
LocationId = vehicleId,
|
||||||
LocationType = LocationType.Vehicle,
|
LocationType = LocationType.Vehicle,
|
||||||
Quantity = qty
|
Quantity = item.QuantityDispatched
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
vehicleBalance.Quantity += qty;
|
vehicleBalance.Quantity += item.QuantityDispatched;
|
||||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||||
stockRepo.UpdateBalance(vehicleBalance);
|
stockRepo.UpdateBalance(vehicleBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = itemId,
|
FK_ItemId = item.FK_ItemId,
|
||||||
FromLocationId = sheet.FK_SourceWarehouseId,
|
FromLocationId = item.FK_WarehouseId,
|
||||||
FromLocationType = LocationType.Warehouse,
|
FromLocationType = LocationType.Warehouse,
|
||||||
ToLocationId = vehicleId,
|
ToLocationId = vehicleId,
|
||||||
ToLocationType = LocationType.Vehicle,
|
ToLocationType = LocationType.Vehicle,
|
||||||
Quantity = qty,
|
Quantity = item.QuantityDispatched,
|
||||||
Type = MovementType.Load,
|
Type = MovementType.Load,
|
||||||
ReferenceId = sheet.Id,
|
ReferenceId = sheetId,
|
||||||
CreatedBy = userId
|
CreatedBy = userId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
|
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
|
||||||
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
|
new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
|
||||||
s.SourceWarehouse?.Name ?? string.Empty,
|
s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
|
||||||
s.Status,
|
|
||||||
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
|
|
||||||
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
|
|
||||||
s.CreatedAt,
|
|
||||||
s.Items.Select(i => new LoadingSheetItemResponse(
|
s.Items.Select(i => new LoadingSheetItemResponse(
|
||||||
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
public interface IPaymentRepository
|
public interface IPaymentRepository
|
||||||
{
|
{
|
||||||
Task<Models.Payment?> GetByIdAsync(Guid id);
|
Task<Models.Payment?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
|
Task<List<Models.Payment>> GetAllAsync(Guid? invoiceId, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
|
Task<int> GetCountAsync(Guid? invoiceId);
|
||||||
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId);
|
||||||
Task AddAsync(Models.Payment payment);
|
Task AddAsync(Models.Payment payment);
|
||||||
|
void Update(Models.Payment payment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,22 @@
|
|||||||
namespace Distribution.Features.Payment;
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/sales/{saleId:guid}/payments")]
|
[Route("api/invoices/{invoiceId:guid}/payments")]
|
||||||
public class PaymentController(PaymentService service) : ControllerBase
|
public class PaymentController(PaymentService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[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);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(saleId, request, ct);
|
var result = await service.CreateAsync(invoiceId, request, ct);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Payment;
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
public record CreatePaymentRequest(
|
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,
|
decimal Amount,
|
||||||
PaymentMethod Method,
|
PaymentMethod Method,
|
||||||
Guid? CollectedByUserId = null);
|
Guid? CollectedByUserId = null);
|
||||||
|
|
||||||
public record PaymentResponse(
|
public record PaymentResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SaleId,
|
Guid InvoiceId,
|
||||||
Guid StoreId,
|
|
||||||
decimal Amount,
|
decimal Amount,
|
||||||
PaymentMethod Method,
|
PaymentMethod Method,
|
||||||
DateTime CollectedAt,
|
DateTime CollectedAt,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -9,29 +9,27 @@ public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
|||||||
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
||||||
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
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
|
await context.Payments
|
||||||
.Where(p => p.IsActive
|
.Where(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId))
|
||||||
&& (storeId == null || p.FK_StoreId == storeId)
|
|
||||||
&& (saleId == null || p.FK_SaleId == saleId))
|
|
||||||
.OrderByDescending(p => p.CollectedAt)
|
.OrderByDescending(p => p.CollectedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
|
public async Task<int> GetCountAsync(Guid? invoiceId) =>
|
||||||
await context.Payments
|
await context.Payments
|
||||||
.Where(p => p.IsActive
|
.CountAsync(p => p.IsActive && (invoiceId == null || p.FK_InvoiceId == invoiceId));
|
||||||
&& (storeId == null || p.FK_StoreId == storeId)
|
|
||||||
&& (saleId == null || p.FK_SaleId == saleId))
|
|
||||||
.CountAsync();
|
|
||||||
|
|
||||||
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
public async Task<List<Models.Payment>> GetByInvoiceIdAsync(Guid invoiceId) =>
|
||||||
await context.Payments
|
await context.Payments
|
||||||
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
.Where(p => p.FK_InvoiceId == invoiceId && p.IsActive)
|
||||||
.OrderBy(p => p.CollectedAt)
|
.OrderBy(p => p.CollectedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task AddAsync(Models.Payment payment) =>
|
public async Task AddAsync(Models.Payment payment) =>
|
||||||
await context.Payments.AddAsync(payment);
|
await context.Payments.AddAsync(payment);
|
||||||
|
|
||||||
|
public void Update(Models.Payment payment) =>
|
||||||
|
context.Payments.Update(payment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Distribution.Features.Sale;
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.Stock;
|
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -8,51 +7,35 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public class PaymentService(
|
public class PaymentService(
|
||||||
IPaymentRepository repo,
|
IPaymentRepository repo,
|
||||||
ISaleRepository saleRepo,
|
IInvoiceRepository invoiceRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser)
|
ICurrentUserService currentUser)
|
||||||
{
|
{
|
||||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
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 invoice = await invoiceRepo.GetByIdAsync(invoiceId);
|
||||||
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
|
if (invoice is null || !invoice.IsActive) return null;
|
||||||
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
|
var payments = await repo.GetByInvoiceIdAsync(invoiceId);
|
||||||
{
|
|
||||||
var payment = await repo.GetByIdAsync(id);
|
|
||||||
return payment is null ? null : ToResponse(payment);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
|
||||||
{
|
|
||||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
|
||||||
if (sale is null || !sale.IsActive) return null;
|
|
||||||
|
|
||||||
var payments = await repo.GetBySaleIdAsync(saleId);
|
|
||||||
return payments.Select(ToResponse).ToList();
|
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);
|
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
|
||||||
if (sale is null || !sale.IsActive) return null;
|
if (invoice is null || !invoice.IsActive) return null;
|
||||||
if (sale.Status == SaleStatus.Cancelled)
|
if (invoice.Status == InvoiceStatus.Finalized)
|
||||||
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
|
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
|
var userId = currentUser.UserId ?? request.CollectedByUserId
|
||||||
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
|
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
|
||||||
|
|
||||||
var payment = new Models.Payment
|
var payment = new Models.Payment
|
||||||
{
|
{
|
||||||
FK_SaleId = saleId,
|
FK_InvoiceId = invoiceId,
|
||||||
FK_StoreId = sale.FK_StoreId,
|
|
||||||
Amount = request.Amount,
|
Amount = request.Amount,
|
||||||
Method = request.Method,
|
Method = request.Method,
|
||||||
CollectedAt = DateTime.Now,
|
CollectedAt = DateTime.Now,
|
||||||
@@ -64,17 +47,16 @@ public class PaymentService(
|
|||||||
{
|
{
|
||||||
await repo.AddAsync(payment);
|
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;
|
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||||
|
|
||||||
sale.PaymentStatus = total == 0
|
invoice.PaymentStatus = total == 0
|
||||||
? PaymentStatus.Pending
|
? PaymentStatus.Pending
|
||||||
: total < sale.TotalAmount
|
: total < invoice.TotalAmount
|
||||||
? PaymentStatus.PartiallyPaid
|
? PaymentStatus.PartiallyPaid
|
||||||
: PaymentStatus.Paid;
|
: PaymentStatus.Paid;
|
||||||
|
|
||||||
saleRepo.Update(sale);
|
invoiceRepo.Update(invoice);
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|||||||
@@ -42,5 +42,5 @@ public interface IRegionRepository
|
|||||||
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
|
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
|
||||||
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
|
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) =>
|
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
|
||||||
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
|
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) =>
|
public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
|
||||||
await (from o in context.Orders
|
await (from o in context.ShopPOs
|
||||||
join s in context.Stores on o.FK_StoreId equals s.Id
|
join s in context.Stores on o.FK_StoreId equals s.Id
|
||||||
join r in context.Routes on s.FK_RouteId equals r.Id
|
join r in context.Routes on s.FK_RouteId equals r.Id
|
||||||
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
|
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();
|
select o).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
|||||||
budget?.BudgetAmount,
|
budget?.BudgetAmount,
|
||||||
orders.Count,
|
orders.Count,
|
||||||
orders.Sum(o => o.TotalAmount),
|
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,
|
taxRule?.RuleConfig,
|
||||||
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
|
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ public interface IReturnRepository
|
|||||||
{
|
{
|
||||||
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
|
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.SaleReturn>> GetAllAsync();
|
Task<List<Models.SaleReturn>> GetAllAsync();
|
||||||
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
|
Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId);
|
||||||
Task AddAsync(Models.SaleReturn entity);
|
Task AddAsync(Models.SaleReturn entity);
|
||||||
void Update(Models.SaleReturn entity);
|
void Update(Models.SaleReturn entity);
|
||||||
Task AddCreditNoteAsync(Models.CreditNote 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);
|
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Returns;
|
namespace Distribution.Features.Returns;
|
||||||
@@ -28,15 +28,13 @@ public class ReturnController(ReturnService service, ICurrentUserService current
|
|||||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/orders/{id:guid}/returns")]
|
[HttpGet("api/shop-pos/{id:guid}/returns")]
|
||||||
public async Task<IActionResult> GetByOrder(Guid id) =>
|
public async Task<IActionResult> GetByShopPO(Guid id) =>
|
||||||
Ok(await service.GetByOrderIdAsync(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)
|
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;
|
var userId = currentUser.UserId ?? request.CreatedByUserId;
|
||||||
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
|
||||||
try
|
try
|
||||||
@@ -44,6 +42,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
|
|||||||
var result = await service.CreateAsync(id, request, userId.Value, ct);
|
var result = await service.CreateAsync(id, request, userId.Value, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
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 }); }
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public record SaleReturnItemResponse(
|
|||||||
|
|
||||||
public record SaleReturnResponse(
|
public record SaleReturnResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid OrderId,
|
Guid ShopPOId,
|
||||||
ReturnMode Mode,
|
ReturnMode Mode,
|
||||||
decimal TotalCreditAmount,
|
decimal TotalCreditAmount,
|
||||||
decimal OrderTotalAmountAfter,
|
decimal OrderTotalAmountAfter,
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
|
|||||||
{
|
{
|
||||||
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
|
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
|
||||||
await context.SaleReturns
|
await context.SaleReturns
|
||||||
.Include(r => r.Order)
|
.Include(r => r.ShopPO)
|
||||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
.FirstOrDefaultAsync(r => r.Id == id);
|
.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
|
||||||
public async Task<List<Models.SaleReturn>> GetAllAsync() =>
|
public async Task<List<Models.SaleReturn>> GetAllAsync() =>
|
||||||
await context.SaleReturns
|
await context.SaleReturns
|
||||||
.Include(r => r.Order)
|
.Include(r => r.ShopPO)
|
||||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
.Where(r => r.IsActive)
|
.Where(r => r.IsActive)
|
||||||
.OrderByDescending(r => r.CreatedAt)
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
|
public async Task<List<Models.SaleReturn>> GetByShopPOIdAsync(Guid shopPOId) =>
|
||||||
await context.SaleReturns
|
await context.SaleReturns
|
||||||
.Include(r => r.Order)
|
.Include(r => r.ShopPO)
|
||||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
.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)
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ public class ReturnRepository(AppDBContext context) : IReturnRepository
|
|||||||
await context.CreditNotes.AddAsync(entity);
|
await context.CreditNotes.AddAsync(entity);
|
||||||
|
|
||||||
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
|
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
|
||||||
await context.OrderItems
|
await context.ShopPOItems
|
||||||
.Include(oi => oi.Order)
|
.Include(oi => oi.ShopPO)
|
||||||
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
|
.AnyAsync(oi => oi.ShopPO.FK_StoreId == storeId && oi.ShopPO.IsActive && oi.FK_ItemId == itemId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.Order;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Ride;
|
|
||||||
using Distribution.Features.Sale;
|
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -10,9 +8,7 @@ namespace Distribution.Features.Returns;
|
|||||||
|
|
||||||
public class ReturnService(
|
public class ReturnService(
|
||||||
IReturnRepository repo,
|
IReturnRepository repo,
|
||||||
IOrderRepository orderRepo,
|
IShopPORepository shopPORepo,
|
||||||
ISaleRepository saleRepo,
|
|
||||||
IRideRepository rideRepo,
|
|
||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
IUnitOfWork uow)
|
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);
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
|
||||||
|
|
||||||
private static SaleReturnResponse Map(SaleReturn r) =>
|
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());
|
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
|
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
|
||||||
@@ -58,141 +54,9 @@ public class ReturnService(
|
|||||||
return Map(r);
|
return Map(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
|
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
|
||||||
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
|
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
|
||||||
|
|
||||||
/// <summary>
|
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
|
||||||
/// Records a return recognized during the same store visit as an open order and settles it
|
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
|
||||||
/// 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!);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
public interface IRideRepository
|
public interface IRideRepository
|
||||||
{
|
{
|
||||||
Task<DeliveryRun?> GetByIdAsync(Guid id);
|
Task<DeliveryRun?> GetByIdAsync(Guid id);
|
||||||
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
|
Task<List<DeliveryRun>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(RideStatus? status, RideType? type, Guid? driverProfileId, Guid? vehicleId);
|
Task<int> GetCountAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId);
|
||||||
Task<bool> HasOpenOrActiveRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
|
Task<bool> HasOpenRideForVehicleAsync(Guid vehicleId, Guid? excludeRideId = null);
|
||||||
Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null);
|
|
||||||
Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId);
|
|
||||||
Task AddAsync(DeliveryRun ride);
|
Task AddAsync(DeliveryRun ride);
|
||||||
void Update(DeliveryRun ride);
|
void Update(DeliveryRun ride);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record OrderSummaryModel(
|
|
||||||
Guid Id,
|
|
||||||
Guid StoreId,
|
|
||||||
string StoreName,
|
|
||||||
DateTime OrderDate,
|
|
||||||
decimal TotalAmount);
|
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Models;
|
||||||
using Distribution.Models;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/rides")]
|
[Route("api/rides")]
|
||||||
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
public class RideController(RideService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] RideStatus? status,
|
[FromQuery] RideStatus? status,
|
||||||
[FromQuery] RideType? type,
|
|
||||||
[FromQuery] Guid? driverProfileId,
|
[FromQuery] Guid? driverProfileId,
|
||||||
[FromQuery] Guid? vehicleId,
|
[FromQuery] Guid? vehicleId,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20)
|
[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);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,8 +29,12 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
try
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
{
|
||||||
|
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")]
|
[HttpPut("{id:guid}/driver")]
|
||||||
@@ -47,32 +49,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
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")]
|
[HttpPost("{id:guid}/close")]
|
||||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
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 }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/cancel-request")]
|
[HttpPost("{id:guid}/cancel")]
|
||||||
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
public async Task<IActionResult> Cancel(Guid id, CancelRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.RaiseCancelRequestAsync(id, request, userId, ct);
|
var result = await service.CancelAsync(id, request, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("cancel-requests")]
|
|
||||||
public async Task<IActionResult> GetCancelRequests(
|
|
||||||
[FromQuery] CancelRequestStatus? status,
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 20)
|
|
||||||
{
|
|
||||||
var result = await service.GetCancelRequestsAsync(status, page, pageSize);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/approve")]
|
|
||||||
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.ApproveCancelAsync(id, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/reject")]
|
|
||||||
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.RejectCancelAsync(id, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id:guid}/cancel-request")]
|
|
||||||
public async Task<IActionResult> GetCancelRequest(Guid id)
|
|
||||||
{
|
|
||||||
var result = await service.GetCancelRequestAsync(id);
|
|
||||||
return result is null ? NotFound() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("breakdown-assist/{rideId:guid}")]
|
|
||||||
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await service.GetBreakdownAssistAsync(rideId);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,27 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Distribution.Features.Stock;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
// Ride (DeliveryRun)
|
|
||||||
public record RideResponse(
|
public record RideResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
RideType RideType,
|
|
||||||
RideStatus Status,
|
RideStatus Status,
|
||||||
Guid VehicleId,
|
Guid VehicleId,
|
||||||
string VehicleNumberPlate,
|
string VehicleNumberPlate,
|
||||||
Guid DriverProfileId,
|
Guid DriverProfileId,
|
||||||
string DriverFullName,
|
string DriverFullName,
|
||||||
Guid RouteId,
|
|
||||||
string RouteName,
|
|
||||||
Guid SourceWarehouseId,
|
|
||||||
string SourceWarehouseName,
|
|
||||||
int Meterstart,
|
int Meterstart,
|
||||||
int Meterend,
|
int Meterend,
|
||||||
DateTime? StartedAt,
|
DateTime? StartedAt,
|
||||||
DateTime? ActivatedAt,
|
|
||||||
DateTime? ClosedAt,
|
DateTime? ClosedAt,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
public record CreateRideRequest(
|
public record CreateRideRequest(
|
||||||
RideType RideType,
|
|
||||||
Guid VehicleId,
|
Guid VehicleId,
|
||||||
Guid DriverProfileId,
|
Guid DriverProfileId,
|
||||||
Guid RouteId,
|
int Meterstart);
|
||||||
Guid? SourceWarehouseId);
|
|
||||||
|
|
||||||
public record AssignRideDriverRequest(Guid DriverProfileId);
|
public record AssignRideDriverRequest(Guid DriverProfileId);
|
||||||
|
|
||||||
public record ActivateRideRequest(int Meterstart);
|
|
||||||
|
|
||||||
public record CloseRideRequest(int Meterend);
|
public record CloseRideRequest(int Meterend);
|
||||||
|
|
||||||
public record RaiseCancelRequest(CancelRequestType TypeCode, string Reason);
|
public record CancelRideRequest(CancelRequestType TypeCode, string Reason);
|
||||||
|
|
||||||
// Cancel Request
|
|
||||||
public record RideCancelRequestResponse(
|
|
||||||
Guid Id,
|
|
||||||
Guid RideId,
|
|
||||||
CancelRequestType TypeCode,
|
|
||||||
string Reason,
|
|
||||||
Guid RequestedBy,
|
|
||||||
DateTime RequestedAt,
|
|
||||||
CancelRequestStatus Status,
|
|
||||||
Guid? ApprovedBy,
|
|
||||||
DateTime? ApprovedAt);
|
|
||||||
|
|
||||||
public record BreakdownAssistRideResponse(
|
|
||||||
Guid RideId,
|
|
||||||
Guid VehicleId,
|
|
||||||
string VehicleNumberPlate,
|
|
||||||
Guid DriverProfileId,
|
|
||||||
string DriverFullName,
|
|
||||||
Guid RouteId,
|
|
||||||
string RouteName,
|
|
||||||
List<StockBalanceResponse> StockLevels);
|
|
||||||
|
|
||||||
public record BreakdownAssistOrderResponse(
|
|
||||||
Guid OrderId,
|
|
||||||
Guid StoreId,
|
|
||||||
string StoreName,
|
|
||||||
DateTime OrderDate,
|
|
||||||
decimal TotalAmount);
|
|
||||||
|
|
||||||
public record BreakdownAssistResponse(
|
|
||||||
Guid RideId,
|
|
||||||
List<BreakdownAssistRideResponse> ActiveRides,
|
|
||||||
List<BreakdownAssistOrderResponse> OpenOrders);
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -10,59 +10,32 @@ public class RideRepository(AppDBContext context) : IRideRepository
|
|||||||
await context.DeliveryRuns
|
await context.DeliveryRuns
|
||||||
.Include(r => r.Vehicle)
|
.Include(r => r.Vehicle)
|
||||||
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
|
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
|
||||||
.Include(r => r.Route)
|
|
||||||
.Include(r => r.SourceWarehouse)
|
|
||||||
.FirstOrDefaultAsync(r => r.Id == id);
|
.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
|
await context.DeliveryRuns
|
||||||
.Include(r => r.Vehicle)
|
.Include(r => r.Vehicle)
|
||||||
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
|
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
|
||||||
.Include(r => r.Route)
|
|
||||||
.Include(r => r.SourceWarehouse)
|
|
||||||
.Where(r =>
|
.Where(r =>
|
||||||
(status == null || r.Status == status) &&
|
(status == null || r.Status == status) &&
|
||||||
(type == null || r.RideType == type) &&
|
|
||||||
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
|
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
|
||||||
(vehicleId == null || r.FK_VehicleId == vehicleId))
|
(vehicleId == null || r.FK_VehicleId == vehicleId))
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.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
|
await context.DeliveryRuns
|
||||||
.CountAsync(r =>
|
.CountAsync(r =>
|
||||||
(status == null || r.Status == status) &&
|
(status == null || r.Status == status) &&
|
||||||
(type == null || r.RideType == type) &&
|
|
||||||
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
|
(driverProfileId == null || r.FK_DriverProfileId == driverProfileId) &&
|
||||||
(vehicleId == null || r.FK_VehicleId == vehicleId));
|
(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 =>
|
await context.DeliveryRuns.AnyAsync(r =>
|
||||||
r.FK_VehicleId == vehicleId &&
|
r.FK_VehicleId == vehicleId &&
|
||||||
r.Id != excludeRideId &&
|
r.Id != excludeRideId &&
|
||||||
(r.Status == RideStatus.Open || r.Status == RideStatus.Active));
|
r.Status == RideStatus.Open);
|
||||||
|
|
||||||
public async Task<List<DeliveryRun>> GetActiveRidesAsync(Guid? excludeRideId = null) =>
|
|
||||||
await context.DeliveryRuns
|
|
||||||
.Include(r => r.Vehicle)
|
|
||||||
.Include(r => r.DriverProfile).ThenInclude(dp => dp.User)
|
|
||||||
.Include(r => r.Route)
|
|
||||||
.Where(r => r.Id != excludeRideId && r.Status == RideStatus.Active)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
public async Task<List<OrderSummaryModel>> GetActiveOrdersByRouteIdAsync(Guid routeId) =>
|
|
||||||
await context.Orders
|
|
||||||
.Include(o => o.Store)
|
|
||||||
.Where(o => o.IsActive && o.Store.FK_RouteId == routeId)
|
|
||||||
.OrderByDescending(o => o.OrderDate)
|
|
||||||
.Select(o => new OrderSummaryModel(
|
|
||||||
o.Id,
|
|
||||||
o.FK_StoreId,
|
|
||||||
o.Store.StoreName,
|
|
||||||
o.OrderDate,
|
|
||||||
o.TotalAmount))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
public async Task AddAsync(DeliveryRun ride) =>
|
public async Task AddAsync(DeliveryRun ride) =>
|
||||||
await context.DeliveryRuns.AddAsync(ride);
|
await context.DeliveryRuns.AddAsync(ride);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -10,21 +10,13 @@ public class RideService(
|
|||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IDriverProfileRepository driverRepo,
|
IDriverProfileRepository driverRepo,
|
||||||
IDriverComplianceConfigRepository complianceRepo,
|
IDriverComplianceConfigRepository complianceRepo,
|
||||||
IRideCancelRequestRepository cancelRepo,
|
|
||||||
Features.Vehicle.IVehicleRepository vehicleRepo,
|
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
public async Task<PagedResponse<RideResponse>> GetAllAsync(
|
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
|
||||||
RideStatus? status,
|
|
||||||
RideType? type,
|
|
||||||
Guid? driverProfileId,
|
|
||||||
Guid? vehicleId,
|
|
||||||
int page,
|
|
||||||
int pageSize)
|
|
||||||
{
|
{
|
||||||
var total = await rideRepo.GetCountAsync(status, type, driverProfileId, vehicleId);
|
var total = await rideRepo.GetCountAsync(status, driverProfileId, vehicleId);
|
||||||
var items = await rideRepo.GetAllAsync(status, type, driverProfileId, vehicleId, page, pageSize);
|
var items = await rideRepo.GetAllAsync(status, driverProfileId, vehicleId, page, pageSize);
|
||||||
return new PagedResponse<RideResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
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)
|
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var vehicle = await vehicleRepo.GetByIdAsync(request.VehicleId)
|
if (await rideRepo.HasOpenRideForVehicleAsync(request.VehicleId))
|
||||||
?? throw new InvalidOperationException("Vehicle not found");
|
throw new InvalidOperationException("Vehicle already has an open ride");
|
||||||
|
|
||||||
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.SourceWarehouseId;
|
await CheckDriverComplianceAsync(request.DriverProfileId);
|
||||||
if (!sourceWarehouseId.HasValue)
|
|
||||||
throw new InvalidOperationException("Source warehouse is required for this vehicle");
|
|
||||||
|
|
||||||
var ride = new DeliveryRun
|
var ride = new DeliveryRun
|
||||||
{
|
{
|
||||||
RideType = request.RideType,
|
|
||||||
FK_VehicleId = request.VehicleId,
|
FK_VehicleId = request.VehicleId,
|
||||||
FK_DriverProfileId = request.DriverProfileId,
|
FK_DriverProfileId = request.DriverProfileId,
|
||||||
FK_RouteId = request.RouteId,
|
Status = RideStatus.Open,
|
||||||
FK_SourceWarehouseId = sourceWarehouseId.Value,
|
Meterstart = request.Meterstart,
|
||||||
Status = RideStatus.Draft
|
StartedAt = DateTime.Now
|
||||||
};
|
};
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
@@ -59,46 +48,19 @@ public class RideService(
|
|||||||
await rideRepo.AddAsync(ride);
|
await rideRepo.AddAsync(ride);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
{
|
|
||||||
await uow.RollbackAsync(ct);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
var saved = await rideRepo.GetByIdAsync(ride.Id);
|
var saved = await rideRepo.GetByIdAsync(ride.Id);
|
||||||
return ToResponse(saved!);
|
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)
|
public async Task<RideResponse> AssignDriverAsync(Guid rideId, Guid driverProfileId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
if (ride.Status != RideStatus.Draft)
|
if (ride.Status != RideStatus.Open)
|
||||||
throw new InvalidOperationException("Driver can only be changed while the ride is in Draft status");
|
throw new InvalidOperationException("Driver can only be changed while the ride is Open");
|
||||||
|
|
||||||
var driver = await driverRepo.GetByIdAsync(driverProfileId);
|
var driver = await driverRepo.GetByIdAsync(driverProfileId);
|
||||||
if (driver is null || !driver.IsActive)
|
if (driver is null || !driver.IsActive)
|
||||||
@@ -114,31 +76,12 @@ public class RideService(
|
|||||||
return ToResponse(saved!);
|
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)
|
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? 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");
|
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
|
||||||
|
|
||||||
if (request.Meterend < ride.Meterstart)
|
if (request.Meterend < ride.Meterstart)
|
||||||
@@ -157,162 +100,21 @@ public class RideService(
|
|||||||
return ToResponse(ride);
|
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)
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
if (ride.Status != RideStatus.Active)
|
if (ride.Status != RideStatus.Open)
|
||||||
throw new InvalidOperationException($"Cannot raise a cancel request for a ride in {ride.Status} status");
|
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
|
||||||
|
|
||||||
var existing = await cancelRepo.GetPendingByRideIdAsync(rideId);
|
|
||||||
if (existing is not null)
|
|
||||||
throw new InvalidOperationException("There is already a pending cancel request for this ride");
|
|
||||||
|
|
||||||
var cancelRequest = new RideCancelRequest
|
|
||||||
{
|
|
||||||
FK_RideId = rideId,
|
|
||||||
TypeCode = request.TypeCode,
|
|
||||||
Reason = request.Reason,
|
|
||||||
RequestedBy = requestedBy,
|
|
||||||
RequestedAt = DateTime.Now,
|
|
||||||
Status = CancelRequestStatus.Pending
|
|
||||||
};
|
|
||||||
|
|
||||||
ride.Status = RideStatus.CancelRequested;
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await cancelRepo.AddAsync(cancelRequest);
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return ToCancelResponse(cancelRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResponse<RideCancelRequestResponse>> GetCancelRequestsAsync(CancelRequestStatus? status, int page, int pageSize)
|
|
||||||
{
|
|
||||||
var total = await cancelRepo.GetCountAsync(status);
|
|
||||||
var items = await cancelRepo.GetAllAsync(status, page, pageSize);
|
|
||||||
return new PagedResponse<RideCancelRequestResponse>(page, pageSize, total, items.Select(ToCancelResponse).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RideCancelRequestResponse> ApproveCancelAsync(Guid cancelRequestId, Guid approvedBy, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
|
|
||||||
?? throw new KeyNotFoundException("Cancel request not found");
|
|
||||||
|
|
||||||
if (cancelRequest.Status != CancelRequestStatus.Pending)
|
|
||||||
throw new InvalidOperationException("Cancel request is not pending");
|
|
||||||
|
|
||||||
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
|
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
if (ride.Status != RideStatus.CancelRequested)
|
|
||||||
throw new InvalidOperationException("Ride is not in CancelRequested status");
|
|
||||||
|
|
||||||
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
|
|
||||||
|
|
||||||
cancelRequest.Status = CancelRequestStatus.Approved;
|
|
||||||
cancelRequest.ApprovedBy = approvedBy;
|
|
||||||
cancelRequest.ApprovedAt = DateTime.Now;
|
|
||||||
|
|
||||||
ride.Status = RideStatus.Cancelled;
|
ride.Status = RideStatus.Cancelled;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||||
{
|
|
||||||
cancelRepo.Update(cancelRequest);
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return ToCancelResponse(cancelRequest);
|
return ToResponse(ride);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RideCancelRequestResponse> RejectCancelAsync(Guid cancelRequestId, Guid rejectedBy, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var cancelRequest = await cancelRepo.GetByIdAsync(cancelRequestId)
|
|
||||||
?? throw new KeyNotFoundException("Cancel request not found");
|
|
||||||
|
|
||||||
if (cancelRequest.Status != CancelRequestStatus.Pending)
|
|
||||||
throw new InvalidOperationException("Cancel request is not pending");
|
|
||||||
|
|
||||||
var ride = await rideRepo.GetByIdAsync(cancelRequest.FK_RideId)
|
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
if (ride.Status != RideStatus.CancelRequested)
|
|
||||||
throw new InvalidOperationException("Ride is not in CancelRequested status");
|
|
||||||
|
|
||||||
cancelRequest.Status = CancelRequestStatus.Rejected;
|
|
||||||
cancelRequest.ApprovedBy = rejectedBy;
|
|
||||||
cancelRequest.ApprovedAt = DateTime.Now;
|
|
||||||
|
|
||||||
// Restore to previous status: Active if ActivatedAt is set, else Open
|
|
||||||
ride.Status = ride.ActivatedAt.HasValue ? RideStatus.Active : RideStatus.Open;
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
cancelRepo.Update(cancelRequest);
|
|
||||||
rideRepo.Update(ride);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
|
||||||
|
|
||||||
return ToCancelResponse(cancelRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RideCancelRequestResponse?> GetCancelRequestAsync(Guid rideId)
|
|
||||||
{
|
|
||||||
var cancelRequest = await cancelRepo.GetByRideIdAsync(rideId);
|
|
||||||
return cancelRequest is null ? null : ToCancelResponse(cancelRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BreakdownAssistResponse> GetBreakdownAssistAsync(Guid rideId)
|
|
||||||
{
|
|
||||||
var strandedRide = await rideRepo.GetByIdAsync(rideId)
|
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
|
||||||
|
|
||||||
var activeRides = await rideRepo.GetActiveRidesAsync(rideId);
|
|
||||||
var activeRideResponses = new List<BreakdownAssistRideResponse>();
|
|
||||||
|
|
||||||
foreach (var ride in activeRides)
|
|
||||||
{
|
|
||||||
var stockLevels = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
|
|
||||||
activeRideResponses.Add(new BreakdownAssistRideResponse(
|
|
||||||
ride.Id,
|
|
||||||
ride.FK_VehicleId,
|
|
||||||
ride.Vehicle?.NumberPlate ?? string.Empty,
|
|
||||||
ride.FK_DriverProfileId,
|
|
||||||
ride.DriverProfile?.User?.FullName ?? string.Empty,
|
|
||||||
ride.FK_RouteId,
|
|
||||||
ride.Route?.RouteName ?? string.Empty,
|
|
||||||
stockLevels.Select(b => new StockBalanceResponse(
|
|
||||||
b.FK_ItemId,
|
|
||||||
b.Item.Name,
|
|
||||||
b.LocationId,
|
|
||||||
b.LocationType,
|
|
||||||
b.Quantity,
|
|
||||||
b.UpdatedAt,
|
|
||||||
ride.Vehicle?.NumberPlate ?? string.Empty)).ToList()));
|
|
||||||
}
|
|
||||||
|
|
||||||
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
|
|
||||||
|
|
||||||
return new BreakdownAssistResponse(
|
|
||||||
strandedRide.Id,
|
|
||||||
activeRideResponses,
|
|
||||||
openOrders.Select(o => new BreakdownAssistOrderResponse(
|
|
||||||
o.Id,
|
|
||||||
o.StoreId,
|
|
||||||
o.StoreName,
|
|
||||||
o.OrderDate,
|
|
||||||
o.TotalAmount)).ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
private async Task CheckDriverComplianceAsync(Guid driverProfileId)
|
||||||
@@ -343,15 +145,9 @@ public class RideService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static RideResponse ToResponse(DeliveryRun r) =>
|
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_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
|
||||||
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? 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.Meterstart, r.Meterend,
|
||||||
r.StartedAt, r.ActivatedAt, r.ClosedAt, r.CreatedAt);
|
r.StartedAt, 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -99,7 +99,7 @@ public record SchemeEligibilityResponse(
|
|||||||
public record SchemeRedemptionResponse(
|
public record SchemeRedemptionResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SchemeId,
|
Guid SchemeId,
|
||||||
Guid? OrderId,
|
Guid? ShopPOId,
|
||||||
decimal? DiscountAmount,
|
decimal? DiscountAmount,
|
||||||
Guid? FreeItemId,
|
Guid? FreeItemId,
|
||||||
string? FreeItemName,
|
string? FreeItemName,
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
|||||||
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
|
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
|
||||||
await context.SchemeRedemptions
|
await context.SchemeRedemptions
|
||||||
.Include(r => r.FreeItem)
|
.Include(r => r.FreeItem)
|
||||||
.Include(r => r.Order)
|
.Include(r => r.ShopPO)
|
||||||
.Where(r => r.FK_SchemeId == schemeId)
|
.Where(r => r.FK_SchemeId == schemeId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||||
await context.SchemeRedemptions.AddAsync(entity);
|
await context.SchemeRedemptions.AddAsync(entity);
|
||||||
|
|
||||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
|
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
|
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||||
|
|
||||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||||
await context.SchemeBundleComponents
|
await context.SchemeBundleComponents
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.Order;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -10,7 +10,7 @@ namespace Distribution.Features.Scheme;
|
|||||||
|
|
||||||
public class SchemeService(
|
public class SchemeService(
|
||||||
ISchemeRepository repo,
|
ISchemeRepository repo,
|
||||||
IOrderRepository orderRepo,
|
IShopPORepository shopPORepo,
|
||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
@@ -27,7 +27,7 @@ public class SchemeService(
|
|||||||
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
|
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
|
||||||
|
|
||||||
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
|
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);
|
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
|
||||||
|
|
||||||
// ── CRUD ───────────────────────────────────────────────────────────────
|
// ── CRUD ───────────────────────────────────────────────────────────────
|
||||||
@@ -243,17 +243,17 @@ public class SchemeService(
|
|||||||
return (await GetByIdAsync(schemeId))!;
|
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);
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
|
||||||
if (store is null || !store.IsActive) return null;
|
if (store is null || !store.IsActive) return null;
|
||||||
|
|
||||||
var order = await orderRepo.GetByIdAsync(orderId);
|
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
|
||||||
if (order is null || !order.IsActive) return null;
|
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 regionId = store.Route?.FK_RegionId;
|
||||||
|
|
||||||
var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive);
|
var candidates = (await repo.GetAllAsync()).Where(s => s.IsActive);
|
||||||
@@ -266,21 +266,21 @@ public class SchemeService(
|
|||||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
||||||
continue;
|
continue;
|
||||||
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
|
if (shopPO.CreatedAt < scheme.DateRangeFrom || shopPO.CreatedAt > scheme.DateRangeTo)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
result.Add(EvaluateRule(scheme, orderItems));
|
result.Add(EvaluateRule(scheme, items));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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)`
|
/// 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
|
/// (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",
|
/// 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>
|
/// </summary>
|
||||||
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
|
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
|
||||||
{
|
{
|
||||||
@@ -305,7 +305,7 @@ public class SchemeService(
|
|||||||
return (true, null);
|
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)
|
switch (scheme.RuleType)
|
||||||
{
|
{
|
||||||
@@ -316,10 +316,10 @@ public class SchemeService(
|
|||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
||||||
"Scheme has no free-goods rule configured.", null, null, null);
|
"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)
|
if (boughtQty < scheme.TriggerQty)
|
||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
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;
|
var multiplier = boughtQty / scheme.TriggerQty.Value;
|
||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
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);
|
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
|
||||||
|
|
||||||
var missing = scheme.BundleComponents
|
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();
|
.ToList();
|
||||||
if (missing.Count > 0)
|
if (missing.Count > 0)
|
||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
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,
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||||
null, scheme.FK_FreeItemId, scheme.FreeQty);
|
null, scheme.FK_FreeItemId, scheme.FreeQty);
|
||||||
@@ -346,7 +346,7 @@ public class SchemeService(
|
|||||||
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
|
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 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,
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||||
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
|
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
|
||||||
}
|
}
|
||||||
@@ -355,12 +355,12 @@ public class SchemeService(
|
|||||||
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
|
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 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)
|
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
|
||||||
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
|
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
|
||||||
if (tier is null)
|
if (tier is null)
|
||||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
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,
|
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||||
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, 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);
|
var scheme = await repo.GetByIdAsync(schemeId);
|
||||||
if (scheme is null || !scheme.IsActive) return null;
|
if (scheme is null || !scheme.IsActive) return null;
|
||||||
|
|
||||||
var order = await orderRepo.GetByIdAsync(orderId);
|
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
|
||||||
if (order is null || !order.IsActive) return null;
|
if (shopPO is null || !shopPO.IsActive) return null;
|
||||||
|
|
||||||
if (await repo.HasRedemptionAsync(schemeId, orderId))
|
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||||
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
|
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||||
|
|
||||||
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||||
var eligibility = EvaluateRule(scheme, orderItems);
|
var eligibility = EvaluateRule(scheme, items);
|
||||||
if (!eligibility.Eligible)
|
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
|
var redemption = new SchemeRedemption
|
||||||
{
|
{
|
||||||
FK_SchemeId = schemeId,
|
FK_SchemeId = schemeId,
|
||||||
FK_OrderId = orderId,
|
FK_ShopPOId = shopPOId,
|
||||||
DiscountAmount = eligibility.DiscountAmount
|
DiscountAmount = eligibility.DiscountAmount
|
||||||
};
|
};
|
||||||
|
|
||||||
var newOrderItems = new List<OrderItem>();
|
var newItems = new List<ShopPOItem>();
|
||||||
|
|
||||||
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
|
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
|
||||||
{
|
{
|
||||||
@@ -406,9 +406,9 @@ public class SchemeService(
|
|||||||
if (availableQty < eligibility.FreeQty.Value)
|
if (availableQty < eligibility.FreeQty.Value)
|
||||||
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
|
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,
|
FK_ItemId = freeItem.Id,
|
||||||
Quantity = (int)eligibility.FreeQty.Value,
|
Quantity = (int)eligibility.FreeQty.Value,
|
||||||
UnitPrice = 0m
|
UnitPrice = 0m
|
||||||
@@ -422,7 +422,7 @@ public class SchemeService(
|
|||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
|
if (newItems.Count > 0) await shopPORepo.AddItemsAsync(newItems);
|
||||||
await repo.AddRedemptionAsync(redemption);
|
await repo.AddRedemptionAsync(redemption);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -457,8 +457,8 @@ public class SchemeService(
|
|||||||
var spend = await GetSpendAsync(schemeId);
|
var spend = await GetSpendAsync(schemeId);
|
||||||
|
|
||||||
var actualSales = redemptions
|
var actualSales = redemptions
|
||||||
.Where(r => r.Order is not null)
|
.Where(r => r.ShopPO is not null)
|
||||||
.Select(r => r.Order!)
|
.Select(r => r.ShopPO!)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Sum(o => o.TotalAmount);
|
.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);
|
Task AddAsync(Models.Store entity);
|
||||||
void Update(Models.Store entity);
|
void Update(Models.Store entity);
|
||||||
void Remove(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);
|
context.Stores.Update(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
|
public async Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
|
||||||
await context.Orders
|
await context.ShopPOs
|
||||||
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive)
|
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
periodStart.ToString("yyyy-MM"),
|
periodStart.ToString("yyyy-MM"),
|
||||||
orders.Count,
|
orders.Count,
|
||||||
orders.Sum(o => o.TotalAmount),
|
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);
|
||||||
|
}
|
||||||
+4026
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
@@ -4,8 +4,7 @@ namespace Distribution.Models
|
|||||||
{
|
{
|
||||||
public class DeliveryRun : Template
|
public class DeliveryRun : Template
|
||||||
{
|
{
|
||||||
public RideType RideType { get; set; }
|
public RideStatus Status { get; set; } = RideStatus.Open;
|
||||||
public RideStatus Status { get; set; } = RideStatus.Draft;
|
|
||||||
|
|
||||||
public Guid FK_VehicleId { get; set; }
|
public Guid FK_VehicleId { get; set; }
|
||||||
[ForeignKey("FK_VehicleId")]
|
[ForeignKey("FK_VehicleId")]
|
||||||
@@ -15,19 +14,10 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_DriverProfileId")]
|
[ForeignKey("FK_DriverProfileId")]
|
||||||
public DriverProfile DriverProfile { get; set; }
|
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 Meterstart { get; set; }
|
||||||
public int Meterend { get; set; }
|
public int Meterend { get; set; }
|
||||||
|
|
||||||
public DateTime? StartedAt { get; set; }
|
public DateTime? StartedAt { get; set; }
|
||||||
public DateTime? ActivatedAt { get; set; }
|
|
||||||
public DateTime? ClosedAt { get; set; }
|
public DateTime? ClosedAt { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_RideId")]
|
[ForeignKey("FK_RideId")]
|
||||||
public DeliveryRun Ride { get; set; }
|
public DeliveryRun Ride { get; set; }
|
||||||
|
|
||||||
public Guid FK_OrderId { get; set; }
|
public Guid FK_ShopPOId { get; set; }
|
||||||
[ForeignKey("FK_OrderId")]
|
[ForeignKey("FK_ShopPOId")]
|
||||||
public Order Order { get; set; }
|
public ShopPO ShopPO { get; set; }
|
||||||
|
|
||||||
public int Sequence { get; set; }
|
public int Sequence { get; set; }
|
||||||
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
|
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ namespace Distribution.Models
|
|||||||
public enum LocationType
|
public enum LocationType
|
||||||
{ Warehouse, Vehicle, External }
|
{ 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 }
|
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
|
||||||
|
|
||||||
@@ -19,16 +18,15 @@ namespace Distribution.Models
|
|||||||
|
|
||||||
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
|
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 PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
public enum PaymentMethod { Cash, Credit }
|
public enum PaymentMethod { Cash, Credit }
|
||||||
|
|
||||||
public enum DeliveryTaskStatus
|
public enum DeliveryTaskStatus { Pending, Delivered, PartiallyDelivered, Failed }
|
||||||
{ Pending, Delivered, PartiallyDelivered, Failed}
|
|
||||||
|
|
||||||
public enum ReturnMode { CreditDeduction, ItemExchange }
|
public enum ReturnMode { CreditDeduction, ItemExchange }
|
||||||
|
|
||||||
@@ -41,4 +39,10 @@ namespace Distribution.Models
|
|||||||
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
|
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
|
||||||
|
|
||||||
public enum SupplierClaimResolutionType { Replacement, Credit, Refund, PriceAdjustment }
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,17 +8,15 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_RideId")]
|
[ForeignKey("FK_RideId")]
|
||||||
public DeliveryRun Ride { get; set; }
|
public DeliveryRun Ride { get; set; }
|
||||||
|
|
||||||
public Guid FK_SourceWarehouseId { get; set; }
|
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Planned;
|
||||||
[ForeignKey("FK_SourceWarehouseId")]
|
|
||||||
public Warehouse SourceWarehouse { get; set; }
|
|
||||||
|
|
||||||
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Pending;
|
public Guid PopulatedBy { get; set; }
|
||||||
|
public DateTime PopulatedAt { get; set; }
|
||||||
|
|
||||||
public DateTime? DispatchConfirmedAt { get; set; }
|
public DateTime? ConfirmedAt { get; set; }
|
||||||
public Guid? DispatchConfirmedBy { get; set; }
|
public Guid? ConfirmedBy { get; set; }
|
||||||
public DateTime? ReceiptConfirmedAt { get; set; }
|
|
||||||
public Guid? ReceiptConfirmedBy { get; set; }
|
|
||||||
|
|
||||||
public List<LoadingSheetItem> Items { get; set; } = [];
|
public List<LoadingSheetItem> Items { get; set; } = [];
|
||||||
|
public List<LoadingSheetOrder> Orders { get; set; } = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ using System.ComponentModel.DataAnnotations.Schema;
|
|||||||
|
|
||||||
namespace Distribution.Models
|
namespace Distribution.Models
|
||||||
{
|
{
|
||||||
public class LoadingSheetItem
|
public class LoadingSheetItem : Template
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
public Guid FK_LoadingSheetId { get; set; }
|
public Guid FK_LoadingSheetId { get; set; }
|
||||||
[ForeignKey("FK_LoadingSheetId")]
|
[ForeignKey("FK_LoadingSheetId")]
|
||||||
public LoadingSheet LoadingSheet { get; set; }
|
public LoadingSheet LoadingSheet { get; set; }
|
||||||
@@ -14,8 +12,14 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_ItemId")]
|
[ForeignKey("FK_ItemId")]
|
||||||
public Item Item { get; set; }
|
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 QuantityDispatched { get; set; }
|
||||||
public decimal? QuantityReceived { get; set; }
|
|
||||||
public decimal? QuantityResolved { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,9 @@ namespace Distribution.Models
|
|||||||
{
|
{
|
||||||
public class Payment : Template
|
public class Payment : Template
|
||||||
{
|
{
|
||||||
public Guid FK_SaleId { get; set; }
|
public Guid FK_InvoiceId { get; set; }
|
||||||
[ForeignKey("FK_SaleId")]
|
[ForeignKey("FK_InvoiceId")]
|
||||||
public Sale Sale { get; set; }
|
public Invoice Invoice { get; set; }
|
||||||
|
|
||||||
public Guid FK_StoreId { get; set; }
|
|
||||||
[ForeignKey("FK_StoreId")]
|
|
||||||
public Store Store { get; set; }
|
|
||||||
|
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public PaymentMethod Method { get; set; }
|
public PaymentMethod Method { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,9 @@ namespace Distribution.Models
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SaleReturn : Template
|
public class SaleReturn : Template
|
||||||
{
|
{
|
||||||
public Guid FK_OrderId { get; set; }
|
public Guid FK_ShopPOId { get; set; }
|
||||||
[ForeignKey("FK_OrderId")]
|
[ForeignKey("FK_ShopPOId")]
|
||||||
public Order Order { get; set; }
|
public ShopPO ShopPO { get; set; }
|
||||||
|
|
||||||
public ReturnMode Mode { get; set; }
|
public ReturnMode Mode { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_SchemeId")]
|
[ForeignKey("FK_SchemeId")]
|
||||||
public Scheme Scheme { get; set; }
|
public Scheme Scheme { get; set; }
|
||||||
|
|
||||||
public Guid? FK_OrderId { get; set; }
|
public Guid? FK_ShopPOId { get; set; }
|
||||||
[ForeignKey("FK_OrderId")]
|
[ForeignKey("FK_ShopPOId")]
|
||||||
public Order? Order { get; set; }
|
public ShopPO? ShopPO { get; set; }
|
||||||
|
|
||||||
/// <summary>Placeholder for the future field-invoice module — no FieldInvoice entity exists yet.</summary>
|
/// <summary>Placeholder for the future field-invoice module — no FieldInvoice entity exists yet.</summary>
|
||||||
public Guid? FieldInvoiceId { get; set; }
|
public Guid? FieldInvoiceId { get; set; }
|
||||||
|
|||||||
@@ -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
|
namespace Distribution.Models
|
||||||
{
|
{
|
||||||
public class OrderItem : Template
|
public class ShopPOItem : Template
|
||||||
{
|
{
|
||||||
public Guid FK_OrderId { get; set; }
|
public Guid FK_ShopPOId { get; set; }
|
||||||
[ForeignKey("FK_OrderId")]
|
[ForeignKey("FK_ShopPOId")]
|
||||||
public Order Order { get; set; }
|
public ShopPO ShopPO { get; set; }
|
||||||
|
|
||||||
public Guid FK_ItemId { get; set; }
|
public Guid FK_ItemId { get; set; }
|
||||||
[ForeignKey("FK_ItemId")]
|
[ForeignKey("FK_ItemId")]
|
||||||
public Item Item { get; set; }
|
public Item Item { get; set; }
|
||||||
|
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
public int QuantityFulfilled { get; set; } = 0;
|
public int QuantityFulfilled { get; set; } = 0;
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ using Distribution.Features.DriverComplianceConfig;
|
|||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.Order;
|
using Distribution.Features.ShopPO;
|
||||||
|
using Distribution.Features.Invoice;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Features.PurchaseReturn;
|
using Distribution.Features.PurchaseReturn;
|
||||||
@@ -22,7 +23,6 @@ using Distribution.Features.LoadingEvent;
|
|||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.Role;
|
using Distribution.Features.Role;
|
||||||
using Distribution.Features.Route;
|
using Distribution.Features.Route;
|
||||||
using Distribution.Features.Sale;
|
|
||||||
using Distribution.Features.Scheme;
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
using Distribution.Features.Store.Kyc;
|
using Distribution.Features.Store.Kyc;
|
||||||
@@ -32,6 +32,7 @@ using Distribution.Features.Uom;
|
|||||||
using Distribution.Features.User;
|
using Distribution.Features.User;
|
||||||
using Distribution.Features.Vehicle;
|
using Distribution.Features.Vehicle;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.SysConfig;
|
||||||
using Distribution.Features.Warehouse;
|
using Distribution.Features.Warehouse;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -52,8 +53,8 @@ builder.Services.AddScoped<IRouteRepository, RouteRepository>();
|
|||||||
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
|
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
|
||||||
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
|
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
|
||||||
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
|
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
|
||||||
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
|
builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
|
||||||
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
|
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
|
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
|
||||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
@@ -65,7 +66,6 @@ builder.Services.AddScoped<IStockRepository, StockRepository>();
|
|||||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||||
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
|
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
|
||||||
builder.Services.AddScoped<IRideRepository, RideRepository>();
|
builder.Services.AddScoped<IRideRepository, RideRepository>();
|
||||||
builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestRepository>();
|
|
||||||
builder.Services.AddScoped<ILoadingSheetRepository, LoadingSheetRepository>();
|
builder.Services.AddScoped<ILoadingSheetRepository, LoadingSheetRepository>();
|
||||||
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
|
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
|
||||||
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
|
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
|
||||||
@@ -85,8 +85,8 @@ builder.Services.AddScoped<RouteService>();
|
|||||||
builder.Services.AddScoped<VehicleService>();
|
builder.Services.AddScoped<VehicleService>();
|
||||||
builder.Services.AddScoped<StoreService>();
|
builder.Services.AddScoped<StoreService>();
|
||||||
builder.Services.AddScoped<WarehouseService>();
|
builder.Services.AddScoped<WarehouseService>();
|
||||||
builder.Services.AddScoped<OrderService>();
|
builder.Services.AddScoped<ShopPOService>();
|
||||||
builder.Services.AddScoped<SaleService>();
|
builder.Services.AddScoped<InvoiceService>();
|
||||||
builder.Services.AddScoped<UserService>();
|
builder.Services.AddScoped<UserService>();
|
||||||
builder.Services.AddScoped<RoleService>();
|
builder.Services.AddScoped<RoleService>();
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user