Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ca9f874d | |||
| 75b5035354 | |||
| c0432a448f | |||
| bfbfe352ee | |||
| 320da0e1c9 | |||
| f0d7f939ef | |||
| 8b84c8aeb3 | |||
| 9febae6d63 | |||
| df27d58089 | |||
| 4e12e9f8f1 | |||
| 39eab2e3a2 | |||
| 889ca0795d | |||
| a7801a2bc2 | |||
| f4db83654c | |||
| d414ec8e38 | |||
| dc7e7a70fc | |||
| 20b82a2734 | |||
| 83d3272784 | |||
| d6cd9ccd08 | |||
| 1bee5f91c6 | |||
| 08ad17f15b | |||
| 97a1db1343 | |||
| 3268a50c3e | |||
| f1c036d1d8 | |||
| 92fef1dfa8 | |||
| bb33928661 | |||
| b279a121ad | |||
| 916a2b8f0a | |||
| 3caacf44d0 | |||
| fe9ccb4de7 | |||
| f244a04dc0 | |||
| aa543d0d0d | |||
| 87146e4662 | |||
| cefefcbc49 | |||
| fcca03d0b2 | |||
| 6d8b6b3e59 | |||
| 3837facb02 | |||
| a833586e83 | |||
| 3bc33ce08f | |||
| 870b412a06 | |||
| 095d35e1b8 | |||
| d7b03941fb | |||
| 81a49e1a98 | |||
| 2995ce7012 | |||
| db0850f1f0 | |||
| 702dbeb39f | |||
| c8d23afe59 | |||
| ade41b4a60 | |||
| ac1b2fbc4b | |||
| f381e7a634 | |||
| 3680fc9035 | |||
| 6a0322570b | |||
| f6211a8edd | |||
| 83bf715716 |
+1
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Region;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Moq;
|
||||
@@ -10,11 +11,14 @@ public class RegionServiceTests
|
||||
{
|
||||
private readonly Mock<IRegionRepository> _repo = new();
|
||||
private readonly Mock<IUnitOfWork> _uow = new();
|
||||
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||
private readonly RegionService _sut;
|
||||
|
||||
public RegionServiceTests()
|
||||
{
|
||||
_sut = new RegionService(_repo.Object, _uow.Object);
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||
}
|
||||
|
||||
private static Region ActiveRegion(string name = "North") =>
|
||||
|
||||
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<UomConversion> UomConversions { get; set; }
|
||||
public DbSet<Item> Items { get; set; }
|
||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||
public DbSet<Sale> Sales { get; set; }
|
||||
public DbSet<Order> Orders { get; set; }
|
||||
public DbSet<OrderItem> OrderItems { get; set; }
|
||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||
@@ -39,6 +40,29 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<Scheme> Schemes { get; set; }
|
||||
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
|
||||
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
||||
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
||||
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
|
||||
public DbSet<Payment> Payments { get; set; }
|
||||
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
|
||||
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
|
||||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
|
||||
public DbSet<CreditNote> CreditNotes { get; set; }
|
||||
public DbSet<Claim> Claims { get; set; }
|
||||
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
||||
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
||||
public DbSet<RegionalTarget> RegionalTargets { get; set; }
|
||||
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
|
||||
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
||||
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||
public DbSet<RideItemCount> RideItemCounts { get; set; }
|
||||
public DbSet<RideVariance> RideVariances { get; set; }
|
||||
public DbSet<RideCloseSummary> RideCloseSummaries { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -71,15 +95,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Order>()
|
||||
.HasIndex(o => o.FK_SaleId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<DriverProfile>()
|
||||
.HasIndex(dp => dp.FK_UserId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Vehicle>()
|
||||
.HasOne(v => v.DefaultWarehouse)
|
||||
.HasOne<Warehouse>()
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.FK_DefaultWarehouseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
.HasForeignKey(nameof(Vehicle.DefaultWarehouseId))
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<DeliveryRun>()
|
||||
.HasOne(r => r.SourceWarehouse)
|
||||
@@ -93,6 +122,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(r => r.FK_DriverProfileId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Sale>()
|
||||
.HasOne(s => s.Store)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_StoreId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Sale>()
|
||||
.HasOne(s => s.CreatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.CreatedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Sale>()
|
||||
.HasOne(s => s.Ride)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_RideId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<Order>()
|
||||
.HasOne(o => o.Sale)
|
||||
.WithMany()
|
||||
.HasForeignKey(o => o.FK_SaleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SchemeRedemption>()
|
||||
.HasOne(r => r.Scheme)
|
||||
.WithMany(s => s.Redemptions)
|
||||
@@ -139,6 +193,232 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<LoadingSheet>()
|
||||
.HasOne(s => s.SourceWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_SourceWarehouseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<LoadingSheet>()
|
||||
.HasOne(s => s.Ride)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_RideId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<LoadingSheetItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasOne(po => po.Supplier)
|
||||
.WithMany()
|
||||
.HasForeignKey(po => po.FK_SupplierId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseOrderItem>()
|
||||
.HasOne(i => i.PurchaseOrder)
|
||||
.WithMany(po => po.Items)
|
||||
.HasForeignKey(i => i.FK_PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseOrderItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceipt>()
|
||||
.HasOne(gr => gr.PurchaseOrder)
|
||||
.WithMany()
|
||||
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceipt>()
|
||||
.HasOne(gr => gr.Warehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(gr => gr.FK_WarehouseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasOne(i => i.GoodsReceipt)
|
||||
.WithMany(gr => gr.Items)
|
||||
.HasForeignKey(i => i.FK_GoodsReceiptId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasOne(i => i.PurchaseOrderItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseReturn>()
|
||||
.HasOne(pr => pr.PurchaseOrder)
|
||||
.WithMany()
|
||||
.HasForeignKey(pr => pr.FK_PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseReturnItem>()
|
||||
.HasOne(i => i.PurchaseReturn)
|
||||
.WithMany(pr => pr.Items)
|
||||
.HasForeignKey(i => i.FK_PurchaseReturnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseReturnItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PurchaseOrderItem>()
|
||||
.HasIndex(i => i.FK_PurchaseOrderId);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
||||
|
||||
modelBuilder.Entity<PurchaseReturnItem>()
|
||||
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||
|
||||
modelBuilder.Entity<SaleReturn>()
|
||||
.HasOne(r => r.Order)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FK_OrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturn>()
|
||||
.HasOne(r => r.CreatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.CreatedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>()
|
||||
.HasOne(i => i.SaleReturn)
|
||||
.WithMany(r => r.Items)
|
||||
.HasForeignKey(i => i.FK_SaleReturnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SaleReturnItem>()
|
||||
.HasIndex(i => i.FK_SaleReturnId);
|
||||
|
||||
modelBuilder.Entity<CreditNote>()
|
||||
.HasOne(n => n.SaleReturn)
|
||||
.WithMany()
|
||||
.HasForeignKey(n => n.FK_SaleReturnId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Claim>()
|
||||
.HasOne(c => c.AgencyUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_AgencyUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Claim>()
|
||||
.HasOne(c => c.SettledByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.SettledByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.PurchaseOrder)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.GoodsReceipt)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_GoodsReceiptId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.RaisedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.RaisedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaim>()
|
||||
.HasOne(c => c.ResolvedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ResolvedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>()
|
||||
.HasOne(i => i.SupplierClaim)
|
||||
.WithMany(c => c.Items)
|
||||
.HasForeignKey(i => i.FK_SupplierClaimId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SupplierClaimItem>()
|
||||
.HasIndex(i => i.FK_SupplierClaimId);
|
||||
|
||||
modelBuilder.Entity<DeliveryTask>()
|
||||
.HasOne(t => t.Ride)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.FK_RideId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<DeliveryTask>()
|
||||
.HasOne(t => t.Order)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.FK_OrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<RegionalTarget>()
|
||||
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RegionalBudget>()
|
||||
.HasIndex(b => new { b.FK_RegionId, b.Period })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RegionAgencyMap>()
|
||||
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RegionAgencyMap>()
|
||||
.HasOne(m => m.AgencyUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.FK_AgencyUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<RegionalTaxRule>()
|
||||
.HasIndex(r => r.FK_RegionId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RideVariance>()
|
||||
.HasOne(v => v.ResolvedBy)
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.FK_ResolvedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<RideCloseSummary>()
|
||||
.HasIndex(s => s.FK_RideId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<RideCloseSummary>()
|
||||
.HasOne(s => s.ClosedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_ClosedByUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,78 +14,6 @@ public static class RoleAndPermissionSeed
|
||||
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
|
||||
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
|
||||
|
||||
private static readonly (string Code, string Description) [] PermissionDefs =
|
||||
[
|
||||
("regions.view", "View regions"),
|
||||
("regions.create", "Create regions"),
|
||||
("regions.update", "Update regions"),
|
||||
("regions.delete", "Delete regions"),
|
||||
|
||||
("routes.view", "View routes"),
|
||||
("routes.create", "Create routes"),
|
||||
("routes.update", "Update routes"),
|
||||
("routes.delete", "Delete routes"),
|
||||
|
||||
("vehicles.view", "View vehicles"),
|
||||
("vehicles.create", "Create vehicles"),
|
||||
("vehicles.update", "Update vehicles"),
|
||||
("vehicles.delete", "Delete vehicles"),
|
||||
|
||||
("stores.view", "View stores"),
|
||||
("stores.create", "Create stores"),
|
||||
("stores.update", "Update stores"),
|
||||
("stores.delete", "Delete stores"),
|
||||
("stores.kyc.verify", "Verify or reject store KYC documents"),
|
||||
("stores.credit.update", "Update store credit limit and outstanding balance"),
|
||||
|
||||
("warehouses.view", "View warehouses"),
|
||||
("warehouses.create", "Create warehouses"),
|
||||
("warehouses.update", "Update warehouses"),
|
||||
("warehouses.delete", "Delete warehouses"),
|
||||
|
||||
("orders.view", "View orders"),
|
||||
("orders.create", "Create orders"),
|
||||
("orders.update", "Update orders"),
|
||||
("orders.delete", "Delete orders"),
|
||||
|
||||
("users.view", "View users"),
|
||||
("users.create", "Create users"),
|
||||
("users.update", "Update users"),
|
||||
("users.deactivate", "Deactivate users"),
|
||||
("users.assign_role", "Assign a role to a user"),
|
||||
("users.assign_scope", "Assign region/route/store scope to a user"),
|
||||
|
||||
("items.view", "View items, brands and UOMs"),
|
||||
("items.create", "Create items, brands and UOMs"),
|
||||
("items.update", "Update items, brands and UOMs"),
|
||||
("items.delete", "Delete items, brands and UOMs"),
|
||||
|
||||
("categories.view", "View categories"),
|
||||
("categories.create", "Create categories and subcategories"),
|
||||
("categories.update", "Update categories and subcategories"),
|
||||
("categories.delete", "Delete categories and subcategories"),
|
||||
|
||||
("driver_profiles.view", "View driver profiles"),
|
||||
("driver_profiles.create", "Create driver profiles"),
|
||||
("driver_profiles.update", "Update driver profiles"),
|
||||
("driver_profiles.deactivate", "Deactivate driver profiles"),
|
||||
|
||||
("compliance.manage", "Manage driver compliance configuration"),
|
||||
|
||||
("rides.view", "View rides"),
|
||||
("rides.create", "Create rides"),
|
||||
("rides.manage", "Manage ride lifecycle"),
|
||||
("rides.cancel.request", "Request ride cancellation"),
|
||||
("rides.cancel.approve", "Approve or reject ride cancellations"),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, Guid> PermissionIds = PermissionDefs
|
||||
.Select((p, i) => (p.Code, Id: DeterministicPermissionId(i)))
|
||||
.ToDictionary(x => x.Code, x => x.Id);
|
||||
|
||||
private static Guid DeterministicPermissionId(int index) =>
|
||||
new($"20000000-0000-0000-0000-{index + 1:D12}");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Role>().HasData(
|
||||
@@ -96,76 +24,5 @@ public static class RoleAndPermissionSeed
|
||||
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
|
||||
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Permission>().HasData(
|
||||
PermissionDefs.Select(p => new Permission
|
||||
{
|
||||
Id = PermissionIds[p.Code],
|
||||
Code = p.Code,
|
||||
Description = p.Description,
|
||||
CreatedAt = SeedDate,
|
||||
IsActive = true
|
||||
})
|
||||
);
|
||||
|
||||
var itemViewPerms = new[] { "items.view", "categories.view" };
|
||||
var itemManagePerms = new[] { "items.view", "items.create", "items.update", "categories.view" };
|
||||
var itemPerms = new[] { "items.view", "items.create", "items.update", "items.delete", "categories.view", "categories.create", "categories.update", "categories.delete" };
|
||||
|
||||
var salesRepPerms = new[] { "orders.view", "orders.create", "orders.update", "stores.view", "routes.view" }.Concat(itemViewPerms).ToArray();
|
||||
var supervisorPerms = salesRepPerms.Concat(["orders.delete", "stores.create", "stores.update"]).ToArray();
|
||||
var ridePerms = new[]
|
||||
{
|
||||
"driver_profiles.view", "driver_profiles.create", "driver_profiles.update", "driver_profiles.deactivate",
|
||||
"compliance.manage",
|
||||
"rides.view", "rides.create", "rides.manage", "rides.cancel.request", "rides.cancel.approve"
|
||||
};
|
||||
|
||||
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view"]).Concat(itemManagePerms).Concat(ridePerms).Distinct().ToArray();
|
||||
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update"]).Concat(itemPerms).Distinct().ToArray();
|
||||
|
||||
var regionalManagerPerms = new[]
|
||||
{
|
||||
"orders.view", "orders.create", "orders.update", "orders.delete",
|
||||
"stores.view", "stores.create", "stores.update", "stores.delete", "stores.kyc.verify", "stores.credit.update",
|
||||
"routes.view", "routes.create", "routes.update", "routes.delete",
|
||||
"vehicles.view", "vehicles.create", "vehicles.update", "vehicles.delete",
|
||||
"warehouses.view", "warehouses.create", "warehouses.update", "warehouses.delete",
|
||||
"regions.view", "users.view",
|
||||
"driver_profiles.view", "driver_profiles.create", "driver_profiles.update", "driver_profiles.deactivate",
|
||||
"compliance.manage",
|
||||
"rides.view", "rides.create", "rides.manage", "rides.cancel.request", "rides.cancel.approve",
|
||||
"items.view", "items.create", "items.update", "items.delete",
|
||||
"categories.view", "categories.create", "categories.update", "categories.delete"
|
||||
};
|
||||
var regionalAgencyPerms = PermissionDefs.Select(p => p.Code).ToArray();
|
||||
|
||||
var rolePermissionMap = new (Guid RoleId, string[] Codes)[]
|
||||
{
|
||||
(SalesRepId, salesRepPerms),
|
||||
(SupervisorId, supervisorPerms),
|
||||
(AreaManagerId, areaManagerPerms),
|
||||
(AreaSupervisorId, areaSupervisorPerms),
|
||||
(RegionalManagerId, regionalManagerPerms),
|
||||
(RegionalAgencyId, regionalAgencyPerms),
|
||||
};
|
||||
|
||||
modelBuilder.Entity<RolePermission>().HasData(
|
||||
rolePermissionMap.SelectMany(rp => rp.Codes.Select(code => new RolePermission
|
||||
{
|
||||
Id = DeterministicRolePermissionId(rp.RoleId, PermissionIds[code]),
|
||||
FK_RoleId = rp.RoleId,
|
||||
FK_PermissionId = PermissionIds[code],
|
||||
CreatedAt = SeedDate,
|
||||
IsActive = true
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private static Guid DeterministicRolePermissionId(Guid roleId, Guid permissionId)
|
||||
{
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
var bytes = md5.ComputeHash(roleId.ToByteArray().Concat(permissionId.ToByteArray()).ToArray());
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(AuthService service) : ControllerBase
|
||||
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
private static IActionResult ToActionResult(AuthHexResponse result) =>
|
||||
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login(LoginRequest request)
|
||||
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
|
||||
ToActionResult(await service.LoginAsync(request, ct));
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
|
||||
ToActionResult(await service.RegisterAsync(request, ct));
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me(CancellationToken ct)
|
||||
{
|
||||
var result = await service.LoginAsync(request);
|
||||
return result is null ? Unauthorized() : Ok(result);
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||
{
|
||||
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
|
||||
return ToActionResult(await service.LogoutAsync(token, ct));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
|
||||
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
|
||||
public record RegisterRequest(string Username, string Password, string? FullName);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using Distribution.Features.User;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
|
||||
/// <summary>
|
||||
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
|
||||
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
|
||||
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
|
||||
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
|
||||
/// </summary>
|
||||
public class AuthService(IAuthHexClient authHex)
|
||||
{
|
||||
private readonly PasswordHasher<Models.User> _hasher = new();
|
||||
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
|
||||
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
|
||||
{
|
||||
var user = await userRepo.GetByUsernameAsync(request.Username);
|
||||
if (user is null || !user.IsActive)
|
||||
return null;
|
||||
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||
|
||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
return null;
|
||||
public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
|
||||
|
||||
var token = tokenService.GenerateToken(user);
|
||||
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
|
||||
}
|
||||
public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
|
||||
authHex.CallAsync("LogoutUser", new { token }, ct: ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Typed HttpClient that proxies requests to the external Auth Service.
|
||||
/// Registered via AddHttpClient<AuthServiceClient> in Program.cs.
|
||||
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
|
||||
///
|
||||
/// TODO: Implement methods once Auth Service contract is agreed.
|
||||
/// </summary>
|
||||
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
|
||||
{
|
||||
// TODO: implement proxy methods, e.g.:
|
||||
//
|
||||
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||
// {
|
||||
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
|
||||
// if (!response.IsSuccessStatusCode) return null;
|
||||
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
public class AuthServiceOptions
|
||||
{
|
||||
public const string SectionName = "AuthService";
|
||||
|
||||
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Distribution.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Connector to the external Auth Service. This backend proxies all auth
|
||||
/// traffic from the Next.js frontend through to the Auth Service — the
|
||||
/// frontend never calls the Auth Service directly.
|
||||
///
|
||||
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
|
||||
/// </summary>
|
||||
public interface IAuthServiceClient
|
||||
{
|
||||
// TODO: define methods based on Auth Service contract, e.g.:
|
||||
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
|
||||
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
// Task LogoutAsync(string token, CancellationToken ct);
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Brand;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/brands")]
|
||||
[Authorize]
|
||||
public class BrandController(BrandService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "items.create")]
|
||||
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "items.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Category;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CategoryController(CategoryService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/categories")]
|
||||
[Authorize(Policy = "categories.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("api/categories")]
|
||||
[Authorize(Policy = "categories.create")]
|
||||
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
||||
[Authorize(Policy = "categories.create")]
|
||||
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
||||
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("api/subcategories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.update")]
|
||||
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
||||
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("api/subcategories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.delete")]
|
||||
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
[ApiController]
|
||||
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/claims/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("api/claims")]
|
||||
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/claims/{id:guid}/settle")]
|
||||
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.SettleAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public record CreateClaimRequest(Guid AgencyUserId, string Details);
|
||||
|
||||
public record SettleClaimRequest(decimal SettlementAmount);
|
||||
|
||||
public record ClaimResponse(
|
||||
Guid Id,
|
||||
Guid AgencyUserId,
|
||||
string Details,
|
||||
ClaimStatus Status,
|
||||
decimal? SettlementAmount,
|
||||
DateTime? SettledAt,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,22 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public class ClaimRepository(AppDBContext context) : IClaimRepository
|
||||
{
|
||||
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
|
||||
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||
await context.Claims
|
||||
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Claim entity) =>
|
||||
await context.Claims.AddAsync(entity);
|
||||
|
||||
public void Update(Models.Claim entity) =>
|
||||
context.Claims.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private static ClaimResponse Map(Claim c) =>
|
||||
new(c.Id, c.FK_AgencyUserId, c.Details, c.Status, c.SettlementAmount, c.SettledAt, c.IsActive, c.CreatedAt);
|
||||
|
||||
public async Task<ClaimResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var c = await repo.GetByIdAsync(id);
|
||||
if (c is null || !c.IsActive) return null;
|
||||
return Map(c);
|
||||
}
|
||||
|
||||
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
|
||||
|
||||
public async Task<ClaimResponse> CreateAsync(CreateClaimRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Details))
|
||||
throw new InvalidOperationException("Claim details are required.");
|
||||
|
||||
var claim = new Claim
|
||||
{
|
||||
FK_AgencyUserId = request.AgencyUserId,
|
||||
Details = request.Details
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
|
||||
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||
{
|
||||
var claim = await repo.GetByIdAsync(id);
|
||||
if (claim is null || !claim.IsActive)
|
||||
throw new KeyNotFoundException("Claim not found.");
|
||||
if (claim.Status == ClaimStatus.Settled)
|
||||
throw new InvalidOperationException("Claim is already settled.");
|
||||
if (request.SettlementAmount < 0)
|
||||
throw new InvalidOperationException("Settlement amount cannot be negative.");
|
||||
|
||||
claim.Status = ClaimStatus.Settled;
|
||||
claim.SettlementAmount = request.SettlementAmount;
|
||||
claim.SettledByUserId = approverId;
|
||||
claim.SettledAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(claim);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(claim);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Distribution.Features.Claims;
|
||||
|
||||
public interface IClaimRepository
|
||||
{
|
||||
Task<Models.Claim?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
|
||||
Task AddAsync(Models.Claim entity);
|
||||
void Update(Models.Claim entity);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.GenerateAsync(rideId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||
{
|
||||
var result = await service.GetByRideIdAsync(rideId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("delivery-tasks/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
||||
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetQuantitiesAsync(id, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
||||
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
||||
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.FailAsync(id, request.Note, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||
|
||||
public record DeliveryTaskResponse(
|
||||
Guid Id,
|
||||
Guid FK_RideId,
|
||||
Guid FK_OrderId,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
int Sequence,
|
||||
DeliveryTaskStatus Status,
|
||||
bool CustomerConfirmed,
|
||||
DateTime? ConfirmedAt,
|
||||
string? Note,
|
||||
List<DeliveryTaskItemResponse> Items,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
|
||||
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
|
||||
|
||||
public record FailTaskRequest(string Note);
|
||||
@@ -0,0 +1,38 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
|
||||
{
|
||||
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
||||
await context.DeliveryTasks
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.Store)
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.OrderItems)
|
||||
.ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
||||
await context.DeliveryTasks
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.Store)
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.OrderItems)
|
||||
.ThenInclude(i => i.Item)
|
||||
.Where(t => t.FK_RideId == rideId)
|
||||
.OrderBy(t => t.Sequence)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
|
||||
await context.DeliveryTasks
|
||||
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
|
||||
|
||||
public async Task AddAsync(DeliveryTask task) =>
|
||||
await context.DeliveryTasks.AddAsync(task);
|
||||
|
||||
public void Update(DeliveryTask task) =>
|
||||
context.DeliveryTasks.Update(task);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public class DeliveryTaskService(
|
||||
IDeliveryTaskRepository repo,
|
||||
IRideRepository rideRepo,
|
||||
IOrderRepository orderRepo,
|
||||
ISaleRepository saleRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||
new(t.Id,
|
||||
t.FK_RideId,
|
||||
t.FK_OrderId,
|
||||
t.Order.FK_StoreId,
|
||||
t.Order.Store?.StoreName ?? string.Empty,
|
||||
t.Sequence,
|
||||
t.Status,
|
||||
t.CustomerConfirmed,
|
||||
t.ConfirmedAt,
|
||||
t.Note,
|
||||
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
|
||||
i.FK_ItemId,
|
||||
i.Item?.Name ?? string.Empty,
|
||||
i.Quantity,
|
||||
i.QuantityFulfilled,
|
||||
i.UnitPrice)).ToList(),
|
||||
t.CreatedAt);
|
||||
|
||||
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.Active)
|
||||
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
|
||||
|
||||
if (ride.RideType != RideType.Delivery)
|
||||
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
|
||||
|
||||
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
|
||||
|
||||
var created = new List<DeliveryTask>();
|
||||
int sequence = 1;
|
||||
|
||||
foreach (var order in orders)
|
||||
{
|
||||
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
|
||||
continue;
|
||||
|
||||
var task = new DeliveryTask
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
FK_OrderId = order.Id,
|
||||
Sequence = sequence++,
|
||||
Status = DeliveryTaskStatus.Pending
|
||||
};
|
||||
created.Add(task);
|
||||
}
|
||||
|
||||
if (created.Count == 0)
|
||||
return new List<DeliveryTaskResponse>();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var task in created)
|
||||
await repo.AddAsync(task);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByRideIdAsync(rideId);
|
||||
return saved.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
||||
{
|
||||
var tasks = await repo.GetByRideIdAsync(rideId);
|
||||
return tasks.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(id);
|
||||
return task is null ? null : ToResponse(task);
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException("Delivery task not found");
|
||||
|
||||
if (task.Status != DeliveryTaskStatus.Pending)
|
||||
throw new InvalidOperationException("Quantities can only be set for a Pending task");
|
||||
|
||||
foreach (var input in request.Items)
|
||||
{
|
||||
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
|
||||
|
||||
if (input.QuantityFulfilled < 0)
|
||||
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
||||
|
||||
if (input.QuantityFulfilled > orderItem.Quantity)
|
||||
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
||||
|
||||
orderItem.QuantityFulfilled = input.QuantityFulfilled;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(task);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException("Delivery task not found");
|
||||
|
||||
if (task.Status != DeliveryTaskStatus.Pending)
|
||||
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
||||
|
||||
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
|
||||
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
var vehicleId = ride.FK_VehicleId;
|
||||
|
||||
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
|
||||
|
||||
task.CustomerConfirmed = true;
|
||||
task.ConfirmedAt = DateTime.Now;
|
||||
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
||||
|
||||
var order = task.Order;
|
||||
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
|
||||
if (fullyDelivered)
|
||||
order.FulfilledAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
|
||||
{
|
||||
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
|
||||
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
|
||||
|
||||
if (vehicleBalance.Quantity < item.QuantityFulfilled)
|
||||
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
|
||||
|
||||
vehicleBalance.Quantity -= item.QuantityFulfilled;
|
||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(vehicleBalance);
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = item.FK_ItemId,
|
||||
FromLocationId = vehicleId,
|
||||
FromLocationType = LocationType.Vehicle,
|
||||
Quantity = item.QuantityFulfilled,
|
||||
Type = MovementType.Sale,
|
||||
ReferenceId = task.FK_OrderId
|
||||
});
|
||||
}
|
||||
|
||||
repo.Update(task);
|
||||
orderRepo.Update(order);
|
||||
|
||||
if (fullyDelivered)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||
?? throw new KeyNotFoundException("Sale not found");
|
||||
sale.Status = SaleStatus.Completed;
|
||||
sale.CompletedAt = DateTime.Now;
|
||||
saleRepo.Update(sale);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException("Delivery task not found");
|
||||
|
||||
if (task.Status != DeliveryTaskStatus.Pending)
|
||||
throw new InvalidOperationException("Only a Pending task can be failed");
|
||||
|
||||
task.Status = DeliveryTaskStatus.Failed;
|
||||
task.Note = note;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(task);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(task);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public interface IDeliveryTaskRepository
|
||||
{
|
||||
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
||||
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
||||
Task AddAsync(DeliveryTask task);
|
||||
void Update(DeliveryTask task);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DriverComplianceConfig;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/driver-compliance-config")]
|
||||
[Authorize]
|
||||
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "compliance.manage")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var result = await service.GetAsync();
|
||||
return result is null ? NoContent() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Authorize(Policy = "compliance.manage")]
|
||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
var result = await service.UpsertAsync(request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Distribution.Features.DriverComplianceConfig;
|
||||
|
||||
public record DriverComplianceConfigResponse(
|
||||
Guid Id,
|
||||
bool EnforceLicenseExpiry,
|
||||
bool EnforceMedicalClearance,
|
||||
DateTime UpdatedAt,
|
||||
Guid UpdatedBy);
|
||||
|
||||
public record UpdateDriverComplianceConfigRequest(
|
||||
bool EnforceLicenseExpiry,
|
||||
bool EnforceMedicalClearance);
|
||||
@@ -1,16 +0,0 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.DriverComplianceConfig;
|
||||
|
||||
public class DriverComplianceConfigRepository(AppDBContext context) : IDriverComplianceConfigRepository
|
||||
{
|
||||
public async Task<Models.DriverComplianceConfig?> GetAsync() =>
|
||||
await context.DriverComplianceConfigs.FirstOrDefaultAsync();
|
||||
|
||||
public async Task AddAsync(Models.DriverComplianceConfig config) =>
|
||||
await context.DriverComplianceConfigs.AddAsync(config);
|
||||
|
||||
public void Update(Models.DriverComplianceConfig config) =>
|
||||
context.DriverComplianceConfigs.Update(config);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.DriverComplianceConfig;
|
||||
|
||||
public class DriverComplianceConfigService(IDriverComplianceConfigRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
public async Task<DriverComplianceConfigResponse?> GetAsync()
|
||||
{
|
||||
var config = await repo.GetAsync();
|
||||
return config is null
|
||||
? null
|
||||
: new DriverComplianceConfigResponse(
|
||||
config.Id,
|
||||
config.EnforceLicenseExpiry,
|
||||
config.EnforceMedicalClearance,
|
||||
config.UpdatedAt,
|
||||
config.UpdatedBy);
|
||||
}
|
||||
|
||||
public async Task<DriverComplianceConfigResponse> UpsertAsync(UpdateDriverComplianceConfigRequest request, Guid updatedBy, CancellationToken ct = default)
|
||||
{
|
||||
var config = await repo.GetAsync();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
config = new global::Distribution.Models.DriverComplianceConfig
|
||||
{
|
||||
EnforceLicenseExpiry = request.EnforceLicenseExpiry,
|
||||
EnforceMedicalClearance = request.EnforceMedicalClearance,
|
||||
UpdatedBy = updatedBy,
|
||||
UpdatedAt = DateTime.Now
|
||||
};
|
||||
await repo.AddAsync(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.EnforceLicenseExpiry = request.EnforceLicenseExpiry;
|
||||
config.EnforceMedicalClearance = request.EnforceMedicalClearance;
|
||||
config.UpdatedBy = updatedBy;
|
||||
config.UpdatedAt = DateTime.Now;
|
||||
repo.Update(config);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new DriverComplianceConfigResponse(
|
||||
config.Id,
|
||||
config.EnforceLicenseExpiry,
|
||||
config.EnforceMedicalClearance,
|
||||
config.UpdatedAt,
|
||||
config.UpdatedBy);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Distribution.Features.DriverComplianceConfig;
|
||||
|
||||
public interface IDriverComplianceConfigRepository
|
||||
{
|
||||
Task<Models.DriverComplianceConfig?> GetAsync();
|
||||
Task AddAsync(Models.DriverComplianceConfig config);
|
||||
void Update(Models.DriverComplianceConfig config);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DriverProfile;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/driver-profiles")]
|
||||
[Authorize]
|
||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
||||
public class DriverProfileController(
|
||||
DriverProfileService service,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "driver_profiles.view")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(page, pageSize);
|
||||
@@ -17,7 +17,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "driver_profiles.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -25,15 +24,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "driver_profiles.create")]
|
||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "driver_profiles.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -41,10 +49,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "driver_profiles.deactivate")]
|
||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var success = await service.DeactivateAsync(id, ct);
|
||||
return success ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("compliance")]
|
||||
public async Task<IActionResult> GetCompliance()
|
||||
{
|
||||
var result = await service.GetComplianceConfigAsync();
|
||||
return result is null ? NoContent() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("compliance")]
|
||||
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
var result = await service.UpsertComplianceConfigAsync(request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record CreateDriverProfileRequest(
|
||||
Guid FK_UserId,
|
||||
Guid UserId,
|
||||
string LicenseNumber,
|
||||
DateTime LicenseExpiry,
|
||||
DateTime MedicalClearanceExpiry);
|
||||
@@ -20,3 +20,14 @@ public record UpdateDriverProfileRequest(
|
||||
string LicenseNumber,
|
||||
DateTime LicenseExpiry,
|
||||
DateTime MedicalClearanceExpiry);
|
||||
|
||||
public record DriverComplianceConfigResponse(
|
||||
Guid Id,
|
||||
bool EnforceLicenseExpiry,
|
||||
bool EnforceMedicalClearance,
|
||||
DateTime UpdatedAt,
|
||||
Guid UpdatedBy);
|
||||
|
||||
public record UpdateDriverComplianceConfigRequest(
|
||||
bool EnforceLicenseExpiry,
|
||||
bool EnforceMedicalClearance);
|
||||
|
||||
@@ -37,4 +37,13 @@ public class DriverProfileRepository(AppDBContext context) : IDriverProfileRepos
|
||||
profile.IsActive = false;
|
||||
context.DriverProfiles.Update(profile);
|
||||
}
|
||||
|
||||
public async Task<global::Distribution.Models.DriverComplianceConfig?> GetComplianceConfigAsync() =>
|
||||
await context.DriverComplianceConfigs.FirstOrDefaultAsync();
|
||||
|
||||
public async Task AddComplianceConfigAsync(global::Distribution.Models.DriverComplianceConfig config) =>
|
||||
await context.DriverComplianceConfigs.AddAsync(config);
|
||||
|
||||
public void UpdateComplianceConfig(global::Distribution.Models.DriverComplianceConfig config) =>
|
||||
context.DriverComplianceConfigs.Update(config);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.User;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.DriverProfile;
|
||||
|
||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
|
||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
|
||||
{
|
||||
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
||||
{
|
||||
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
|
||||
|
||||
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
|
||||
var user = await userRepo.GetByIdAsync(request.UserId);
|
||||
if (user is null)
|
||||
throw new KeyNotFoundException($"User {request.UserId} not found");
|
||||
|
||||
var existing = await repo.GetByUserIdAsync(request.UserId);
|
||||
if (existing is not null && existing.IsActive)
|
||||
throw new InvalidOperationException("An active driver profile already exists for this user");
|
||||
|
||||
var profile = new global::Distribution.Models.DriverProfile
|
||||
{
|
||||
FK_UserId = request.FK_UserId,
|
||||
FK_UserId = request.UserId,
|
||||
LicenseNumber = request.LicenseNumber,
|
||||
LicenseExpiry = request.LicenseExpiry,
|
||||
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
||||
@@ -93,6 +98,56 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<DriverComplianceConfigResponse?> GetComplianceConfigAsync()
|
||||
{
|
||||
var config = await repo.GetComplianceConfigAsync();
|
||||
return config is null ? null
|
||||
: new DriverComplianceConfigResponse(
|
||||
config.Id, config.EnforceLicenseExpiry, config.EnforceMedicalClearance,
|
||||
config.UpdatedAt, config.UpdatedBy);
|
||||
}
|
||||
|
||||
public async Task<DriverComplianceConfigResponse> UpsertComplianceConfigAsync(
|
||||
UpdateDriverComplianceConfigRequest request, Guid updatedBy, CancellationToken ct = default)
|
||||
{
|
||||
var config = await repo.GetComplianceConfigAsync();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
config = new global::Distribution.Models.DriverComplianceConfig
|
||||
{
|
||||
EnforceLicenseExpiry = request.EnforceLicenseExpiry,
|
||||
EnforceMedicalClearance = request.EnforceMedicalClearance,
|
||||
UpdatedBy = updatedBy,
|
||||
UpdatedAt = DateTime.Now
|
||||
};
|
||||
await repo.AddComplianceConfigAsync(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.EnforceLicenseExpiry = request.EnforceLicenseExpiry;
|
||||
config.EnforceMedicalClearance = request.EnforceMedicalClearance;
|
||||
config.UpdatedBy = updatedBy;
|
||||
config.UpdatedAt = DateTime.Now;
|
||||
repo.UpdateComplianceConfig(config);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new DriverComplianceConfigResponse(
|
||||
config.Id, config.EnforceLicenseExpiry, config.EnforceMedicalClearance,
|
||||
config.UpdatedAt, config.UpdatedBy);
|
||||
}
|
||||
|
||||
private static DriverProfileResponse ToResponse(global::Distribution.Models.DriverProfile p) =>
|
||||
new(p.Id, p.FK_UserId, p.User?.FullName ?? string.Empty,
|
||||
p.LicenseNumber, p.LicenseExpiry, p.MedicalClearanceExpiry,
|
||||
|
||||
@@ -9,4 +9,8 @@ public interface IDriverProfileRepository
|
||||
Task AddAsync(global::Distribution.Models.DriverProfile profile);
|
||||
void Update(global::Distribution.Models.DriverProfile profile);
|
||||
void Deactivate(global::Distribution.Models.DriverProfile profile);
|
||||
|
||||
Task<global::Distribution.Models.DriverComplianceConfig?> GetComplianceConfigAsync();
|
||||
Task AddComplianceConfigAsync(global::Distribution.Models.DriverComplianceConfig config);
|
||||
void UpdateComplianceConfig(global::Distribution.Models.DriverComplianceConfig config);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
[ApiController]
|
||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/goods-receipts")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? purchaseOrderId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("api/goods-receipts")]
|
||||
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
||||
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(id, request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
|
||||
|
||||
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||
|
||||
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
|
||||
|
||||
public record GrnItemResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderItemId,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
int AcceptedQuantity,
|
||||
int RejectedQuantity);
|
||||
|
||||
public record GrnResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderId,
|
||||
Guid ReceivedByUserId,
|
||||
Guid WarehouseId,
|
||||
string WarehouseName,
|
||||
DateTime ReceivedAt,
|
||||
DateTime CreatedAt,
|
||||
List<GrnItemResponse> Items);
|
||||
@@ -0,0 +1,50 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||
{
|
||||
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||
|
||||
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||
.OrderByDescending(gr => gr.ReceivedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
|
||||
await context.GoodsReceipts
|
||||
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||
.CountAsync();
|
||||
|
||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||
.OrderByDescending(gr => gr.ReceivedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.GoodsReceipt entity) =>
|
||||
await context.GoodsReceipts.AddAsync(entity);
|
||||
|
||||
public async Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive);
|
||||
|
||||
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceiptItems
|
||||
.Include(i => i.PurchaseOrderItem)
|
||||
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public class GrnService(
|
||||
IGrnRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
|
||||
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
|
||||
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
|
||||
|
||||
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(purchaseOrderId);
|
||||
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
||||
}
|
||||
|
||||
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var gr = await repo.GetByIdAsync(id);
|
||||
if (gr is null || !gr.IsActive) return null;
|
||||
return Map(gr);
|
||||
}
|
||||
|
||||
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
||||
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
|
||||
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
|
||||
/// </summary>
|
||||
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await poRepo.GetByIdAsync(purchaseOrderId)
|
||||
?? throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (!po.IsActive)
|
||||
throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot receive goods against a purchase order in {po.Status} status.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||
|
||||
var goodsReceipt = new Models.GoodsReceipt
|
||||
{
|
||||
FK_PurchaseOrderId = po.Id,
|
||||
FK_ReceivedByUserId = receivedByUserId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
ReceivedAt = DateTime.Now
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||
|
||||
if (poItem.IsCancelled)
|
||||
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
||||
|
||||
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
|
||||
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||
|
||||
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
||||
throw new InvalidOperationException(
|
||||
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
||||
|
||||
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||
|
||||
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
||||
{
|
||||
FK_PurchaseOrderItemId = poItem.Id,
|
||||
AcceptedQuantity = line.AcceptedQuantity,
|
||||
RejectedQuantity = line.RejectedQuantity
|
||||
});
|
||||
}
|
||||
|
||||
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
|
||||
? PurchaseOrderStatus.Received
|
||||
: PurchaseOrderStatus.PartiallyReceived;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(goodsReceipt);
|
||||
|
||||
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
||||
{
|
||||
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
|
||||
|
||||
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
||||
if (balance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = poItem.FK_ItemId,
|
||||
LocationId = request.WarehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += line.AcceptedQuantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = poItem.FK_ItemId,
|
||||
ToLocationId = request.WarehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity,
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
});
|
||||
}
|
||||
|
||||
poRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||
return Map(saved!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public interface IGrnRepository
|
||||
{
|
||||
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? purchaseOrderId);
|
||||
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.GoodsReceipt entity);
|
||||
|
||||
/// <summary>True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard).</summary>
|
||||
Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId);
|
||||
|
||||
/// <summary>Sum of AcceptedQuantity per ItemId across all receipts for a Po.</summary>
|
||||
Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId);
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Item;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/items")]
|
||||
[Authorize]
|
||||
public class ItemController(ItemService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? categoryId,
|
||||
[FromQuery] Guid? subCategoryId,
|
||||
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "items.create")]
|
||||
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "items.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/uom-conversions")]
|
||||
[Authorize(Policy = "items.update")]
|
||||
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
public interface ILoadingSheetRepository
|
||||
{
|
||||
Task<LoadingSheet?> GetByIdAsync(Guid id);
|
||||
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
|
||||
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
|
||||
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
|
||||
Task AddAsync(LoadingSheet sheet);
|
||||
void Update(LoadingSheet sheet);
|
||||
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
||||
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Distribution.Models;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/loading-sheets")]
|
||||
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? rideId,
|
||||
[FromQuery] LoadingSheetStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(rideId, status, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetItemsAsync(id, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm-dispatch")]
|
||||
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/receipt-items")]
|
||||
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetReceiptItemsAsync(id, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm-receipt")]
|
||||
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/resolve")]
|
||||
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ResolveAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/dispatch")]
|
||||
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/receipt")]
|
||||
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/resolve")]
|
||||
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.PatchResolveAsync(id, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
public record LoadingSheetResponse(
|
||||
Guid Id,
|
||||
Guid FK_RideId,
|
||||
Guid FK_SourceWarehouseId,
|
||||
string WarehouseName,
|
||||
LoadingSheetStatus Status,
|
||||
DateTime? DispatchConfirmedAt,
|
||||
Guid? DispatchConfirmedBy,
|
||||
DateTime? ReceiptConfirmedAt,
|
||||
Guid? ReceiptConfirmedBy,
|
||||
DateTime CreatedAt,
|
||||
List<LoadingSheetItemResponse> Items);
|
||||
|
||||
public record LoadingSheetItemResponse(
|
||||
Guid Id,
|
||||
Guid FK_ItemId,
|
||||
string ItemName,
|
||||
decimal QuantityDispatched,
|
||||
decimal? QuantityReceived,
|
||||
decimal? QuantityResolved);
|
||||
|
||||
public record CreateLoadingSheetRequest(Guid FK_RideId);
|
||||
|
||||
public record SetItemsRequest(List<SetItemInput> Items);
|
||||
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
|
||||
|
||||
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
|
||||
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
|
||||
|
||||
public record ConfirmReceiptRequest(int Meterstart);
|
||||
|
||||
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
||||
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
||||
|
||||
// Combined PATCH endpoints used by the frontend
|
||||
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
|
||||
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
|
||||
@@ -0,0 +1,63 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetRepository
|
||||
{
|
||||
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
|
||||
await context.LoadingSheets
|
||||
.Include(s => s.SourceWarehouse)
|
||||
.Include(s => s.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
|
||||
await context.LoadingSheets
|
||||
.FirstOrDefaultAsync(s =>
|
||||
s.FK_RideId == rideId &&
|
||||
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
|
||||
s.Status != LoadingSheetStatus.Resolved);
|
||||
|
||||
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
|
||||
await context.LoadingSheets
|
||||
.Include(s => s.SourceWarehouse)
|
||||
.Where(s =>
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(status == null || s.Status == status))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
|
||||
await context.LoadingSheets
|
||||
.CountAsync(s =>
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(status == null || s.Status == status));
|
||||
|
||||
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
||||
{
|
||||
var groups = await context.OrderItems
|
||||
.Where(oi => oi.IsActive
|
||||
&& oi.Order.IsActive
|
||||
&& oi.Order.Store.FK_RouteId == routeId
|
||||
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
|
||||
.GroupBy(oi => oi.FK_ItemId)
|
||||
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
|
||||
.ToListAsync();
|
||||
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
||||
}
|
||||
|
||||
public async Task AddAsync(LoadingSheet sheet) =>
|
||||
await context.LoadingSheets.AddAsync(sheet);
|
||||
|
||||
public void Update(LoadingSheet sheet) =>
|
||||
context.LoadingSheets.Update(sheet);
|
||||
|
||||
public void RemoveItems(IEnumerable<LoadingSheetItem> items) =>
|
||||
context.LoadingSheetItems.RemoveRange(items);
|
||||
|
||||
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
|
||||
await context.LoadingSheetItems.AddRangeAsync(items);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.LoadingEvent;
|
||||
|
||||
public class LoadingSheetService(
|
||||
ILoadingSheetRepository repo,
|
||||
IRideRepository rideRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(rideId, status);
|
||||
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
|
||||
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id);
|
||||
return sheet is null ? null : ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.Open)
|
||||
throw new InvalidOperationException("A loading sheet can only be created for an Open ride");
|
||||
|
||||
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
|
||||
if (existing is not null)
|
||||
throw new InvalidOperationException("This ride already has an active loading sheet");
|
||||
|
||||
var sheet = new LoadingSheet
|
||||
{
|
||||
FK_RideId = ride.Id,
|
||||
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
|
||||
Status = LoadingSheetStatus.Pending
|
||||
};
|
||||
|
||||
if (ride.RideType == RideType.Delivery)
|
||||
{
|
||||
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
|
||||
foreach (var (itemId, qty) in orderItems)
|
||||
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(sheet);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(sheet.Id);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> SetItemsAsync(Guid id, SetItemsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.Pending)
|
||||
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.RemoveItems(sheet.Items);
|
||||
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
|
||||
{
|
||||
FK_LoadingSheetId = sheet.Id,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
QuantityDispatched = i.Quantity
|
||||
}));
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(sheet.Id);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.Pending)
|
||||
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
|
||||
|
||||
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
|
||||
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
|
||||
|
||||
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
|
||||
sheet.DispatchConfirmedAt = DateTime.Now;
|
||||
sheet.DispatchConfirmedBy = userId;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(sheet); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
|
||||
|
||||
foreach (var input in request.Items)
|
||||
{
|
||||
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
|
||||
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
|
||||
item.QuantityReceived = input.QuantityReceived;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(sheet); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
|
||||
|
||||
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
|
||||
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
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 = 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> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, 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 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");
|
||||
if (input.QuantityResolved < 0)
|
||||
throw new InvalidOperationException("Resolved quantity cannot be negative");
|
||||
item.QuantityResolved = input.QuantityResolved;
|
||||
}
|
||||
|
||||
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
|
||||
throw new InvalidOperationException("All items must have a resolved quantity");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
sheet.Status = LoadingSheetStatus.Resolved;
|
||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||
sheet.ReceiptConfirmedBy = userId;
|
||||
|
||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||
|
||||
ride.Status = RideStatus.Active;
|
||||
ride.ActivatedAt = DateTime.Now;
|
||||
ride.Meterstart = request.Meterstart;
|
||||
rideRepo.Update(ride);
|
||||
|
||||
repo.Update(sheet);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
|
||||
|
||||
foreach (var input in items)
|
||||
{
|
||||
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
|
||||
sheetItem.QuantityReceived = input.QuantityReceived;
|
||||
}
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (hasDiscrepancy)
|
||||
{
|
||||
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
||||
}
|
||||
else
|
||||
{
|
||||
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||
sheet.ReceiptConfirmedBy = userId;
|
||||
|
||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||
|
||||
ride.Status = RideStatus.Active;
|
||||
ride.ActivatedAt = DateTime.Now;
|
||||
ride.Meterstart = 0;
|
||||
rideRepo.Update(ride);
|
||||
}
|
||||
|
||||
repo.Update(sheet);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
||||
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
||||
|
||||
foreach (var item in sheet.Items)
|
||||
{
|
||||
if (!item.QuantityReceived.HasValue)
|
||||
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
|
||||
item.QuantityResolved = item.QuantityReceived.Value;
|
||||
}
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
sheet.Status = LoadingSheetStatus.Resolved;
|
||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||
sheet.ReceiptConfirmedBy = userId;
|
||||
|
||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||
|
||||
ride.Status = RideStatus.Active;
|
||||
ride.ActivatedAt = DateTime.Now;
|
||||
ride.Meterstart = 0;
|
||||
rideRepo.Update(ride);
|
||||
|
||||
repo.Update(sheet);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
||||
{
|
||||
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
||||
{
|
||||
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
|
||||
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
|
||||
if (warehouseBalance.Quantity < qty)
|
||||
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
|
||||
|
||||
warehouseBalance.Quantity -= qty;
|
||||
warehouseBalance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(warehouseBalance);
|
||||
|
||||
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
|
||||
if (vehicleBalance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = itemId,
|
||||
LocationId = vehicleId,
|
||||
LocationType = LocationType.Vehicle,
|
||||
Quantity = qty
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vehicleBalance.Quantity += qty;
|
||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(vehicleBalance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = itemId,
|
||||
FromLocationId = sheet.FK_SourceWarehouseId,
|
||||
FromLocationType = LocationType.Warehouse,
|
||||
ToLocationId = vehicleId,
|
||||
ToLocationType = LocationType.Vehicle,
|
||||
Quantity = qty,
|
||||
Type = MovementType.Load,
|
||||
ReferenceId = sheet.Id,
|
||||
CreatedBy = userId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
|
||||
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
|
||||
s.SourceWarehouse?.Name ?? string.Empty,
|
||||
s.Status,
|
||||
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
|
||||
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
|
||||
s.CreatedAt,
|
||||
s.Items.Select(i => new LoadingSheetItemResponse(
|
||||
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
|
||||
}
|
||||
@@ -4,10 +4,12 @@ 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,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/orders")]
|
||||
[Authorize]
|
||||
public class OrderController(OrderService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("store/{storeId:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
@@ -30,23 +25,28 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "orders.create")]
|
||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "orders.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
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}")]
|
||||
[Authorize(Policy = "orders.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
|
||||
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record CreateOrderRequest(Guid SaleId);
|
||||
|
||||
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
|
||||
public record UpdateOrderRequest(OrderStatus Status);
|
||||
|
||||
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
|
||||
|
||||
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
|
||||
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,4 +1,5 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
||||
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);
|
||||
|
||||
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
||||
|
||||
public void Remove(Models.Order entity)
|
||||
{
|
||||
entity.Status = Models.OrderStatus.Cancelled;
|
||||
entity.IsActive = false;
|
||||
context.Orders.Update(entity);
|
||||
}
|
||||
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
||||
|
||||
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,10 +1,11 @@
|
||||
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;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
|
||||
@@ -12,17 +13,18 @@ public class OrderService(
|
||||
IOrderRepository repo,
|
||||
IStoreRepository storeRepo,
|
||||
IItemRepository itemRepo,
|
||||
IStockRepository stockRepo,
|
||||
PriceListService priceListService,
|
||||
ISaleRepository saleRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser,
|
||||
IScopeAccessService scopeAccess,
|
||||
IConfiguration configuration)
|
||||
IScopeAccessService scopeAccess)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
|
||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
|
||||
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;
|
||||
@@ -33,29 +35,6 @@ public class OrderService(
|
||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
|
||||
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
|
||||
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
|
||||
/// can be resolved, or if the requested price is outside the allowed band.
|
||||
/// </summary>
|
||||
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
|
||||
{
|
||||
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, orderDate);
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task<List<OrderResponse>> GetAllAsync()
|
||||
{
|
||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
||||
@@ -97,50 +76,15 @@ public class OrderService(
|
||||
|
||||
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||
var sale = await saleRepo.GetByIdAsync(request.SaleId);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (store.KycStatus != KycStatus.Verified)
|
||||
{
|
||||
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
||||
if (request.TotalAmount > threshold) return null;
|
||||
}
|
||||
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
|
||||
?? throw new InvalidOperationException("Orders are created automatically from sales");
|
||||
|
||||
var order = new Models.Order
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
OrderDate = request.OrderDate,
|
||||
TotalAmount = request.TotalAmount
|
||||
};
|
||||
var orderItems = new List<Models.OrderItem>();
|
||||
foreach (var i in request.Items)
|
||||
{
|
||||
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
||||
if (unitPrice is null) return null;
|
||||
|
||||
orderItems.Add(new Models.OrderItem
|
||||
{
|
||||
FK_OrderId = order.Id,
|
||||
FK_ItemId = i.ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = unitPrice.Value
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(order);
|
||||
await repo.AddItemsAsync(orderItems);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(order, orderItems);
|
||||
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
|
||||
return ToResponse(existingOrder, items);
|
||||
}
|
||||
|
||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
||||
@@ -148,12 +92,26 @@ public class OrderService(
|
||||
var order = await repo.GetByIdAsync(id);
|
||||
if (order is null || !order.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
||||
order.OrderDate = request.OrderDate;
|
||||
order.TotalAmount = request.TotalAmount;
|
||||
|
||||
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
|
||||
@@ -161,6 +119,7 @@ public class OrderService(
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||
return ToResponse(order, items);
|
||||
}
|
||||
@@ -170,10 +129,19 @@ public class OrderService(
|
||||
var order = await repo.GetByIdAsync(id);
|
||||
if (order is null || !order.IsActive) return false;
|
||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) 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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/payments")]
|
||||
public class FlatPaymentController(PaymentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? storeId,
|
||||
[FromQuery] Guid? saleId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inner = new CreatePaymentRequest(request.Amount, request.Method);
|
||||
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 }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public interface IPaymentRepository
|
||||
{
|
||||
Task<Models.Payment?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
|
||||
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||
Task AddAsync(Models.Payment payment);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sales/{saleId:guid}/payments")]
|
||||
public class PaymentController(PaymentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetBySale(Guid saleId)
|
||||
{
|
||||
var result = await service.GetBySaleIdAsync(saleId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(saleId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||
|
||||
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
|
||||
|
||||
public record PaymentResponse(
|
||||
Guid Id,
|
||||
Guid SaleId,
|
||||
Guid StoreId,
|
||||
decimal Amount,
|
||||
PaymentMethod Method,
|
||||
DateTime CollectedAt,
|
||||
Guid CollectedBy,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,37 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||
{
|
||||
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
||||
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
||||
|
||||
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
|
||||
await context.Payments
|
||||
.Where(p => p.IsActive
|
||||
&& (storeId == null || p.FK_StoreId == storeId)
|
||||
&& (saleId == null || p.FK_SaleId == saleId))
|
||||
.OrderByDescending(p => p.CollectedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
|
||||
await context.Payments
|
||||
.Where(p => p.IsActive
|
||||
&& (storeId == null || p.FK_StoreId == storeId)
|
||||
&& (saleId == null || p.FK_SaleId == saleId))
|
||||
.CountAsync();
|
||||
|
||||
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||
await context.Payments
|
||||
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||
.OrderBy(p => p.CollectedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Payment payment) =>
|
||||
await context.Payments.AddAsync(payment);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public class PaymentService(
|
||||
IPaymentRepository repo,
|
||||
ISaleRepository saleRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||
|
||||
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(storeId, saleId);
|
||||
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
|
||||
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var payment = await repo.GetByIdAsync(id);
|
||||
return payment is null ? null : ToResponse(payment);
|
||||
}
|
||||
|
||||
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
|
||||
var payments = await repo.GetBySaleIdAsync(saleId);
|
||||
return payments.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
if (sale.Status == SaleStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
|
||||
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
var payment = new Models.Payment
|
||||
{
|
||||
FK_SaleId = saleId,
|
||||
FK_StoreId = sale.FK_StoreId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
CollectedAt = DateTime.Now,
|
||||
CollectedBy = userId
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
var allPayments = await repo.GetBySaleIdAsync(saleId);
|
||||
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||
|
||||
sale.PaymentStatus = total == 0
|
||||
? PaymentStatus.Pending
|
||||
: total < sale.TotalAmount
|
||||
? PaymentStatus.PartiallyPaid
|
||||
: PaymentStatus.Paid;
|
||||
|
||||
saleRepo.Update(sale);
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PriceList;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/price-lists")]
|
||||
[Authorize]
|
||||
public class PriceListController(PriceListService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public interface IPoRepository
|
||||
{
|
||||
Task<Models.PurchaseOrder?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status);
|
||||
Task AddAsync(Models.PurchaseOrder entity);
|
||||
void Update(Models.PurchaseOrder entity);
|
||||
|
||||
Task AddItemAsync(PurchaseOrderItem entity);
|
||||
|
||||
/// <summary>True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier.</summary>
|
||||
Task<bool> HasActiveForSupplierAsync(Guid supplierId);
|
||||
|
||||
/// <summary>All active PurchaseOrders for a supplier, with items, used by evaluateSupplier.</summary>
|
||||
Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchase-orders")]
|
||||
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
|
||||
Ok(await service.GetAllAsync(supplierId, status));
|
||||
|
||||
[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(CreatePoRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/close")]
|
||||
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CloseAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CancelAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
|
||||
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CancelLineAsync(id, itemId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/match-invoice")]
|
||||
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
|
||||
{
|
||||
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/resolve-variance")]
|
||||
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ResolveInvoiceVarianceAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
|
||||
|
||||
public record CreatePoRequest(Guid SupplierId, List<PoItemRequest> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
|
||||
/// null = add a new line to the Po. Only valid while the Po is Issued.
|
||||
/// </summary>
|
||||
public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost);
|
||||
|
||||
public record UpdatePoRequest(List<UpdatePoLineRequest> Items);
|
||||
|
||||
public record CancelPoRequest(string Reason);
|
||||
|
||||
public record CancelPoLineRequest(string? Reason);
|
||||
|
||||
public record PoItemResponse(
|
||||
Guid Id,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
int Quantity,
|
||||
decimal UnitCost,
|
||||
int ReceivedQuantity,
|
||||
int CancelledQuantity,
|
||||
bool IsCancelled);
|
||||
|
||||
public record PoResponse(
|
||||
Guid Id,
|
||||
Guid SupplierId,
|
||||
string SupplierName,
|
||||
Guid CreatedByUserId,
|
||||
PurchaseOrderStatus Status,
|
||||
string? CancelReason,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<PoItemResponse> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Po-vs-Grn quantity/cost reconciliation. Full 3-way match (Po + Grn + Invoice) activates
|
||||
/// once Finance's Invoice entity (step 19, not yet built) exists — until then this compares only
|
||||
/// PurchaseOrderItem vs GoodsReceiptItem.
|
||||
/// </summary>
|
||||
public record InvoiceMatchLineResult(Guid PurchaseOrderItemId, Guid ItemId, int OrderedQuantity, int AcceptedQuantity, decimal UnitCost, bool QuantityMatches);
|
||||
|
||||
public record InvoiceMatchResult(Guid PurchaseOrderId, Guid? GoodsReceiptId, Guid? InvoiceId, bool IsMatched, List<InvoiceMatchLineResult> Lines, string Note);
|
||||
|
||||
public record ResolveInvoiceVarianceRequest(string Variance, string Resolution, decimal? AdjustedUnitCost, Guid? PurchaseOrderItemId);
|
||||
|
||||
public record InvoiceVarianceResolutionResponse(Guid PurchaseOrderId, string Variance, string Resolution, DateTime ResolvedAt);
|
||||
@@ -0,0 +1,49 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public class PoRepository(AppDBContext context) : IPoRepository
|
||||
{
|
||||
public async Task<Models.PurchaseOrder?> GetByIdAsync(Guid id) =>
|
||||
await context.PurchaseOrders
|
||||
.Include(po => po.Supplier)
|
||||
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(po => po.Id == id);
|
||||
|
||||
public async Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status)
|
||||
{
|
||||
var query = context.PurchaseOrders
|
||||
.Include(po => po.Supplier)
|
||||
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||
.Where(po => po.IsActive)
|
||||
.AsQueryable();
|
||||
|
||||
if (supplierId.HasValue) query = query.Where(po => po.FK_SupplierId == supplierId.Value);
|
||||
if (status.HasValue) query = query.Where(po => po.Status == status.Value);
|
||||
|
||||
return await query.OrderByDescending(po => po.CreatedAt).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task AddAsync(Models.PurchaseOrder entity) =>
|
||||
await context.PurchaseOrders.AddAsync(entity);
|
||||
|
||||
public void Update(Models.PurchaseOrder entity) =>
|
||||
context.PurchaseOrders.Update(entity);
|
||||
|
||||
public async Task AddItemAsync(PurchaseOrderItem entity) =>
|
||||
await context.PurchaseOrderItems.AddAsync(entity);
|
||||
|
||||
public async Task<bool> HasActiveForSupplierAsync(Guid supplierId) =>
|
||||
await context.PurchaseOrders.AnyAsync(po =>
|
||||
po.FK_SupplierId == supplierId &&
|
||||
po.IsActive &&
|
||||
po.Status != PurchaseOrderStatus.Cancelled);
|
||||
|
||||
public async Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId) =>
|
||||
await context.PurchaseOrders
|
||||
.Include(po => po.Items)
|
||||
.Where(po => po.FK_SupplierId == supplierId && po.IsActive)
|
||||
.ToListAsync();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Po;
|
||||
|
||||
public class PoService(
|
||||
IPoRepository repo,
|
||||
IGrnRepository goodsReceiptRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
|
||||
|
||||
private static PoResponse Map(Models.PurchaseOrder po) =>
|
||||
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
|
||||
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
|
||||
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
|
||||
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
|
||||
|
||||
public async Task<PoResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A purchase order must have at least one line item.");
|
||||
|
||||
var po = new Models.PurchaseOrder
|
||||
{
|
||||
FK_SupplierId = request.SupplierId,
|
||||
FK_CreatedByUserId = createdByUserId,
|
||||
Status = PurchaseOrderStatus.Issued
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
po.Items.Add(new PurchaseOrderItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitCost = line.UnitCost
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(po.Id))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
|
||||
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
|
||||
/// </summary>
|
||||
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (po.Status != PurchaseOrderStatus.Issued)
|
||||
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.PurchaseOrderItemId.HasValue)
|
||||
{
|
||||
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||
|
||||
if (existing.ReceivedQuantity > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
|
||||
|
||||
existing.FK_ItemId = line.ItemId;
|
||||
existing.Quantity = line.Quantity;
|
||||
existing.UnitCost = line.UnitCost;
|
||||
}
|
||||
else
|
||||
{
|
||||
await repo.AddItemAsync(new PurchaseOrderItem
|
||||
{
|
||||
FK_PurchaseOrderId = po.Id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
UnitCost = line.UnitCost
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(id))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
|
||||
/// </summary>
|
||||
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
|
||||
|
||||
po.Status = PurchaseOrderStatus.Closed;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
|
||||
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
|
||||
|
||||
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
|
||||
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
|
||||
|
||||
po.Status = PurchaseOrderStatus.Cancelled;
|
||||
po.CancelReason = request.Reason;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity < Quantity
|
||||
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
|
||||
/// every non-cancelled line is fully received.
|
||||
/// </summary>
|
||||
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
|
||||
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||
|
||||
if (line.IsCancelled)
|
||||
throw new InvalidOperationException("This line is already cancelled.");
|
||||
|
||||
if (line.ReceivedQuantity >= line.Quantity)
|
||||
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
|
||||
|
||||
line.IsCancelled = true;
|
||||
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
|
||||
|
||||
var activeLines = po.Items.Where(i => i.IsActive).ToList();
|
||||
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
|
||||
&& activeLines.Any(i => !i.IsCancelled))
|
||||
{
|
||||
po.Status = PurchaseOrderStatus.Received;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(po);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
|
||||
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
|
||||
/// </summary>
|
||||
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(poId);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
var receipts = grnId.HasValue
|
||||
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
|
||||
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
|
||||
|
||||
var acceptedByItem = receipts
|
||||
.SelectMany(g => g.Items.Where(i => i.IsActive))
|
||||
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
|
||||
|
||||
var lines = po.Items.Where(i => i.IsActive).Select(i =>
|
||||
{
|
||||
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
|
||||
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
|
||||
}).ToList();
|
||||
|
||||
var isMatched = lines.All(l => l.QuantityMatches);
|
||||
|
||||
return new InvoiceMatchResult(
|
||||
poId, grnId, invoiceId, isMatched, lines,
|
||||
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
|
||||
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
|
||||
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
|
||||
/// </summary>
|
||||
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(poId);
|
||||
if (po is null || !po.IsActive) return null;
|
||||
|
||||
if (request.AdjustedUnitCost.HasValue)
|
||||
{
|
||||
if (!request.PurchaseOrderItemId.HasValue)
|
||||
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
|
||||
|
||||
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
|
||||
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||
|
||||
line.UnitCost = request.AdjustedUnitCost.Value;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
}
|
||||
|
||||
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
|
||||
public interface IPurchaseReturnRepository
|
||||
{
|
||||
Task<Models.PurchaseReturn?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.PurchaseReturn entity);
|
||||
|
||||
/// <summary>Sum of Quantity per ItemId across all prior returns for a Po.</summary>
|
||||
Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
|
||||
[ApiController]
|
||||
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/returns")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/returns")]
|
||||
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(id, request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
|
||||
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
|
||||
|
||||
public record CreatePurchaseReturnRequest(string Reason, List<PurchaseReturnItemRequest> Items);
|
||||
|
||||
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
|
||||
|
||||
public record PurchaseReturnResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderId,
|
||||
string Reason,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<PurchaseReturnItemResponse> Items);
|
||||
@@ -0,0 +1,30 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
|
||||
public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository
|
||||
{
|
||||
public async Task<Models.PurchaseReturn?> GetByIdAsync(Guid id) =>
|
||||
await context.PurchaseReturns
|
||||
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(pr => pr.Id == id);
|
||||
|
||||
public async Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
await context.PurchaseReturns
|
||||
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||
.Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive)
|
||||
.OrderByDescending(pr => pr.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.PurchaseReturn entity) =>
|
||||
await context.PurchaseReturns.AddAsync(entity);
|
||||
|
||||
public async Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||
await context.PurchaseReturnItems
|
||||
.Include(i => i.PurchaseReturn)
|
||||
.Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||
.GroupBy(i => i.FK_ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.PurchaseReturn;
|
||||
|
||||
public class PurchaseReturnService(
|
||||
IPurchaseReturnRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IGrnRepository goodsReceiptRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity);
|
||||
|
||||
private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) =>
|
||||
new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt,
|
||||
pr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<PurchaseReturnResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var pr = await repo.GetByIdAsync(id);
|
||||
if (pr is null || !pr.IsActive) return null;
|
||||
return Map(pr);
|
||||
}
|
||||
|
||||
public async Task<List<PurchaseReturnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot
|
||||
/// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance
|
||||
/// and writes a StockMovement in the same unit of work.
|
||||
/// </summary>
|
||||
public async Task<PurchaseReturnResponse> CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||
if (po is null || !po.IsActive)
|
||||
throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId))
|
||||
throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A purchase return must have at least one line item.");
|
||||
|
||||
var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId);
|
||||
var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId);
|
||||
|
||||
// The receipt(s) for this Po share a single receiving warehouse per the current model
|
||||
// (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse
|
||||
// as the stock-decrement location for the return.
|
||||
var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId);
|
||||
var warehouseId = receipts.First().FK_WarehouseId;
|
||||
|
||||
var purchaseReturn = new Models.PurchaseReturn
|
||||
{
|
||||
FK_PurchaseOrderId = purchaseOrderId,
|
||||
Reason = request.Reason
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.Quantity <= 0)
|
||||
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||
|
||||
receivedTotals.TryGetValue(line.ItemId, out var received);
|
||||
returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned);
|
||||
var available = received - alreadyReturned;
|
||||
|
||||
if (line.Quantity > available)
|
||||
throw new InvalidOperationException(
|
||||
$"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available}).");
|
||||
|
||||
purchaseReturn.Items.Add(new Models.PurchaseReturnItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(purchaseReturn);
|
||||
|
||||
foreach (var line in purchaseReturn.Items)
|
||||
{
|
||||
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
|
||||
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
|
||||
|
||||
if (balance.Quantity < line.Quantity)
|
||||
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
|
||||
|
||||
balance.Quantity -= line.Quantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = line.FK_ItemId,
|
||||
FromLocationId = warehouseId,
|
||||
FromLocationType = LocationType.Warehouse,
|
||||
Quantity = line.Quantity,
|
||||
Type = MovementType.PurchaseReturn,
|
||||
ReasonCode = request.Reason,
|
||||
ReferenceId = purchaseReturn.Id,
|
||||
CreatedBy = createdByUserId
|
||||
});
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(purchaseReturn.Id);
|
||||
return Map(saved!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Reconciliation;
|
||||
|
||||
public interface IReconciliationRepository
|
||||
{
|
||||
Task AddItemCountAsync(RideItemCount count);
|
||||
Task<List<RideItemCount>> GetItemCountsByRideAsync(Guid rideId);
|
||||
|
||||
Task AddVarianceAsync(RideVariance variance);
|
||||
Task<List<RideVariance>> GetVariancesByRideAsync(Guid rideId);
|
||||
Task<RideVariance?> GetVarianceByIdAsync(Guid id);
|
||||
void UpdateVariance(RideVariance variance);
|
||||
Task<bool> HasPendingVariancesAsync(Guid rideId);
|
||||
|
||||
Task AddCloseSummaryAsync(RideCloseSummary summary);
|
||||
Task<RideCloseSummary?> GetCloseSummaryByRideAsync(Guid rideId);
|
||||
|
||||
/// <summary>Sum of Cash payments on sales belonging to the given ride.</summary>
|
||||
Task<decimal> GetCashCollectedByRideAsync(Guid rideId);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Reconciliation;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/rides/{rideId:guid}")]
|
||||
public class ReconciliationController(ReconciliationService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpPost("cycle-count")]
|
||||
public async Task<IActionResult> SubmitCycleCount(Guid rideId, SubmitCycleCountRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.SubmitCycleCountAsync(rideId, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("variances")]
|
||||
public async Task<IActionResult> GetVariances(Guid rideId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.GetVariancesAsync(rideId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("variances/{id:guid}/approve")]
|
||||
public async Task<IActionResult> ApproveVariance(Guid rideId, Guid id, ResolveVarianceRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ApproveVarianceAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("variances/{id:guid}/reject")]
|
||||
public async Task<IActionResult> RejectVariance(Guid rideId, Guid id, ResolveVarianceRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.RejectVarianceAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("close-ride")]
|
||||
public async Task<IActionResult> CloseRide(Guid rideId, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CloseRideAsync(rideId, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Reconciliation;
|
||||
|
||||
public record CycleCountItemLine(Guid ItemId, decimal PhysicalQuantity);
|
||||
public record SubmitCycleCountRequest(List<CycleCountItemLine> Items, decimal CashOnHand);
|
||||
|
||||
public record RideVarianceResponse(
|
||||
Guid Id, Guid RideId, Guid? ItemId, string? ItemName,
|
||||
bool IsCash, decimal ExpectedQuantity, decimal ActualQuantity, decimal VarianceQuantity,
|
||||
string? ReasonCode, VarianceStatus Status, Guid? ResolvedByUserId, DateTime? ResolvedAt);
|
||||
|
||||
public record ResolveVarianceRequest(string ReasonCode);
|
||||
|
||||
public record CycleCountResult(bool HasVariances, List<RideVarianceResponse> Variances);
|
||||
|
||||
public record RideCloseSummaryResponse(
|
||||
Guid Id, Guid RideId, decimal ExpectedCash, decimal ActualCash, decimal CashVariance,
|
||||
Guid ClosedByUserId, DateTime ClosedAt);
|
||||
@@ -0,0 +1,51 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Reconciliation;
|
||||
|
||||
public class ReconciliationRepository(AppDBContext db) : IReconciliationRepository
|
||||
{
|
||||
public async Task AddItemCountAsync(RideItemCount count)
|
||||
=> await db.RideItemCounts.AddAsync(count);
|
||||
|
||||
public async Task<List<RideItemCount>> GetItemCountsByRideAsync(Guid rideId)
|
||||
=> await db.RideItemCounts
|
||||
.Where(c => c.FK_RideId == rideId)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddVarianceAsync(RideVariance variance)
|
||||
=> await db.RideVariances.AddAsync(variance);
|
||||
|
||||
public async Task<List<RideVariance>> GetVariancesByRideAsync(Guid rideId)
|
||||
=> await db.RideVariances
|
||||
.Include(v => v.Item)
|
||||
.Include(v => v.ResolvedBy)
|
||||
.Where(v => v.FK_RideId == rideId)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<RideVariance?> GetVarianceByIdAsync(Guid id)
|
||||
=> await db.RideVariances
|
||||
.Include(v => v.Item)
|
||||
.Include(v => v.ResolvedBy)
|
||||
.FirstOrDefaultAsync(v => v.Id == id);
|
||||
|
||||
public void UpdateVariance(RideVariance variance)
|
||||
=> db.RideVariances.Update(variance);
|
||||
|
||||
public async Task<bool> HasPendingVariancesAsync(Guid rideId)
|
||||
=> await db.RideVariances
|
||||
.AnyAsync(v => v.FK_RideId == rideId && v.Status == VarianceStatus.Pending);
|
||||
|
||||
public async Task AddCloseSummaryAsync(RideCloseSummary summary)
|
||||
=> await db.RideCloseSummaries.AddAsync(summary);
|
||||
|
||||
public async Task<RideCloseSummary?> GetCloseSummaryByRideAsync(Guid rideId)
|
||||
=> await db.RideCloseSummaries
|
||||
.FirstOrDefaultAsync(s => s.FK_RideId == rideId);
|
||||
|
||||
public async Task<decimal> GetCashCollectedByRideAsync(Guid rideId)
|
||||
=> await db.Payments
|
||||
.Where(p => p.Sale.FK_RideId == rideId && p.Method == PaymentMethod.Cash)
|
||||
.SumAsync(p => p.Amount);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Reconciliation;
|
||||
|
||||
public class ReconciliationService(
|
||||
IReconciliationRepository reconciliationRepo,
|
||||
IRideRepository rideRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
public async Task<CycleCountResult> SubmitCycleCountAsync(
|
||||
Guid rideId, SubmitCycleCountRequest request, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.Active)
|
||||
throw new InvalidOperationException($"Cannot submit cycle count for a ride in {ride.Status} status");
|
||||
|
||||
var existingCounts = await reconciliationRepo.GetItemCountsByRideAsync(rideId);
|
||||
if (existingCounts.Count > 0)
|
||||
throw new InvalidOperationException("Cycle count has already been submitted for this ride");
|
||||
|
||||
// Build submitted counts lookup
|
||||
var submittedCounts = request.Items.ToDictionary(i => i.ItemId, i => i.PhysicalQuantity);
|
||||
|
||||
// Save all item count records
|
||||
var itemCounts = request.Items.Select(line => new RideItemCount
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
FK_ItemId = line.ItemId,
|
||||
PhysicalQuantity = line.PhysicalQuantity
|
||||
}).ToList();
|
||||
|
||||
// Get current vehicle stock balances
|
||||
var vehicleBalances = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle);
|
||||
|
||||
var variances = new List<RideVariance>();
|
||||
|
||||
// Compare each stock balance against the physical count
|
||||
foreach (var balance in vehicleBalances)
|
||||
{
|
||||
var physicalQty = submittedCounts.TryGetValue(balance.FK_ItemId, out var qty) ? qty : 0m;
|
||||
|
||||
if (balance.Quantity != physicalQty)
|
||||
{
|
||||
variances.Add(new RideVariance
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
FK_ItemId = balance.FK_ItemId,
|
||||
IsCash = false,
|
||||
ExpectedQuantity = balance.Quantity,
|
||||
ActualQuantity = physicalQty,
|
||||
VarianceQuantity = balance.Quantity - physicalQty,
|
||||
Status = VarianceStatus.Pending
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cash variance check
|
||||
var expectedCash = await reconciliationRepo.GetCashCollectedByRideAsync(rideId);
|
||||
if (expectedCash != request.CashOnHand)
|
||||
{
|
||||
variances.Add(new RideVariance
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
FK_ItemId = null,
|
||||
IsCash = true,
|
||||
ExpectedQuantity = expectedCash,
|
||||
ActualQuantity = request.CashOnHand,
|
||||
VarianceQuantity = expectedCash - request.CashOnHand,
|
||||
Status = VarianceStatus.Pending
|
||||
});
|
||||
}
|
||||
|
||||
ride.Status = RideStatus.PendingClose;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var count in itemCounts)
|
||||
await reconciliationRepo.AddItemCountAsync(count);
|
||||
|
||||
foreach (var variance in variances)
|
||||
await reconciliationRepo.AddVarianceAsync(variance);
|
||||
|
||||
rideRepo.Update(ride);
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
var varianceResponses = variances.Select(ToVarianceResponse).ToList();
|
||||
return new CycleCountResult(varianceResponses.Count > 0, varianceResponses);
|
||||
}
|
||||
|
||||
public async Task<List<RideVarianceResponse>> GetVariancesAsync(Guid rideId)
|
||||
{
|
||||
var variances = await reconciliationRepo.GetVariancesByRideAsync(rideId);
|
||||
return variances.Select(ToVarianceResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<RideVarianceResponse> ApproveVarianceAsync(
|
||||
Guid varianceId, ResolveVarianceRequest request, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var variance = await reconciliationRepo.GetVarianceByIdAsync(varianceId)
|
||||
?? throw new KeyNotFoundException("Variance not found");
|
||||
|
||||
if (variance.Status != VarianceStatus.Pending)
|
||||
throw new InvalidOperationException($"Variance is not in Pending status");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(variance.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.PendingClose)
|
||||
throw new InvalidOperationException($"Ride is not in PendingClose status");
|
||||
|
||||
StockMovement? movement = null;
|
||||
StockBalance? balance = null;
|
||||
|
||||
if (!variance.IsCash)
|
||||
{
|
||||
// adjustmentQty = -variance.VarianceQuantity
|
||||
// positive variance = short → debit vehicle (reduce stock)
|
||||
// negative variance = surplus → credit vehicle (add stock)
|
||||
var adjustmentQty = -variance.VarianceQuantity;
|
||||
|
||||
balance = await stockRepo.GetBalanceAsync(variance.FK_ItemId!.Value, ride.FK_VehicleId);
|
||||
|
||||
if (adjustmentQty < 0)
|
||||
{
|
||||
// Debit vehicle: FromLocationId=vehicleId, Quantity=abs(adjustmentQty)
|
||||
var absQty = Math.Abs(adjustmentQty);
|
||||
movement = new StockMovement
|
||||
{
|
||||
FK_ItemId = variance.FK_ItemId!.Value,
|
||||
FromLocationId = ride.FK_VehicleId,
|
||||
FromLocationType = LocationType.Vehicle,
|
||||
ToLocationId = null,
|
||||
ToLocationType = null,
|
||||
Quantity = absQty,
|
||||
Type = MovementType.Adjustment,
|
||||
ReasonCode = request.ReasonCode,
|
||||
ReferenceId = variance.Id,
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedBy = userId
|
||||
};
|
||||
|
||||
if (balance is not null)
|
||||
{
|
||||
balance.Quantity -= absQty;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
}
|
||||
}
|
||||
else if (adjustmentQty > 0)
|
||||
{
|
||||
// Credit vehicle: ToLocationId=vehicleId, Quantity=adjustmentQty
|
||||
movement = new StockMovement
|
||||
{
|
||||
FK_ItemId = variance.FK_ItemId!.Value,
|
||||
FromLocationId = null,
|
||||
FromLocationType = null,
|
||||
ToLocationId = ride.FK_VehicleId,
|
||||
ToLocationType = LocationType.Vehicle,
|
||||
Quantity = adjustmentQty,
|
||||
Type = MovementType.Adjustment,
|
||||
ReasonCode = request.ReasonCode,
|
||||
ReferenceId = variance.Id,
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedBy = userId
|
||||
};
|
||||
|
||||
if (balance is not null)
|
||||
{
|
||||
balance.Quantity += adjustmentQty;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
}
|
||||
else
|
||||
{
|
||||
balance = new StockBalance
|
||||
{
|
||||
FK_ItemId = variance.FK_ItemId!.Value,
|
||||
LocationId = ride.FK_VehicleId,
|
||||
LocationType = LocationType.Vehicle,
|
||||
Quantity = adjustmentQty,
|
||||
UpdatedAt = DateTime.Now
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variance.Status = VarianceStatus.Approved;
|
||||
variance.ReasonCode = request.ReasonCode;
|
||||
variance.FK_ResolvedByUserId = userId;
|
||||
variance.ResolvedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
reconciliationRepo.UpdateVariance(variance);
|
||||
|
||||
if (movement is not null)
|
||||
await stockRepo.AddMovementAsync(movement);
|
||||
|
||||
if (balance is not null)
|
||||
stockRepo.UpdateBalance(balance);
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return ToVarianceResponse(variance);
|
||||
}
|
||||
|
||||
public async Task<RideVarianceResponse> RejectVarianceAsync(
|
||||
Guid varianceId, ResolveVarianceRequest request, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var variance = await reconciliationRepo.GetVarianceByIdAsync(varianceId)
|
||||
?? throw new KeyNotFoundException("Variance not found");
|
||||
|
||||
if (variance.Status != VarianceStatus.Pending)
|
||||
throw new InvalidOperationException($"Variance is not in Pending status");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(variance.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.PendingClose)
|
||||
throw new InvalidOperationException($"Ride is not in PendingClose status");
|
||||
|
||||
StockMovement? movement = null;
|
||||
StockBalance? balance = null;
|
||||
|
||||
if (!variance.IsCash)
|
||||
{
|
||||
var adjustmentQty = -variance.VarianceQuantity;
|
||||
|
||||
balance = await stockRepo.GetBalanceAsync(variance.FK_ItemId!.Value, ride.FK_VehicleId);
|
||||
|
||||
if (adjustmentQty < 0)
|
||||
{
|
||||
var absQty = Math.Abs(adjustmentQty);
|
||||
movement = new StockMovement
|
||||
{
|
||||
FK_ItemId = variance.FK_ItemId!.Value,
|
||||
FromLocationId = ride.FK_VehicleId,
|
||||
FromLocationType = LocationType.Vehicle,
|
||||
ToLocationId = null,
|
||||
ToLocationType = null,
|
||||
Quantity = absQty,
|
||||
Type = MovementType.Adjustment,
|
||||
ReasonCode = request.ReasonCode,
|
||||
ReferenceId = variance.Id,
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedBy = userId
|
||||
};
|
||||
|
||||
if (balance is not null)
|
||||
{
|
||||
balance.Quantity -= absQty;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
}
|
||||
}
|
||||
else if (adjustmentQty > 0)
|
||||
{
|
||||
movement = new StockMovement
|
||||
{
|
||||
FK_ItemId = variance.FK_ItemId!.Value,
|
||||
FromLocationId = null,
|
||||
FromLocationType = null,
|
||||
ToLocationId = ride.FK_VehicleId,
|
||||
ToLocationType = LocationType.Vehicle,
|
||||
Quantity = adjustmentQty,
|
||||
Type = MovementType.Adjustment,
|
||||
ReasonCode = request.ReasonCode,
|
||||
ReferenceId = variance.Id,
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedBy = userId
|
||||
};
|
||||
|
||||
if (balance is not null)
|
||||
{
|
||||
balance.Quantity += adjustmentQty;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
}
|
||||
else
|
||||
{
|
||||
balance = new StockBalance
|
||||
{
|
||||
FK_ItemId = variance.FK_ItemId!.Value,
|
||||
LocationId = ride.FK_VehicleId,
|
||||
LocationType = LocationType.Vehicle,
|
||||
Quantity = adjustmentQty,
|
||||
UpdatedAt = DateTime.Now
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variance.Status = VarianceStatus.Rejected;
|
||||
variance.ReasonCode = request.ReasonCode;
|
||||
variance.FK_ResolvedByUserId = userId;
|
||||
variance.ResolvedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
reconciliationRepo.UpdateVariance(variance);
|
||||
|
||||
if (movement is not null)
|
||||
await stockRepo.AddMovementAsync(movement);
|
||||
|
||||
if (balance is not null)
|
||||
stockRepo.UpdateBalance(balance);
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return ToVarianceResponse(variance);
|
||||
}
|
||||
|
||||
public async Task<RideCloseSummaryResponse> CloseRideAsync(
|
||||
Guid rideId, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.PendingClose)
|
||||
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
|
||||
|
||||
if (await reconciliationRepo.HasPendingVariancesAsync(rideId))
|
||||
throw new InvalidOperationException("All variances must be resolved before the ride can be closed");
|
||||
|
||||
var allVariances = await reconciliationRepo.GetVariancesByRideAsync(rideId);
|
||||
var cashVariance = allVariances.FirstOrDefault(v => v.IsCash);
|
||||
|
||||
var expectedCash = cashVariance?.ExpectedQuantity ?? 0m;
|
||||
var actualCash = cashVariance?.ActualQuantity ?? 0m;
|
||||
var cashVarianceAmount = cashVariance?.VarianceQuantity ?? 0m;
|
||||
|
||||
var summary = new RideCloseSummary
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
ExpectedCash = expectedCash,
|
||||
ActualCash = actualCash,
|
||||
CashVariance = cashVarianceAmount,
|
||||
FK_ClosedByUserId = userId,
|
||||
ClosedAt = DateTime.Now
|
||||
};
|
||||
|
||||
ride.Status = RideStatus.Closed;
|
||||
ride.ClosedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await reconciliationRepo.AddCloseSummaryAsync(summary);
|
||||
rideRepo.Update(ride);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new RideCloseSummaryResponse(
|
||||
summary.Id,
|
||||
summary.FK_RideId,
|
||||
summary.ExpectedCash,
|
||||
summary.ActualCash,
|
||||
summary.CashVariance,
|
||||
summary.FK_ClosedByUserId,
|
||||
summary.ClosedAt);
|
||||
}
|
||||
|
||||
private static RideVarianceResponse ToVarianceResponse(RideVariance v) =>
|
||||
new(v.Id, v.FK_RideId, v.FK_ItemId, v.Item?.Name,
|
||||
v.IsCash, v.ExpectedQuantity, v.ActualQuantity, v.VarianceQuantity,
|
||||
v.ReasonCode, v.Status, v.FK_ResolvedByUserId, v.ResolvedAt);
|
||||
}
|
||||
@@ -7,4 +7,26 @@ public interface IRegionRepository
|
||||
Task AddAsync(Models.Region entity);
|
||||
void Update(Models.Region entity);
|
||||
void Remove(Models.Region entity);
|
||||
|
||||
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
|
||||
Task AddTargetAsync(Models.RegionalTarget target);
|
||||
void UpdateTarget(Models.RegionalTarget target);
|
||||
|
||||
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
|
||||
Task AddBudgetAsync(Models.RegionalBudget budget);
|
||||
void UpdateBudget(Models.RegionalBudget budget);
|
||||
|
||||
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
|
||||
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
|
||||
|
||||
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
|
||||
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
|
||||
void UpdateTaxRule(Models.RegionalTaxRule rule);
|
||||
|
||||
Task<int?> GetUserRoleLevelAsync(Guid userId);
|
||||
|
||||
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
|
||||
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
|
||||
|
||||
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/regions")]
|
||||
[Authorize]
|
||||
public class RegionController(RegionService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "regions.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "regions.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "regions.create")]
|
||||
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "regions.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,10 +32,51 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "regions.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/targets/{period}")]
|
||||
public async Task<IActionResult> SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.SetTargetAsync(id, period, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/budgets/{period}")]
|
||||
public async Task<IActionResult> SetBudget(Guid id, string period, SetRegionalBudgetRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.SetBudgetAsync(id, period, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/agencies")]
|
||||
public async Task<IActionResult> MapAgency(Guid id, MapAgencyToRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.MapAgencyAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/tax-rule")]
|
||||
public async Task<IActionResult> SetTaxRule(Guid id, SetRegionalTaxRuleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.SetTaxRuleAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/hierarchy")]
|
||||
public async Task<IActionResult> GetHierarchy(Guid id)
|
||||
{
|
||||
var result = await service.GetHierarchyAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,32 @@ public record CreateRegionRequest(string Name);
|
||||
public record UpdateRegionRequest(string Name);
|
||||
|
||||
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record SetRegionalTargetRequest(decimal TargetValue);
|
||||
|
||||
public record SetRegionalBudgetRequest(decimal BudgetAmount);
|
||||
|
||||
public record MapAgencyToRegionRequest(Guid AgencyUserId);
|
||||
|
||||
public record SetRegionalTaxRuleRequest(string RuleConfig);
|
||||
|
||||
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||
|
||||
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
|
||||
|
||||
public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
|
||||
|
||||
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
|
||||
|
||||
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
|
||||
|
||||
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
|
||||
|
||||
public record RegionDashboardResponse(
|
||||
Guid RegionId,
|
||||
string Period,
|
||||
decimal? TargetValue,
|
||||
decimal? BudgetAmount,
|
||||
int OrderCount,
|
||||
decimal ActualSales,
|
||||
DateTime? LastOrderDate);
|
||||
|
||||
@@ -22,4 +22,60 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
entity.IsActive = false;
|
||||
context.Regions.Update(entity);
|
||||
}
|
||||
|
||||
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
|
||||
await context.RegionalTargets.FirstOrDefaultAsync(t =>
|
||||
t.FK_RegionId == regionId && t.Period == period && t.IsActive);
|
||||
|
||||
public async Task AddTargetAsync(Models.RegionalTarget target) =>
|
||||
await context.RegionalTargets.AddAsync(target);
|
||||
|
||||
public void UpdateTarget(Models.RegionalTarget target) =>
|
||||
context.RegionalTargets.Update(target);
|
||||
|
||||
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
|
||||
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
|
||||
b.FK_RegionId == regionId && b.Period == period && b.IsActive);
|
||||
|
||||
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
|
||||
await context.RegionalBudgets.AddAsync(budget);
|
||||
|
||||
public void UpdateBudget(Models.RegionalBudget budget) =>
|
||||
context.RegionalBudgets.Update(budget);
|
||||
|
||||
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
|
||||
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
|
||||
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId && m.IsActive);
|
||||
|
||||
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
|
||||
await context.RegionAgencyMaps.AddAsync(map);
|
||||
|
||||
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
|
||||
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive);
|
||||
|
||||
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
|
||||
await context.RegionalTaxRules.AddAsync(rule);
|
||||
|
||||
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
|
||||
context.RegionalTaxRules.Update(rule);
|
||||
|
||||
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
|
||||
await context.Users
|
||||
.Where(u => u.Id == userId && u.IsActive)
|
||||
.Select(u => (int?)u.Role.Level)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<List<Models.Route>> GetRoutesAsync(Guid regionId) =>
|
||||
await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync();
|
||||
|
||||
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
|
||||
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
|
||||
|
||||
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
|
||||
await (from o in context.Orders
|
||||
join s in context.Stores on o.FK_StoreId equals s.Id
|
||||
join r in context.Routes on s.FK_RouteId equals r.Id
|
||||
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
|
||||
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
|
||||
select o).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
{
|
||||
private const int RegionalManagerRoleLevel = 50;
|
||||
private const int RegionalAgencyRoleLevel = 60;
|
||||
|
||||
private async Task<bool> CanManageRegionAsync(Guid regionId)
|
||||
{
|
||||
if (currentUser.RoleLevel >= RegionalAgencyRoleLevel) return true;
|
||||
if (currentUser.RoleLevel < RegionalManagerRoleLevel || currentUser.UserId is null) return false;
|
||||
return await scopeAccess.HasRegionScopeAsync(currentUser.UserId.Value, regionId);
|
||||
}
|
||||
|
||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
@@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<RegionalTargetResponse?> SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
if (!await CanManageRegionAsync(regionId)) return null;
|
||||
|
||||
var target = await repo.GetTargetAsync(regionId, period);
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (target is null)
|
||||
{
|
||||
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue };
|
||||
await repo.AddTargetAsync(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
target.TargetValue = request.TargetValue;
|
||||
repo.UpdateTarget(target);
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionalTargetResponse(regionId, period, target.TargetValue);
|
||||
}
|
||||
|
||||
public async Task<RegionalBudgetResponse?> SetBudgetAsync(Guid regionId, string period, SetRegionalBudgetRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
if (!await CanManageRegionAsync(regionId)) return null;
|
||||
|
||||
var budget = await repo.GetBudgetAsync(regionId, period);
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (budget is null)
|
||||
{
|
||||
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = period, BudgetAmount = request.BudgetAmount };
|
||||
await repo.AddBudgetAsync(budget);
|
||||
}
|
||||
else
|
||||
{
|
||||
budget.BudgetAmount = request.BudgetAmount;
|
||||
repo.UpdateBudget(budget);
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionalBudgetResponse(regionId, period, budget.BudgetAmount);
|
||||
}
|
||||
|
||||
public async Task<RegionAgencyMapResponse?> MapAgencyAsync(Guid regionId, MapAgencyToRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
if (!await CanManageRegionAsync(regionId)) return null;
|
||||
|
||||
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId);
|
||||
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel) return null;
|
||||
|
||||
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId);
|
||||
if (existing is not null) return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
|
||||
|
||||
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId };
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAgencyMapAsync(map);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionAgencyMapResponse(regionId, request.AgencyUserId);
|
||||
}
|
||||
|
||||
public async Task<RegionalTaxRuleResponse?> SetTaxRuleAsync(Guid regionId, SetRegionalTaxRuleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
if (!await CanManageRegionAsync(regionId)) return null;
|
||||
|
||||
var rule = await repo.GetTaxRuleAsync(regionId);
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (rule is null)
|
||||
{
|
||||
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
|
||||
await repo.AddTaxRuleAsync(rule);
|
||||
}
|
||||
else
|
||||
{
|
||||
rule.RuleConfig = request.RuleConfig;
|
||||
repo.UpdateTaxRule(rule);
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
|
||||
}
|
||||
|
||||
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
var routes = await repo.GetRoutesAsync(regionId);
|
||||
var stores = await repo.GetStoresByRouteIdsAsync(routes.Select(r => r.Id).ToList());
|
||||
var routeItems = routes
|
||||
.Select(r => new RegionHierarchyRouteItem(
|
||||
r.Id,
|
||||
r.RouteName,
|
||||
stores.Where(s => s.FK_RouteId == r.Id).Select(s => s.Id).ToList()))
|
||||
.ToList();
|
||||
return new RegionHierarchyResponse(region.Id, region.Name, routeItems);
|
||||
}
|
||||
|
||||
public async Task<RegionDashboardResponse?> GetDashboardAsync(Guid regionId, string period)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
|
||||
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
|
||||
var normalizedPeriod = periodStart.ToString("yyyy-MM");
|
||||
var periodEndExclusive = periodStart.AddMonths(1);
|
||||
|
||||
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
|
||||
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
|
||||
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
|
||||
|
||||
return new RegionDashboardResponse(
|
||||
regionId,
|
||||
normalizedPeriod,
|
||||
target?.TargetValue,
|
||||
budget?.BudgetAmount,
|
||||
orders.Count,
|
||||
orders.Sum(o => o.TotalAmount),
|
||||
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public interface IReturnRepository
|
||||
{
|
||||
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
|
||||
Task AddAsync(Models.SaleReturn entity);
|
||||
Task AddCreditNoteAsync(Models.CreditNote entity);
|
||||
|
||||
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
|
||||
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
[ApiController]
|
||||
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/returns/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/orders/{id:guid}/returns")]
|
||||
public async Task<IActionResult> GetByOrder(Guid id) =>
|
||||
Ok(await service.GetByOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/orders/{id:guid}/returns")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(id, request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
|
||||
|
||||
public record CreateSaleReturnRequest(ReturnMode Mode, List<ReturnItemRequest> Items);
|
||||
|
||||
public record SaleReturnItemResponse(
|
||||
Guid Id,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
decimal Quantity,
|
||||
ReturnReasonCode ReasonCode,
|
||||
decimal UnitPrice);
|
||||
|
||||
public record SaleReturnResponse(
|
||||
Guid Id,
|
||||
Guid OrderId,
|
||||
ReturnMode Mode,
|
||||
decimal TotalCreditAmount,
|
||||
decimal OrderTotalAmountAfter,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
List<SaleReturnItemResponse> Items);
|
||||
@@ -0,0 +1,32 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public class ReturnRepository(AppDBContext context) : IReturnRepository
|
||||
{
|
||||
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
|
||||
await context.SaleReturns
|
||||
.Include(r => r.Order)
|
||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
|
||||
await context.SaleReturns
|
||||
.Include(r => r.Order)
|
||||
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||
.Where(r => r.FK_OrderId == orderId && r.IsActive)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.SaleReturn entity) =>
|
||||
await context.SaleReturns.AddAsync(entity);
|
||||
|
||||
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
|
||||
await context.CreditNotes.AddAsync(entity);
|
||||
|
||||
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
|
||||
await context.OrderItems
|
||||
.Include(oi => oi.Order)
|
||||
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Returns;
|
||||
|
||||
public class ReturnService(
|
||||
IReturnRepository repo,
|
||||
IOrderRepository orderRepo,
|
||||
ISaleRepository saleRepo,
|
||||
IRideRepository rideRepo,
|
||||
IItemRepository itemRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
|
||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
|
||||
|
||||
private static SaleReturnResponse Map(SaleReturn r) =>
|
||||
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
|
||||
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var r = await repo.GetByIdAsync(id);
|
||||
if (r is null || !r.IsActive) return null;
|
||||
return Map(r);
|
||||
}
|
||||
|
||||
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
|
||||
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Records a return recognized during the same store visit as an open order and settles it
|
||||
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
|
||||
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
|
||||
/// A return is only allowed for an item the store has previously ordered.
|
||||
/// </summary>
|
||||
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var order = await orderRepo.GetByIdAsync(orderId);
|
||||
if (order is null || !order.IsActive)
|
||||
throw new InvalidOperationException("Order not found.");
|
||||
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
|
||||
throw new InvalidOperationException("Cannot record a return against a closed order.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A return must have at least one line item.");
|
||||
|
||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||
?? throw new InvalidOperationException("Sale not found for this order.");
|
||||
if (sale.FK_RideId is null)
|
||||
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
|
||||
?? throw new InvalidOperationException("Ride not found.");
|
||||
var vehicleId = ride.FK_VehicleId;
|
||||
|
||||
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
||||
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
|
||||
|
||||
var saleReturn = new SaleReturn
|
||||
{
|
||||
FK_OrderId = orderId,
|
||||
Mode = request.Mode,
|
||||
CreatedByUserId = createdByUserId
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.Quantity <= 0)
|
||||
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||
|
||||
var item = await itemRepo.GetByIdAsync(line.ItemId)
|
||||
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
||||
|
||||
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
|
||||
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
|
||||
|
||||
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
|
||||
? matchingOrderItem.UnitPrice
|
||||
: item.SalePrice ?? 0;
|
||||
|
||||
saleReturn.Items.Add(new SaleReturnItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
ReasonCode = line.ReasonCode,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var line in saleReturn.Items)
|
||||
{
|
||||
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
|
||||
if (balance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = line.FK_ItemId,
|
||||
LocationId = vehicleId,
|
||||
LocationType = LocationType.Vehicle,
|
||||
Quantity = line.Quantity
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += line.Quantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = line.FK_ItemId,
|
||||
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,17 +1,14 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Ride;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/rides")]
|
||||
[Authorize]
|
||||
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] RideStatus? status,
|
||||
[FromQuery] RideType? type,
|
||||
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "rides.create")]
|
||||
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/driver")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/open")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -66,8 +59,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/activate")]
|
||||
[HttpPost("{id:guid}/activate")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -79,8 +72,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/close")]
|
||||
[HttpPost("{id:guid}/close")]
|
||||
[Authorize(Policy = "rides.manage")]
|
||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -93,7 +86,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel-request")]
|
||||
[Authorize(Policy = "rides.cancel.request")]
|
||||
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -107,7 +99,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("cancel-requests")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetCancelRequests(
|
||||
[FromQuery] CancelRequestStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
@@ -118,7 +109,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("cancel-requests/{id:guid}/approve")]
|
||||
[Authorize(Policy = "rides.cancel.approve")]
|
||||
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -132,7 +122,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpPost("cancel-requests/{id:guid}/reject")]
|
||||
[Authorize(Policy = "rides.cancel.approve")]
|
||||
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
@@ -146,7 +135,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/cancel-request")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetCancelRequest(Guid id)
|
||||
{
|
||||
var result = await service.GetCancelRequestAsync(id);
|
||||
@@ -154,7 +142,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
}
|
||||
|
||||
[HttpGet("breakdown-assist/{rideId:guid}")]
|
||||
[Authorize(Policy = "rides.view")]
|
||||
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Features.DriverProfile;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -9,7 +8,6 @@ namespace Distribution.Features.Ride;
|
||||
public class RideService(
|
||||
IRideRepository rideRepo,
|
||||
IDriverProfileRepository driverRepo,
|
||||
IDriverComplianceConfigRepository complianceRepo,
|
||||
IRideCancelRequestRepository cancelRepo,
|
||||
Features.Vehicle.IVehicleRepository vehicleRepo,
|
||||
IStockRepository stockRepo,
|
||||
@@ -39,7 +37,7 @@ public class RideService(
|
||||
var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId)
|
||||
?? throw new InvalidOperationException("Vehicle not found");
|
||||
|
||||
var sourceWarehouseId = vehicle.FK_DefaultWarehouseId ?? request.FK_SourceWarehouseId;
|
||||
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.FK_SourceWarehouseId;
|
||||
if (!sourceWarehouseId.HasValue)
|
||||
throw new InvalidOperationException("Source warehouse is required for this vehicle");
|
||||
|
||||
@@ -138,11 +136,9 @@ public class RideService(
|
||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.Active)
|
||||
if (ride.Status != RideStatus.PendingClose)
|
||||
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
|
||||
|
||||
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
|
||||
|
||||
ride.Status = RideStatus.Closed;
|
||||
ride.ClosedAt = DateTime.Now;
|
||||
ride.Meterend = request.Meterend;
|
||||
@@ -319,7 +315,7 @@ public class RideService(
|
||||
if (!driver.IsActive)
|
||||
throw new InvalidOperationException("Driver profile is inactive");
|
||||
|
||||
var config = await complianceRepo.GetAsync();
|
||||
var config = await driverRepo.GetComplianceConfigAsync();
|
||||
if (config is null) return;
|
||||
|
||||
var today = DateTime.Now.Date;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Role;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/roles")]
|
||||
[Authorize]
|
||||
public class RoleController(RoleService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Route;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/routes")]
|
||||
[Authorize]
|
||||
public class RouteController(RouteService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "routes.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "routes.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "routes.create")]
|
||||
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,15 +25,20 @@ public class RouteController(RouteService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "routes.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/region")]
|
||||
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.AssignToRegionAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "routes.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -5,3 +5,5 @@ public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPo
|
||||
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
|
||||
|
||||
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record AssignRouteToRegionRequest(Guid RegionId);
|
||||
|
||||
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Route;
|
||||
|
||||
public class RouteService(IRouteRepository repo, IUnitOfWork uow)
|
||||
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow)
|
||||
{
|
||||
public async Task<List<RouteResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
@@ -64,6 +64,29 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
|
||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
|
||||
var region = await regionRepo.GetByIdAsync(request.RegionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
route.FK_RegionId = request.RegionId;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(route);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Sale;
|
||||
|
||||
public interface ISaleRepository
|
||||
{
|
||||
Task<Models.Sale?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
|
||||
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
||||
Task AddAsync(Models.Sale sale);
|
||||
void Update(Models.Sale sale);
|
||||
void Remove(Models.Sale sale);
|
||||
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 ? Forbid() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/cancel")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CancelAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
|
||||
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||
|
||||
public record SaleResponse(
|
||||
Guid Id,
|
||||
DateTime SaleDate,
|
||||
Guid StoreId,
|
||||
Guid CreatedByUserId,
|
||||
Guid? RideId,
|
||||
decimal TotalAmount,
|
||||
SaleStatus Status,
|
||||
PaymentStatus PaymentStatus,
|
||||
bool FulfillImmediately,
|
||||
DateTime? CompletedAt,
|
||||
Guid OrderId,
|
||||
OrderStatus OrderStatus,
|
||||
DateTime? OrderFulfilledAt,
|
||||
List<SaleItemResponse> Items,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,45 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Sale;
|
||||
|
||||
public class SaleRepository(AppDBContext context) : ISaleRepository
|
||||
{
|
||||
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
|
||||
await context.Sales.FindAsync(id);
|
||||
|
||||
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
|
||||
await context.Sales
|
||||
.Where(s => s.IsActive &&
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
|
||||
await context.Sales
|
||||
.CountAsync(s => s.IsActive &&
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
|
||||
|
||||
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
|
||||
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Sale sale) =>
|
||||
await context.Sales.AddAsync(sale);
|
||||
|
||||
public void Update(Models.Sale sale) =>
|
||||
context.Sales.Update(sale);
|
||||
|
||||
public void Remove(Models.Sale sale)
|
||||
{
|
||||
sale.IsActive = false;
|
||||
context.Sales.Update(sale);
|
||||
}
|
||||
|
||||
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
|
||||
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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)
|
||||
{
|
||||
var sale = await repo.GetByIdAsync(id);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(sale.FK_StoreId)) 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)
|
||||
{
|
||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
|
||||
if (store.KycStatus != KycStatus.Verified)
|
||||
{
|
||||
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
||||
if (request.TotalAmount > threshold) return null;
|
||||
}
|
||||
|
||||
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
|
||||
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
|
||||
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
|
||||
|
||||
var sale = new Models.Sale
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
SaleDate = request.SaleDate,
|
||||
CreatedByUserId = userId,
|
||||
FK_RideId = request.RideId,
|
||||
TotalAmount = request.TotalAmount,
|
||||
Status = saleStatus,
|
||||
FulfillImmediately = request.FulfillImmediately,
|
||||
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
|
||||
};
|
||||
|
||||
var orderItems = new List<Models.OrderItem>();
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
|
||||
if (unitPrice is null) return null;
|
||||
|
||||
orderItems.Add(new Models.OrderItem
|
||||
{
|
||||
FK_ItemId = item.ItemId,
|
||||
Quantity = item.Quantity,
|
||||
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
|
||||
UnitPrice = unitPrice.Value
|
||||
});
|
||||
}
|
||||
|
||||
var order = new Models.Order
|
||||
{
|
||||
FK_SaleId = sale.Id,
|
||||
FK_StoreId = request.StoreId,
|
||||
OrderDate = request.SaleDate,
|
||||
TotalAmount = request.TotalAmount,
|
||||
Status = orderStatus,
|
||||
FulfilledAt = fulfilledAt
|
||||
};
|
||||
|
||||
foreach (var orderItem in orderItems)
|
||||
orderItem.FK_OrderId = order.Id;
|
||||
|
||||
Guid? vehicleId = null;
|
||||
if (request.FulfillImmediately && request.RideId.HasValue)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
|
||||
if (ride is null) return null;
|
||||
vehicleId = ride.FK_VehicleId;
|
||||
|
||||
foreach (var item in orderItems)
|
||||
{
|
||||
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
|
||||
if (balance is null || balance.Quantity < item.Quantity)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
|
||||
balance!.Quantity -= item.Quantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = item.FK_ItemId,
|
||||
FromLocationId = vehicleId.Value,
|
||||
FromLocationType = LocationType.Vehicle,
|
||||
Quantity = item.Quantity,
|
||||
Type = MovementType.Sale,
|
||||
ReferenceId = order.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
return ToResponse(sale, order, orderItems);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/schemes")]
|
||||
[Authorize]
|
||||
public class SchemeController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase
|
||||
|
||||
[ApiController]
|
||||
[Route("api/orders")]
|
||||
[Authorize]
|
||||
public class SchemeOrderController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("{id:guid}/apply-scheme")]
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Stock;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stock")]
|
||||
[Authorize]
|
||||
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
// ── Query ──────────────────────────────────────────────────────────────
|
||||
// ── Query ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("balances")]
|
||||
public async Task<IActionResult> GetBalances(
|
||||
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("transfer")]
|
||||
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
||||
[Authorize]
|
||||
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
@@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "stores.update")]
|
||||
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UploadAsync(storeId, request, ct);
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stores")]
|
||||
[Authorize]
|
||||
public class StoreController(StoreService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
@@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "stores.create")]
|
||||
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
@@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "stores.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
@@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Policy = "stores.delete")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
@@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/credit-limit")]
|
||||
[Authorize(Policy = "stores.credit.update")]
|
||||
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
||||
@@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/outstanding-balance")]
|
||||
[Authorize(Policy = "stores.credit.update")]
|
||||
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
||||
@@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/outstanding-balance")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
||||
{
|
||||
var result = await service.GetOutstandingBalanceAsync(id);
|
||||
@@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/kyc/verify")]
|
||||
[Authorize(Policy = "stores.kyc.verify")]
|
||||
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.VerifyKycAsync(id, request, ct);
|
||||
@@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/segment")]
|
||||
[Authorize(Policy = "stores.update")]
|
||||
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ClassifySegmentAsync(id, request, ct);
|
||||
@@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/visit-history")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetVisitHistory(Guid id)
|
||||
{
|
||||
var result = await service.GetVisitHistoryAsync(id);
|
||||
@@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
||||
{
|
||||
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
||||
@@ -102,10 +88,16 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/dashboard")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/kyc-status")]
|
||||
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
|
||||
|
||||
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
||||
|
||||
public record PatchKycStatusRequest(KycStatus KycStatus);
|
||||
|
||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Supplier;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/suppliers")]
|
||||
[Authorize]
|
||||
public class SupplierController(SupplierService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -36,7 +34,18 @@ public class SupplierController(SupplierService service) : ControllerBase
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
try
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/evaluate")]
|
||||
public async Task<IActionResult> Evaluate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await service.EvaluateAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Supplier;
|
||||
|
||||
public class SupplierService(ISupplierRepository repo, IUnitOfWork uow)
|
||||
public class SupplierService(
|
||||
ISupplierRepository repo,
|
||||
IPoRepository purchaseOrderRepo,
|
||||
IGrnRepository goodsReceiptRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static SupplierResponse Map(Models.Supplier s) =>
|
||||
new(s.Id, s.Name, s.ContactInfo, s.PerformanceScore, s.IsActive, s.CreatedAt);
|
||||
@@ -52,10 +58,83 @@ public class SupplierService(ISupplierRepository repo, IUnitOfWork uow)
|
||||
var supplier = await repo.GetByIdAsync(id);
|
||||
if (supplier is null || !supplier.IsActive) return false;
|
||||
|
||||
if (await purchaseOrderRepo.HasActiveForSupplierAsync(id))
|
||||
throw new InvalidOperationException(
|
||||
"Cannot delete a supplier that is referenced by an active purchase order.");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Remove(supplier); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The only writer of Supplier.PerformanceScore. Computes a simple 0-100 score from this
|
||||
/// supplier's own purchase-order history:
|
||||
/// - lineFulfillmentRatio: received (non-cancelled, fully-received) Po lines / all non-active
|
||||
/// Po lines seen (received + cancelled) - how reliably the supplier fulfils what it commits to.
|
||||
/// - acceptanceRatio: AcceptedQuantity / (AcceptedQuantity + RejectedQuantity) across every
|
||||
/// GoodsReceipt against this supplier's Pos - how much of what arrives is usable.
|
||||
/// score = round(average(lineFulfillmentRatio, acceptanceRatio) * 100, 2).
|
||||
/// Defaults to 50 (neutral) when there is no history yet, rather than hardcoding a fixed score.
|
||||
/// </summary>
|
||||
public async Task<SupplierResponse?> EvaluateAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var supplier = await repo.GetByIdAsync(id);
|
||||
if (supplier is null || !supplier.IsActive) return null;
|
||||
|
||||
var purchaseOrders = await purchaseOrderRepo.GetBySupplierAsync(id);
|
||||
|
||||
var receivedLines = 0;
|
||||
var cancelledLines = 0;
|
||||
foreach (var po in purchaseOrders)
|
||||
{
|
||||
foreach (var item in po.Items.Where(i => i.IsActive))
|
||||
{
|
||||
if (item.IsCancelled) cancelledLines++;
|
||||
else if (item.ReceivedQuantity >= item.Quantity) receivedLines++;
|
||||
}
|
||||
}
|
||||
|
||||
var totalAccepted = 0;
|
||||
var totalRejected = 0;
|
||||
foreach (var po in purchaseOrders)
|
||||
{
|
||||
foreach (var receipt in await goodsReceiptRepo.GetByPurchaseOrderIdAsync(po.Id))
|
||||
{
|
||||
foreach (var item in receipt.Items.Where(i => i.IsActive))
|
||||
{
|
||||
totalAccepted += item.AcceptedQuantity;
|
||||
totalRejected += item.RejectedQuantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lineFulfillmentRatio = receivedLines + cancelledLines > 0
|
||||
? (decimal)receivedLines / (receivedLines + cancelledLines)
|
||||
: (decimal?)null;
|
||||
|
||||
var acceptanceRatio = totalAccepted + totalRejected > 0
|
||||
? (decimal)totalAccepted / (totalAccepted + totalRejected)
|
||||
: (decimal?)null;
|
||||
|
||||
decimal score;
|
||||
if (lineFulfillmentRatio.HasValue && acceptanceRatio.HasValue)
|
||||
score = Math.Round((lineFulfillmentRatio.Value + acceptanceRatio.Value) / 2m * 100m, 2);
|
||||
else if (lineFulfillmentRatio.HasValue)
|
||||
score = Math.Round(lineFulfillmentRatio.Value * 100m, 2);
|
||||
else if (acceptanceRatio.HasValue)
|
||||
score = Math.Round(acceptanceRatio.Value * 100m, 2);
|
||||
else
|
||||
score = 50m;
|
||||
|
||||
supplier.PerformanceScore = score;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(supplier); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(supplier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
public interface ISupplierClaimRepository
|
||||
{
|
||||
Task<Models.SupplierClaim?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.SupplierClaim entity);
|
||||
void Update(Models.SupplierClaim entity);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.SupplierClaims;
|
||||
|
||||
[ApiController]
|
||||
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/supplier-claims/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/claims")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
[HttpPost("api/purchase-orders/{id:guid}/claims")]
|
||||
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(id, request, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
||||
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ApproveAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
||||
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.RejectAsync(id, request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user