Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev
This commit is contained in:
@@ -18,6 +18,7 @@ public class RegionServiceTests
|
||||
public RegionServiceTests()
|
||||
{
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||
}
|
||||
|
||||
@@ -148,6 +149,123 @@ public class RegionServiceTests
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
// Region code
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||
|
||||
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
|
||||
|
||||
Assert.Equal("REG-0001", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
|
||||
|
||||
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
|
||||
|
||||
Assert.Equal("REG-0010", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_StoresProvidedCode_AsEntered()
|
||||
{
|
||||
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
|
||||
|
||||
Assert.Equal("nth-1", result.Code);
|
||||
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
|
||||
{
|
||||
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
|
||||
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextCodeAsync_ReturnsSuggestion()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
|
||||
|
||||
var result = await _sut.GetNextCodeAsync();
|
||||
|
||||
Assert.Equal("REG-0004", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
|
||||
|
||||
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
|
||||
|
||||
Assert.Equal("north-1", result!.Code);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
|
||||
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
|
||||
|
||||
Assert.Equal("REG-0001", result!.Code);
|
||||
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
|
||||
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_AllowsResubmittingOwnCode()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
|
||||
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
|
||||
|
||||
Assert.Equal("REG-0001", result!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsCode()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0007";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
|
||||
var result = await _sut.GetByIdAsync(region.Id);
|
||||
|
||||
Assert.Equal("REG-0007", result!.Code);
|
||||
}
|
||||
|
||||
// DeleteAsync
|
||||
|
||||
[Fact]
|
||||
@@ -195,4 +313,93 @@ public class RegionServiceTests
|
||||
|
||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
// Region manager
|
||||
|
||||
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
|
||||
Role = new Role { Name = role, Level = 50 }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
|
||||
|
||||
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser("SalesRep");
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser();
|
||||
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
|
||||
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
|
||||
|
||||
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||
|
||||
Assert.Equal(user.Id, result!.UserId);
|
||||
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
|
||||
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser();
|
||||
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
|
||||
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
|
||||
|
||||
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||
|
||||
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
|
||||
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
|
||||
|
||||
Assert.False(await _sut.RemoveManagerAsync(region.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetManagerAsync_ReturnsManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser();
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
|
||||
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
|
||||
|
||||
var result = await _sut.GetManagerAsync(region.Id);
|
||||
|
||||
Assert.Equal("mgr", result!.Username);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<Uom> Uoms { get; set; }
|
||||
public DbSet<UomConversion> UomConversions { get; set; }
|
||||
public DbSet<Item> Items { get; set; }
|
||||
public DbSet<ItemBatch> ItemBatches { get; set; }
|
||||
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
|
||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||
@@ -31,7 +33,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
|
||||
public DbSet<StockBalance> StockBalances { get; set; }
|
||||
public DbSet<StockMovement> StockMovements { get; set; }
|
||||
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
|
||||
public DbSet<PriceList> PriceLists { get; set; }
|
||||
public DbSet<PriceListItem> PriceListItems { get; set; }
|
||||
public DbSet<Supplier> Suppliers { get; set; }
|
||||
@@ -48,6 +49,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||
public DbSet<GoodsReceiptItemBatch> GoodsReceiptItemBatches { get; set; }
|
||||
public DbSet<GoodsReceiptItemSerial> GoodsReceiptItemSerials { get; set; }
|
||||
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||
@@ -84,8 +87,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
|
||||
// --- Unique indexes ---
|
||||
|
||||
// NOTE: MySQL treats each NULL as distinct in a unique index, so this does not by itself
|
||||
// prevent duplicate rows for untracked items (both FK columns null) — StockRepository's
|
||||
// get-then-update pattern is what keeps those to one row per (Item, Location), same as before.
|
||||
modelBuilder.Entity<StockBalance>()
|
||||
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
|
||||
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<ItemSerialNumber>()
|
||||
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<PriceListItem>()
|
||||
@@ -293,6 +303,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceipt>()
|
||||
.HasOne(gr => gr.Supplier)
|
||||
.WithMany()
|
||||
.HasForeignKey(gr => gr.FK_SupplierId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<GoodsReceipt>()
|
||||
.HasOne(gr => gr.Warehouse)
|
||||
.WithMany()
|
||||
@@ -311,12 +328,92 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasOne(i => i.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItem>()
|
||||
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
||||
|
||||
// --- ItemBatch / ItemSerialNumber ---
|
||||
|
||||
modelBuilder.Entity<ItemBatch>()
|
||||
.HasOne(b => b.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<ItemBatch>()
|
||||
.HasIndex(b => new { b.FK_ItemId, b.BatchNumber });
|
||||
|
||||
modelBuilder.Entity<ItemSerialNumber>()
|
||||
.HasOne(s => s.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// --- StockBalance / StockMovement batch & serial links ---
|
||||
|
||||
modelBuilder.Entity<StockBalance>()
|
||||
.HasOne(sb => sb.ItemBatch)
|
||||
.WithMany()
|
||||
.HasForeignKey(sb => sb.FK_ItemBatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<StockBalance>()
|
||||
.HasOne(sb => sb.ItemSerialNumber)
|
||||
.WithMany()
|
||||
.HasForeignKey(sb => sb.FK_ItemSerialNumberId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<StockMovement>()
|
||||
.HasOne(sm => sm.ItemBatch)
|
||||
.WithMany()
|
||||
.HasForeignKey(sm => sm.FK_ItemBatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<StockMovement>()
|
||||
.HasOne(sm => sm.ItemSerialNumber)
|
||||
.WithMany()
|
||||
.HasForeignKey(sm => sm.FK_ItemSerialNumberId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
// --- GoodsReceiptItemBatch / GoodsReceiptItemSerial ---
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItemBatch>()
|
||||
.HasOne(b => b.GoodsReceiptItem)
|
||||
.WithMany(i => i.Batches)
|
||||
.HasForeignKey(b => b.FK_GoodsReceiptItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItemBatch>()
|
||||
.HasOne(b => b.ItemBatch)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.FK_ItemBatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItemSerial>()
|
||||
.HasOne(s => s.GoodsReceiptItem)
|
||||
.WithMany(i => i.Serials)
|
||||
.HasForeignKey(s => s.FK_GoodsReceiptItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<GoodsReceiptItemSerial>()
|
||||
.HasOne(s => s.ItemSerialNumber)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_ItemSerialNumberId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// --- PurchaseReturn ---
|
||||
|
||||
modelBuilder.Entity<PurchaseReturn>()
|
||||
@@ -447,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
|
||||
// --- Regional ---
|
||||
|
||||
modelBuilder.Entity<Region>()
|
||||
.Property(r => r.Code)
|
||||
.HasMaxLength(50);
|
||||
|
||||
modelBuilder.Entity<Region>()
|
||||
.HasIndex(r => r.Code)
|
||||
.IsUnique();
|
||||
|
||||
|
||||
modelBuilder.Entity<RegionalTarget>()
|
||||
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||
.IsUnique();
|
||||
@@ -479,7 +585,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasMaxLength(100);
|
||||
|
||||
modelBuilder.Entity<SysConfig>().HasData(
|
||||
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
|
||||
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null },
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
|
||||
);
|
||||
|
||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||
|
||||
@@ -84,8 +84,8 @@ public static class MasterDataSeed
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Region>().HasData(
|
||||
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
||||
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
||||
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
||||
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Models.Route>().HasData(
|
||||
@@ -96,12 +96,12 @@ public static class MasterDataSeed
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Store>().HasData(
|
||||
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
|
||||
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", CreatedAt = SeedDate, IsActive = true },
|
||||
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<Warehouse>().HasData(
|
||||
|
||||
@@ -27,8 +27,6 @@ public static class OperationsExtrasSeed
|
||||
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
|
||||
|
||||
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
|
||||
|
||||
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
|
||||
|
||||
@@ -83,12 +81,6 @@ public static class OperationsExtrasSeed
|
||||
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
||||
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<StoreKycDocument>().HasData(
|
||||
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
|
||||
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
|
||||
);
|
||||
|
||||
modelBuilder.Entity<DriverComplianceConfig>().HasData(
|
||||
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
@@ -26,6 +27,16 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("delivery-tasks")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] DeliveryTaskStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(status, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("delivery-tasks/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
|
||||
@@ -26,6 +26,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
|
||||
.OrderBy(t => t.Sequence)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
|
||||
{
|
||||
var query = context.DeliveryTasks
|
||||
.Include(t => t.ShopPO)
|
||||
.ThenInclude(o => o.Store)
|
||||
.Include(t => t.ShopPO)
|
||||
.ThenInclude(o => o.Items.Where(i => i.IsActive))
|
||||
.ThenInclude(i => i.Item)
|
||||
.AsQueryable();
|
||||
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
|
||||
return query.OrderByDescending(t => t.CreatedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
|
||||
{
|
||||
var query = context.DeliveryTasks.AsQueryable();
|
||||
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
|
||||
return query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
|
||||
await context.DeliveryTasks
|
||||
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Distribution.Features.LoadingEvent;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.ShopPO;
|
||||
using Distribution.Features.Stock;
|
||||
@@ -10,6 +11,7 @@ public class DeliveryTaskService(
|
||||
IDeliveryTaskRepository repo,
|
||||
IRideRepository rideRepo,
|
||||
IShopPORepository shopPORepo,
|
||||
ILoadingSheetRepository loadingSheetRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
@@ -32,8 +34,52 @@ public class DeliveryTaskService(
|
||||
i.UnitPrice)).ToList(),
|
||||
t.CreatedAt);
|
||||
|
||||
public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
|
||||
throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
|
||||
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue);
|
||||
if (confirmedSheets.Count == 0)
|
||||
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
|
||||
|
||||
var shopPOIds = confirmedSheets
|
||||
.SelectMany(s => s.Orders)
|
||||
.Select(o => o.FK_ShopPOId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var existingTasks = await repo.GetByRideIdAsync(rideId);
|
||||
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
|
||||
|
||||
var newTasks = new List<DeliveryTask>();
|
||||
foreach (var shopPOId in shopPOIds)
|
||||
{
|
||||
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
|
||||
|
||||
newTasks.Add(new DeliveryTask
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
FK_ShopPOId = shopPOId,
|
||||
Sequence = nextSequence++,
|
||||
Status = DeliveryTaskStatus.Pending
|
||||
});
|
||||
}
|
||||
|
||||
if (newTasks.Count > 0)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var task in newTasks) 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)
|
||||
{
|
||||
@@ -41,6 +87,13 @@ public class DeliveryTaskService(
|
||||
return tasks.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<DeliveryTaskResponse>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
|
||||
{
|
||||
var items = await repo.GetAllAsync(status, page, pageSize);
|
||||
var total = await repo.GetCountAsync(status);
|
||||
return new PagedResponse<DeliveryTaskResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(id);
|
||||
|
||||
@@ -6,6 +6,8 @@ public interface IDeliveryTaskRepository
|
||||
{
|
||||
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
||||
Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize);
|
||||
Task<int> GetCountAsync(DeliveryTaskStatus? status);
|
||||
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
||||
Task AddAsync(DeliveryTask task);
|
||||
void Update(DeliveryTask task);
|
||||
|
||||
@@ -32,13 +32,28 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
||||
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
||||
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
|
||||
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
|
||||
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
/// <summary>Goods received without a purchase order.</summary>
|
||||
[HttpPost("api/goods-receipts/direct")]
|
||||
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateDirectAsync(request, userId.Value, 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));
|
||||
|
||||
@@ -1,35 +1,104 @@
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
|
||||
/// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary>
|
||||
public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
|
||||
|
||||
public record GrnItemRequest(
|
||||
// Set for a PO-based receipt; null for a direct receipt (ItemId is used instead).
|
||||
Guid? PurchaseOrderItemId,
|
||||
int AcceptedQuantity,
|
||||
int RejectedQuantity,
|
||||
// Populated only when the item has TracksBatches and/or TracksExpiryDate set.
|
||||
List<GrnBatchLineRequest>? Batches = null,
|
||||
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
|
||||
List<string>? SerialNumbers = null,
|
||||
// Set for a direct receipt line (no purchase order).
|
||||
Guid? ItemId = null,
|
||||
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
|
||||
decimal? UnitCost = null,
|
||||
// Null falls back to the GRN-wide percentage.
|
||||
decimal? DiscountPercent = null,
|
||||
decimal? VatPercent = null,
|
||||
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
|
||||
int FreeQuantity = 0,
|
||||
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
|
||||
decimal? NbtPercent = null);
|
||||
|
||||
public record CreateGrnRequest(
|
||||
Guid WarehouseId,
|
||||
List<GrnItemRequest> Items,
|
||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||
Guid? ReceivedByUserId = null);
|
||||
Guid? ReceivedByUserId = null,
|
||||
// GRN-wide defaults for lines that don't set their own.
|
||||
decimal? DiscountPercent = null,
|
||||
decimal? VatPercent = null,
|
||||
decimal? NbtPercent = null);
|
||||
|
||||
public record CreateFlatGrnRequest(
|
||||
Guid PurchaseOrderId,
|
||||
Guid WarehouseId,
|
||||
List<GrnItemRequest> Items,
|
||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||
Guid? ReceivedByUserId = null);
|
||||
Guid? ReceivedByUserId = null,
|
||||
decimal? DiscountPercent = null,
|
||||
decimal? VatPercent = null,
|
||||
decimal? NbtPercent = null);
|
||||
|
||||
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
|
||||
public record CreateDirectGrnRequest(
|
||||
Guid WarehouseId,
|
||||
List<GrnItemRequest> Items,
|
||||
Guid? SupplierId = null,
|
||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||
Guid? ReceivedByUserId = null,
|
||||
decimal? DiscountPercent = null,
|
||||
decimal? VatPercent = null,
|
||||
decimal? NbtPercent = null);
|
||||
|
||||
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
|
||||
|
||||
public record GrnItemResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderItemId,
|
||||
Guid? PurchaseOrderItemId,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
int AcceptedQuantity,
|
||||
int RejectedQuantity);
|
||||
int RejectedQuantity,
|
||||
List<GrnBatchLineResponse> Batches,
|
||||
List<string> SerialNumbers,
|
||||
decimal UnitCost,
|
||||
decimal DiscountPercent,
|
||||
decimal VatPercent,
|
||||
// Accepted qty x unit cost, before discount.
|
||||
decimal GrossAmount,
|
||||
decimal DiscountAmount,
|
||||
decimal VatAmount,
|
||||
// Gross - discount + VAT.
|
||||
decimal LineTotal,
|
||||
int FreeQuantity,
|
||||
decimal NbtPercent,
|
||||
decimal NbtAmount);
|
||||
|
||||
public record GrnResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderId,
|
||||
Guid? PurchaseOrderId,
|
||||
Guid ReceivedByUserId,
|
||||
Guid WarehouseId,
|
||||
string WarehouseName,
|
||||
Guid? SupplierId,
|
||||
string? SupplierName,
|
||||
DateTime ReceivedAt,
|
||||
DateTime CreatedAt,
|
||||
List<GrnItemResponse> Items);
|
||||
List<GrnItemResponse> Items,
|
||||
decimal DiscountPercent,
|
||||
decimal VatPercent,
|
||||
// Sum of line gross amounts.
|
||||
decimal SubTotal,
|
||||
decimal DiscountAmount,
|
||||
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
|
||||
decimal TaxableAmount,
|
||||
decimal VatAmount,
|
||||
decimal TotalAmount,
|
||||
decimal NbtPercent,
|
||||
decimal NbtAmount);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
|
||||
|
||||
/// <summary>
|
||||
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
|
||||
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
|
||||
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
|
||||
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
|
||||
/// </summary>
|
||||
public static class GrnPricing
|
||||
{
|
||||
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
public static LineAmounts Line(
|
||||
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
|
||||
{
|
||||
var gross = Round(acceptedQty * unitCost);
|
||||
var discount = Round(gross * discountPercent / 100m);
|
||||
var net = gross - discount;
|
||||
var nbt = Round(net * nbtPercent / 100m);
|
||||
var vat = Round((net + nbt) * vatPercent / 100m);
|
||||
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
|
||||
}
|
||||
|
||||
public static void ValidatePercent(decimal value, string label)
|
||||
{
|
||||
if (value < 0 || value > 100)
|
||||
throw new InvalidOperationException($"{label} must be between 0 and 100.");
|
||||
}
|
||||
|
||||
public static void ValidateCost(decimal value, string label)
|
||||
{
|
||||
if (value < 0)
|
||||
throw new InvalidOperationException($"{label} cannot be negative.");
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,21 @@ 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)
|
||||
.Include(gr => gr.Supplier)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Item)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
|
||||
.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)
|
||||
.Include(gr => gr.Supplier)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Item)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
|
||||
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||
.OrderByDescending(gr => gr.ReceivedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||
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)
|
||||
.Include(gr => gr.Supplier)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Item)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
|
||||
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||
.OrderByDescending(gr => gr.ReceivedAt)
|
||||
.ToListAsync();
|
||||
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||
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)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -9,15 +10,88 @@ public class GrnService(
|
||||
IGrnRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
IUnitOfWork uow,
|
||||
Distribution.Features.Item.IItemRepository itemRepo,
|
||||
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||
{
|
||||
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 GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||
{
|
||||
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
|
||||
var item = i.Item ?? i.PurchaseOrderItem!.Item;
|
||||
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
|
||||
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
|
||||
i.AcceptedQuantity, i.RejectedQuantity,
|
||||
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
|
||||
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
|
||||
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
|
||||
i.NbtPercent, a.Nbt);
|
||||
}
|
||||
|
||||
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());
|
||||
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
|
||||
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
|
||||
|
||||
private static GrnResponse Map(Models.GoodsReceipt gr)
|
||||
{
|
||||
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
|
||||
var subTotal = items.Sum(i => i.GrossAmount);
|
||||
var discount = items.Sum(i => i.DiscountAmount);
|
||||
var vat = items.Sum(i => i.VatAmount);
|
||||
var nbt = items.Sum(i => i.NbtAmount);
|
||||
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||
gr.FK_SupplierId, gr.Supplier?.Name,
|
||||
gr.ReceivedAt, gr.CreatedAt, items,
|
||||
gr.DiscountPercent, gr.VatPercent,
|
||||
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
|
||||
gr.NbtPercent, nbt);
|
||||
}
|
||||
|
||||
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
|
||||
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
|
||||
|
||||
/// <summary>
|
||||
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
|
||||
/// forced to 0 whatever the client sent.
|
||||
/// </summary>
|
||||
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
|
||||
{
|
||||
var discount = discountPercent ?? 0m;
|
||||
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
|
||||
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
|
||||
GrnPricing.ValidatePercent(discount, "GRN discount %");
|
||||
GrnPricing.ValidatePercent(vat, "GRN VAT %");
|
||||
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
|
||||
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
|
||||
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
|
||||
/// </summary>
|
||||
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
|
||||
{
|
||||
if (mode == TaxMode.Disabled) return 0m;
|
||||
var value = lineValue ?? headerValue;
|
||||
if (value is null)
|
||||
{
|
||||
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
|
||||
return 0m;
|
||||
}
|
||||
GrnPricing.ValidatePercent(value.Value, label);
|
||||
return value.Value;
|
||||
}
|
||||
|
||||
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
|
||||
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
|
||||
GrnHeader header, GrnTaxSettings tax, string sku)
|
||||
{
|
||||
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
|
||||
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
|
||||
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
|
||||
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
|
||||
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
|
||||
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||
{
|
||||
@@ -36,10 +110,61 @@ public class GrnService(
|
||||
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an
|
||||
/// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for
|
||||
/// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not
|
||||
/// yet linked into the ledger (follow-up work).
|
||||
/// </summary>
|
||||
private static void ValidateLineShape(Models.Item item, GrnItemRequest line)
|
||||
{
|
||||
if (item.TracksSerialNumbers)
|
||||
{
|
||||
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
|
||||
throw new InvalidOperationException(
|
||||
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
|
||||
|
||||
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
|
||||
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
|
||||
}
|
||||
else if (line.SerialNumbers is { Count: > 0 })
|
||||
{
|
||||
throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers.");
|
||||
}
|
||||
else if (item.TracksBatches || item.TracksExpiryDate)
|
||||
{
|
||||
if (StockQty(line) > 0)
|
||||
{
|
||||
if (line.Batches is null || line.Batches.Count == 0)
|
||||
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
|
||||
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
|
||||
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
|
||||
}
|
||||
|
||||
foreach (var b in line.Batches ?? [])
|
||||
{
|
||||
if (b.Quantity <= 0)
|
||||
throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive.");
|
||||
if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber))
|
||||
throw new InvalidOperationException($"Item {item.Sku} requires a batch number.");
|
||||
if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber))
|
||||
throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers.");
|
||||
if (item.TracksExpiryDate && b.ExpiryDate is null)
|
||||
throw new InvalidOperationException($"Item {item.Sku} requires an expiry date.");
|
||||
}
|
||||
}
|
||||
else if (line.Batches is { Count: > 0 })
|
||||
{
|
||||
throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
|
||||
/// units don't count against the PO's ordered quantity), creates
|
||||
/// ItemBatch/ItemSerialNumber records for tracked items, 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)
|
||||
{
|
||||
@@ -56,37 +181,63 @@ public class GrnService(
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||
|
||||
var tax = await sysConfig.GetGrnTaxSettingsAsync();
|
||||
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
|
||||
|
||||
var goodsReceipt = new Models.GoodsReceipt
|
||||
{
|
||||
FK_PurchaseOrderId = po.Id,
|
||||
FK_ReceivedByUserId = receivedByUserId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
ReceivedAt = DateTime.Now
|
||||
ReceivedAt = DateTime.Now,
|
||||
DiscountPercent = header.Discount,
|
||||
VatPercent = header.Vat,
|
||||
NbtPercent = header.Nbt
|
||||
};
|
||||
|
||||
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.PurchaseOrderItemId is null)
|
||||
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
|
||||
|
||||
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)
|
||||
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 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.");
|
||||
|
||||
ValidateLineShape(poItem.Item, line);
|
||||
|
||||
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
|
||||
{
|
||||
foreach (var serialNumber in line.SerialNumbers)
|
||||
{
|
||||
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
|
||||
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
|
||||
}
|
||||
}
|
||||
|
||||
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||
|
||||
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
||||
var entity = new Models.GoodsReceiptItem
|
||||
{
|
||||
FK_PurchaseOrderItemId = poItem.Id,
|
||||
AcceptedQuantity = line.AcceptedQuantity,
|
||||
FreeQuantity = line.FreeQuantity,
|
||||
RejectedQuantity = line.RejectedQuantity
|
||||
});
|
||||
};
|
||||
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
|
||||
goodsReceipt.Items.Add(entity);
|
||||
grLines.Add((entity, line, poItem.Item));
|
||||
}
|
||||
|
||||
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||
@@ -94,48 +245,238 @@ public class GrnService(
|
||||
? PurchaseOrderStatus.Received
|
||||
: PurchaseOrderStatus.PartiallyReceived;
|
||||
|
||||
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
|
||||
}
|
||||
/// <summary>
|
||||
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
|
||||
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
|
||||
/// </summary>
|
||||
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||
{
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||
|
||||
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
|
||||
throw new InvalidOperationException("Supplier not found.");
|
||||
|
||||
var tax = await sysConfig.GetGrnTaxSettingsAsync();
|
||||
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
|
||||
|
||||
var goodsReceipt = new Models.GoodsReceipt
|
||||
{
|
||||
FK_PurchaseOrderId = null,
|
||||
FK_SupplierId = request.SupplierId,
|
||||
FK_ReceivedByUserId = receivedByUserId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
ReceivedAt = DateTime.Now,
|
||||
DiscountPercent = header.Discount,
|
||||
VatPercent = header.Vat,
|
||||
NbtPercent = header.Nbt
|
||||
};
|
||||
|
||||
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
||||
var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>();
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
if (line.ItemId is null)
|
||||
throw new InvalidOperationException("Each line of a direct receipt needs an ItemId.");
|
||||
|
||||
var item = await itemRepo.GetByIdAsync(line.ItemId.Value);
|
||||
if (item is null || !item.IsActive)
|
||||
throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
||||
|
||||
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
|
||||
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||
|
||||
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
|
||||
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
|
||||
|
||||
ValidateLineShape(item, line);
|
||||
|
||||
if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
|
||||
{
|
||||
foreach (var serialNumber in line.SerialNumbers)
|
||||
{
|
||||
if (!serialsInRequest.Add((item.Id, serialNumber)))
|
||||
throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}.");
|
||||
if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber))
|
||||
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}.");
|
||||
}
|
||||
}
|
||||
|
||||
var entity = new Models.GoodsReceiptItem
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
FreeQuantity = line.FreeQuantity,
|
||||
AcceptedQuantity = line.AcceptedQuantity,
|
||||
RejectedQuantity = line.RejectedQuantity
|
||||
};
|
||||
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
|
||||
goodsReceipt.Items.Add(entity);
|
||||
grLines.Add((entity, line, item));
|
||||
}
|
||||
|
||||
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO
|
||||
/// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts.
|
||||
/// </summary>
|
||||
private async Task<GrnResponse> PostReceiptAsync(
|
||||
Models.GoodsReceipt goodsReceipt,
|
||||
List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines,
|
||||
Guid warehouseId,
|
||||
Guid receivedByUserId,
|
||||
Models.PurchaseOrder? po,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(goodsReceipt);
|
||||
|
||||
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
||||
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 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)
|
||||
if (item.TracksSerialNumbers)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
foreach (var serialNumber in line.SerialNumbers!)
|
||||
{
|
||||
FK_ItemId = poItem.FK_ItemId,
|
||||
LocationId = request.WarehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity
|
||||
});
|
||||
var serial = new ItemSerialNumber
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
SerialNumber = serialNumber,
|
||||
Status = SerialStatus.InStock,
|
||||
CurrentLocationId = warehouseId,
|
||||
CurrentLocationType = LocationType.Warehouse,
|
||||
ReceivedAt = goodsReceipt.ReceivedAt
|
||||
};
|
||||
|
||||
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
|
||||
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
|
||||
// stays null until the sale/delivery flow sets it.
|
||||
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
|
||||
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
|
||||
|
||||
await stockRepo.AddItemSerialNumberAsync(serial);
|
||||
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
|
||||
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
LocationId = warehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
FK_ItemSerialNumberId = serial.Id,
|
||||
Quantity = 1
|
||||
});
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
FromLocationId = StockService.ExternalLocationId,
|
||||
FromLocationType = LocationType.External,
|
||||
ToLocationId = warehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
FK_ItemSerialNumberId = serial.Id,
|
||||
Quantity = 1,
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (item.TracksBatches || item.TracksExpiryDate)
|
||||
{
|
||||
foreach (var b in line.Batches!)
|
||||
{
|
||||
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
|
||||
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
|
||||
: null;
|
||||
|
||||
if (batch is null)
|
||||
{
|
||||
batch = new ItemBatch
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
BatchNumber = b.BatchNumber,
|
||||
ExpiryDate = b.ExpiryDate
|
||||
};
|
||||
await stockRepo.AddItemBatchAsync(batch);
|
||||
}
|
||||
|
||||
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
|
||||
|
||||
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
|
||||
if (balance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
LocationId = warehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
FK_ItemBatchId = batch.Id,
|
||||
Quantity = b.Quantity
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += b.Quantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
FromLocationId = StockService.ExternalLocationId,
|
||||
FromLocationType = LocationType.External,
|
||||
ToLocationId = warehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
FK_ItemBatchId = batch.Id,
|
||||
Quantity = b.Quantity,
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += line.AcceptedQuantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
|
||||
if (balance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
LocationId = warehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
Quantity = StockQty(line)
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += StockQty(line);
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = poItem.FK_ItemId,
|
||||
FromLocationId = StockService.ExternalLocationId,
|
||||
FromLocationType = LocationType.External,
|
||||
ToLocationId = request.WarehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity,
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
});
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = item.Id,
|
||||
FromLocationId = StockService.ExternalLocationId,
|
||||
FromLocationType = LocationType.External,
|
||||
ToLocationId = warehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
Quantity = StockQty(line),
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
poRepo.Update(po);
|
||||
if (po is not null) poRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
@@ -6,8 +6,9 @@ public interface IInvoiceRepository
|
||||
{
|
||||
Task<Models.Invoice?> GetByIdAsync(Guid id);
|
||||
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
|
||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
|
||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||
Task AddAsync(Models.Invoice invoice);
|
||||
void Update(Models.Invoice invoice);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Invoice;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/invoices")]
|
||||
public class InvoiceController(InvoiceService service) : ControllerBase
|
||||
public class InvoiceController(InvoiceService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
@@ -13,10 +14,18 @@ public class InvoiceController(InvoiceService service) : ControllerBase
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? shopId,
|
||||
[FromQuery] InvoiceStatus? status,
|
||||
[FromQuery] PaymentStatus? paymentStatus,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
|
||||
var result = await service.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("outstanding-summary")]
|
||||
public async Task<IActionResult> GetOutstandingSummary()
|
||||
{
|
||||
var result = await service.GetOutstandingSummaryAsync();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -26,4 +35,36 @@ public class InvoiceController(InvoiceService service) : ControllerBase
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/print")]
|
||||
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request?.PrintedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "PrintedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.PrintAsync(id, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/finalize")]
|
||||
public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request?.FinalizedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "FinalizedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace Distribution.Features.Invoice;
|
||||
|
||||
public record CreateInvoiceRequest(Guid ShopPOId);
|
||||
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
|
||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
|
||||
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
|
||||
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
|
||||
|
||||
public record InvoiceResponse(
|
||||
Guid Id,
|
||||
@@ -20,3 +21,5 @@ public record InvoiceResponse(
|
||||
decimal TotalAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -9,6 +9,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
|
||||
|
||||
@@ -17,27 +18,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
.Include(i => i.ShopPO)
|
||||
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
|
||||
|
||||
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) =>
|
||||
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Where(i => i.IsActive
|
||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
||||
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
|
||||
&& (status == null || i.Status == status))
|
||||
&& (status == null || i.Status == status)
|
||||
&& (paymentStatus == null || i.PaymentStatus == paymentStatus))
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) =>
|
||||
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus) =>
|
||||
await context.Invoices
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.CountAsync(i => i.IsActive
|
||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
||||
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
|
||||
&& (status == null || i.Status == status));
|
||||
&& (status == null || i.Status == status)
|
||||
&& (paymentStatus == null || i.PaymentStatus == paymentStatus));
|
||||
|
||||
public async Task<List<Models.Invoice>> GetOutstandingAsync() =>
|
||||
await context.Invoices
|
||||
.Include(i => i.Payments.Where(p => p.IsActive))
|
||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Invoice invoice) =>
|
||||
await context.Invoices.AddAsync(invoice);
|
||||
|
||||
@@ -24,10 +24,75 @@ public class InvoiceService(
|
||||
return invoice is null ? null : ToResponse(invoice);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
|
||||
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(agentId, routeId, shopId, status);
|
||||
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
|
||||
var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
|
||||
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
|
||||
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync()
|
||||
{
|
||||
var invoices = await repo.GetOutstandingAsync();
|
||||
var count = invoices.Count;
|
||||
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount));
|
||||
return new OutstandingPaymentsResponse(count, outstanding);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Invoice not found");
|
||||
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||
|
||||
invoice.Status = InvoiceStatus.Printed;
|
||||
invoice.PrintedAt = DateTime.Now;
|
||||
invoice.PrintedBy = printedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
}
|
||||
|
||||
public async Task<InvoiceResponse> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Invoice not found");
|
||||
|
||||
if (invoice.Status != InvoiceStatus.Printed)
|
||||
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
|
||||
|
||||
var adjustments = request.Adjustments ?? [];
|
||||
if (adjustments.Count > 0)
|
||||
{
|
||||
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
|
||||
decimal total = 0;
|
||||
foreach (var item in poItems)
|
||||
{
|
||||
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
|
||||
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
|
||||
|
||||
if (billedQuantity < 0 || billedQuantity > item.Quantity)
|
||||
throw new InvalidOperationException(
|
||||
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
|
||||
|
||||
total += billedQuantity * item.UnitPrice;
|
||||
}
|
||||
invoice.TotalAmount = total;
|
||||
}
|
||||
|
||||
invoice.Status = InvoiceStatus.Finalized;
|
||||
invoice.FinalizedAt = DateTime.Now;
|
||||
invoice.FinalizedBy = finalizedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,14 @@ public record CreateItemRequest(
|
||||
Guid? BrandId,
|
||||
Guid BaseUomId,
|
||||
decimal? SalePrice,
|
||||
List<UomConversionRequest>? Conversions);
|
||||
List<UomConversionRequest>? Conversions,
|
||||
decimal? ReorderLevel = null,
|
||||
bool TracksSerialNumbers = false,
|
||||
bool TracksBatches = false,
|
||||
bool TracksExpiryDate = false,
|
||||
bool HasWarranty = false,
|
||||
int? WarrantyPeriodDays = null,
|
||||
bool AllowCustomWarrantyPeriod = false);
|
||||
|
||||
public record UpdateItemRequest(
|
||||
string Name,
|
||||
@@ -21,7 +28,14 @@ public record UpdateItemRequest(
|
||||
Guid? BrandId,
|
||||
Guid BaseUomId,
|
||||
decimal? SalePrice,
|
||||
bool IsActive = true);
|
||||
bool IsActive = true,
|
||||
decimal? ReorderLevel = null,
|
||||
bool TracksSerialNumbers = false,
|
||||
bool TracksBatches = false,
|
||||
bool TracksExpiryDate = false,
|
||||
bool HasWarranty = false,
|
||||
int? WarrantyPeriodDays = null,
|
||||
bool AllowCustomWarrantyPeriod = false);
|
||||
|
||||
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
||||
|
||||
@@ -40,6 +54,13 @@ public record ItemSummaryResponse(
|
||||
Guid BaseUomId,
|
||||
string BaseUomName,
|
||||
decimal? SalePrice,
|
||||
decimal? ReorderLevel,
|
||||
bool TracksSerialNumbers,
|
||||
bool TracksBatches,
|
||||
bool TracksExpiryDate,
|
||||
bool HasWarranty,
|
||||
int? WarrantyPeriodDays,
|
||||
bool AllowCustomWarrantyPeriod,
|
||||
Models.SyncSource SyncSource,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
@@ -58,6 +79,13 @@ public record ItemDetailResponse(
|
||||
Guid BaseUomId,
|
||||
string BaseUomName,
|
||||
decimal? SalePrice,
|
||||
decimal? ReorderLevel,
|
||||
bool TracksSerialNumbers,
|
||||
bool TracksBatches,
|
||||
bool TracksExpiryDate,
|
||||
bool HasWarranty,
|
||||
int? WarrantyPeriodDays,
|
||||
bool AllowCustomWarrantyPeriod,
|
||||
Models.SyncSource SyncSource,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
|
||||
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Item;
|
||||
|
||||
public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
|
||||
{
|
||||
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
|
||||
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
|
||||
@@ -13,7 +13,10 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
i.FK_SubCategoryId, i.SubCategory?.Name,
|
||||
i.FK_BrandId, i.Brand?.Name,
|
||||
i.FK_BaseUomId, i.BaseUom.Name,
|
||||
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
|
||||
i.SalePrice, i.ReorderLevel,
|
||||
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
|
||||
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
|
||||
i.SyncSource, i.IsActive, i.CreatedAt);
|
||||
|
||||
private static ItemDetailResponse MapDetail(Models.Item i) =>
|
||||
new(i.Id, i.Sku, i.Name, i.Description,
|
||||
@@ -21,9 +24,44 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
i.FK_SubCategoryId, i.SubCategory?.Name,
|
||||
i.FK_BrandId, i.Brand?.Name,
|
||||
i.FK_BaseUomId, i.BaseUom.Name,
|
||||
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
|
||||
i.SalePrice, i.ReorderLevel,
|
||||
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
|
||||
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
|
||||
i.SyncSource, i.IsActive, i.CreatedAt,
|
||||
i.Conversions.Select(MapConversion).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Rejects per-item tracking flags that are off system-wide, and warranty-period values that
|
||||
/// don't make sense given HasWarranty/AllowCustomWarrantyPeriod.
|
||||
/// </summary>
|
||||
private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches,
|
||||
bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod)
|
||||
{
|
||||
if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync())
|
||||
throw new InvalidOperationException("Serial number tracking is disabled system-wide.");
|
||||
|
||||
if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync())
|
||||
throw new InvalidOperationException("Batch tracking is disabled system-wide.");
|
||||
|
||||
if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync())
|
||||
throw new InvalidOperationException("Expiry date tracking is disabled system-wide.");
|
||||
|
||||
if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync())
|
||||
throw new InvalidOperationException("Warranty tracking is disabled system-wide.");
|
||||
|
||||
if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync())
|
||||
throw new InvalidOperationException("Custom warranty period override is disabled system-wide.");
|
||||
|
||||
if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod))
|
||||
throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true.");
|
||||
|
||||
if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod)
|
||||
throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true.");
|
||||
|
||||
if (warrantyPeriodDays is < 0)
|
||||
throw new InvalidOperationException("WarrantyPeriodDays cannot be negative.");
|
||||
}
|
||||
|
||||
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
|
||||
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
|
||||
.Select(MapSummary)
|
||||
@@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
if (await repo.SkuExistsAsync(request.Sku))
|
||||
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
|
||||
|
||||
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
|
||||
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
|
||||
|
||||
var item = new Models.Item
|
||||
{
|
||||
Sku = request.Sku,
|
||||
@@ -51,6 +92,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
FK_BrandId = request.BrandId,
|
||||
FK_BaseUomId = request.BaseUomId,
|
||||
SalePrice = request.SalePrice,
|
||||
ReorderLevel = request.ReorderLevel,
|
||||
TracksSerialNumbers = request.TracksSerialNumbers,
|
||||
TracksBatches = request.TracksBatches,
|
||||
TracksExpiryDate = request.TracksExpiryDate,
|
||||
HasWarranty = request.HasWarranty,
|
||||
WarrantyPeriodDays = request.WarrantyPeriodDays,
|
||||
AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod,
|
||||
SyncSource = Models.SyncSource.Manual
|
||||
};
|
||||
|
||||
@@ -77,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
if (item is null) return null;
|
||||
if (item.SyncSource == Models.SyncSource.Erp) return null;
|
||||
|
||||
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
|
||||
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
|
||||
|
||||
item.Name = request.Name;
|
||||
item.Description = request.Description;
|
||||
item.FK_CategoryId = request.CategoryId;
|
||||
@@ -84,6 +135,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
item.FK_BrandId = request.BrandId;
|
||||
item.FK_BaseUomId = request.BaseUomId;
|
||||
item.SalePrice = request.SalePrice;
|
||||
item.ReorderLevel = request.ReorderLevel;
|
||||
item.TracksSerialNumbers = request.TracksSerialNumbers;
|
||||
item.TracksBatches = request.TracksBatches;
|
||||
item.TracksExpiryDate = request.TracksExpiryDate;
|
||||
item.HasWarranty = request.HasWarranty;
|
||||
item.WarrantyPeriodDays = request.WarrantyPeriodDays;
|
||||
item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod;
|
||||
item.IsActive = request.IsActive;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
|
||||
@@ -6,10 +6,12 @@ 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<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId);
|
||||
Task AddAsync(LoadingSheet sheet);
|
||||
void Update(LoadingSheet sheet);
|
||||
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
||||
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
|
||||
Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds);
|
||||
Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? rideId,
|
||||
[FromQuery] LoadingSheetStatus? status,
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? regionId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(rideId, status, page, pageSize);
|
||||
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -29,10 +31,13 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.PopulatedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "PopulatedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId, ct);
|
||||
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
@@ -52,12 +57,15 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm")]
|
||||
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmAsync(id, userId, ct);
|
||||
var result = await service.ConfirmAsync(id, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
|
||||
@@ -21,11 +21,16 @@ public record LoadingSheetItemResponse(
|
||||
Guid FK_WarehouseId,
|
||||
string WarehouseName,
|
||||
decimal QuantityDispatched,
|
||||
Guid? FK_ShopPOItemId);
|
||||
Guid? FK_ShopPOItemId,
|
||||
Guid? ShopPOId = null,
|
||||
Guid? StoreId = null,
|
||||
string? StoreName = null);
|
||||
|
||||
public record LoadingSheetOrderResponse(Guid ShopPOId);
|
||||
|
||||
public record CreateLoadingSheetRequest(Guid FK_RideId);
|
||||
public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null);
|
||||
|
||||
public record SetItemsRequest(List<SetItemInput> Items);
|
||||
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
|
||||
|
||||
public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null);
|
||||
|
||||
@@ -10,6 +10,7 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
||||
await context.LoadingSheets
|
||||
.Include(s => s.Items).ThenInclude(i => i.Item)
|
||||
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
|
||||
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(s => s.Orders)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
@@ -17,24 +18,29 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
||||
await context.LoadingSheets
|
||||
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
|
||||
|
||||
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
|
||||
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) =>
|
||||
await context.LoadingSheets
|
||||
.Include(s => s.Items).ThenInclude(i => i.Item)
|
||||
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
|
||||
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
|
||||
.Include(s => s.Orders)
|
||||
.Where(s =>
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(status == null || s.Status == status))
|
||||
(status == null || s.Status == status) &&
|
||||
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
|
||||
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
|
||||
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId) =>
|
||||
await context.LoadingSheets
|
||||
.CountAsync(s =>
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(status == null || s.Status == status));
|
||||
(status == null || s.Status == status) &&
|
||||
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
|
||||
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)));
|
||||
|
||||
public async Task AddAsync(LoadingSheet sheet) =>
|
||||
await context.LoadingSheets.AddAsync(sheet);
|
||||
@@ -47,4 +53,25 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
||||
|
||||
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
|
||||
await context.LoadingSheetItems.AddRangeAsync(items);
|
||||
|
||||
public async Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds) =>
|
||||
await context.ShopPOItems
|
||||
.Where(i => shopPOItemIds.Contains(i.Id))
|
||||
.Select(i => i.FK_ShopPOId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
public async Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds)
|
||||
{
|
||||
var current = await context.LoadingSheetOrders
|
||||
.Where(o => o.FK_LoadingSheetId == loadingSheetId)
|
||||
.ToListAsync();
|
||||
context.LoadingSheetOrders.RemoveRange(current);
|
||||
|
||||
await context.LoadingSheetOrders.AddRangeAsync(shopPOIds.Select(shopPOId => new LoadingSheetOrder
|
||||
{
|
||||
FK_LoadingSheetId = loadingSheetId,
|
||||
FK_ShopPOId = shopPOId
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ public class LoadingSheetService(
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
|
||||
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(rideId, status);
|
||||
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
|
||||
var total = await repo.GetCountAsync(rideId, status, routeId, regionId);
|
||||
var items = await repo.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
|
||||
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
@@ -64,18 +64,54 @@ public class LoadingSheetService(
|
||||
if (sheet.Status != LoadingSheetStatus.Planned)
|
||||
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
|
||||
|
||||
// The request is the full desired item list: a row matching (item, warehouse,
|
||||
// shop PO item) gets its quantity updated in place, a new combination is added,
|
||||
// and rows for combinations not present in the request are removed.
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.RemoveItems(sheet.Items);
|
||||
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
|
||||
var newItems = new List<LoadingSheetItem>();
|
||||
var keptItems = new List<LoadingSheetItem>();
|
||||
foreach (var input in request.Items)
|
||||
{
|
||||
FK_LoadingSheetId = sheet.Id,
|
||||
FK_ItemId = i.FK_ItemId,
|
||||
FK_WarehouseId = i.FK_WarehouseId,
|
||||
FK_ShopPOItemId = i.FK_ShopPOItemId,
|
||||
QuantityDispatched = i.Quantity
|
||||
}));
|
||||
var existing = sheet.Items.FirstOrDefault(i =>
|
||||
i.FK_ItemId == input.FK_ItemId &&
|
||||
i.FK_WarehouseId == input.FK_WarehouseId &&
|
||||
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.QuantityDispatched = input.Quantity;
|
||||
keptItems.Add(existing);
|
||||
}
|
||||
else
|
||||
newItems.Add(new LoadingSheetItem
|
||||
{
|
||||
FK_LoadingSheetId = sheet.Id,
|
||||
FK_ItemId = input.FK_ItemId,
|
||||
FK_WarehouseId = input.FK_WarehouseId,
|
||||
FK_ShopPOItemId = input.FK_ShopPOItemId,
|
||||
QuantityDispatched = input.Quantity
|
||||
});
|
||||
}
|
||||
|
||||
var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList();
|
||||
if (removedItems.Count > 0)
|
||||
repo.RemoveItems(removedItems);
|
||||
if (newItems.Count > 0)
|
||||
await repo.AddItemsAsync(newItems);
|
||||
|
||||
var shopPOItemIds = keptItems.Select(i => i.FK_ShopPOItemId)
|
||||
.Concat(newItems.Select(i => i.FK_ShopPOItemId))
|
||||
.Where(x => x.HasValue)
|
||||
.Select(x => x!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var shopPOIds = shopPOItemIds.Count > 0
|
||||
? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds)
|
||||
: new List<Guid>();
|
||||
|
||||
await repo.ReplaceOrdersAsync(sheet.Id, shopPOIds);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
@@ -118,28 +154,39 @@ public class LoadingSheetService(
|
||||
|
||||
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
|
||||
{
|
||||
// Vehicle balances created during this confirm are only tracked, not yet saved, so a
|
||||
// later row for the same item would not find them via the repository and would try to
|
||||
// insert a duplicate (item, vehicle) balance.
|
||||
var createdVehicleBalances = new Dictionary<Guid, StockBalance>();
|
||||
|
||||
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
|
||||
{
|
||||
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
|
||||
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
|
||||
?? throw new InvalidOperationException(
|
||||
$"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'");
|
||||
|
||||
if (warehouseBalance.Quantity < item.QuantityDispatched)
|
||||
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
|
||||
throw new InvalidOperationException(
|
||||
$"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " +
|
||||
$"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})");
|
||||
|
||||
warehouseBalance.Quantity -= item.QuantityDispatched;
|
||||
warehouseBalance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(warehouseBalance);
|
||||
|
||||
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
||||
var vehicleBalance = createdVehicleBalances.GetValueOrDefault(item.FK_ItemId)
|
||||
?? await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
||||
if (vehicleBalance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
var created = new StockBalance
|
||||
{
|
||||
FK_ItemId = item.FK_ItemId,
|
||||
LocationId = vehicleId,
|
||||
LocationType = LocationType.Vehicle,
|
||||
Quantity = item.QuantityDispatched
|
||||
});
|
||||
};
|
||||
createdVehicleBalances[item.FK_ItemId] = created;
|
||||
await stockRepo.AddBalanceAsync(created);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -169,6 +216,9 @@ public class LoadingSheetService(
|
||||
s.Items.Select(i => new LoadingSheetItemResponse(
|
||||
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
|
||||
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
|
||||
i.QuantityDispatched, i.FK_ShopPOItemId,
|
||||
i.ShopPOItem?.FK_ShopPOId,
|
||||
i.ShopPOItem?.ShopPO?.FK_StoreId,
|
||||
i.ShopPOItem?.ShopPO?.Store?.StoreName)).ToList(),
|
||||
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ProductReport;
|
||||
|
||||
public record SalesAggregate(Guid ItemId, string Sku, string Name, decimal? CostPrice,
|
||||
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled, decimal SalesValue);
|
||||
|
||||
public record ReturnAggregate(Guid ItemId, string Sku, string Name, ReturnReasonCode Reason,
|
||||
decimal Qty, decimal Value);
|
||||
|
||||
public interface IProductReportRepository
|
||||
{
|
||||
Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize);
|
||||
Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize);
|
||||
Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f);
|
||||
Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds);
|
||||
Task<decimal> GetTotalSalesAsync(ProductReportFilter f);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ProductReport;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reports/products")]
|
||||
public class ProductReportController(ProductReportService service) : ControllerBase
|
||||
{
|
||||
private IActionResult? Check(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var error = ProductReportService.Validate(f, page, pageSize);
|
||||
return error is null ? null : BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
[HttpGet("stock")]
|
||||
public async Task<IActionResult> Stock(
|
||||
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
|
||||
[FromQuery] Guid? warehouseId, [FromQuery] bool activeOnly = true, [FromQuery] bool lowStockOnly = false,
|
||||
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
{
|
||||
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, warehouseId, null, null, null,
|
||||
null, null, activeOnly, lowStockOnly, search);
|
||||
return Check(f, page, pageSize) ?? Ok(await service.GetStockAsync(f, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("sales")]
|
||||
public async Task<IActionResult> Sales(
|
||||
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
|
||||
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
{
|
||||
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
|
||||
from, to, false, false, search);
|
||||
return Check(f, page, pageSize) ?? Ok(await service.GetSalesAsync(f, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("returns")]
|
||||
public async Task<IActionResult> Returns(
|
||||
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
|
||||
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
{
|
||||
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
|
||||
from, to, false, false, search);
|
||||
return Check(f, page, pageSize) ?? Ok(await service.GetReturnsAsync(f, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary(
|
||||
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? warehouseId,
|
||||
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var f = new ProductReportFilter(brandId, categoryId, null, warehouseId, null, null, null,
|
||||
from, to, true, false, null);
|
||||
return Check(f, 1, 1) ?? Ok(await service.GetSummaryAsync(f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ProductReport;
|
||||
|
||||
public record ProductStockRow(
|
||||
Guid ItemId, string Sku, string Name, string? Brand, string Category, string? SubCategory, string Uom,
|
||||
decimal? SalePrice, decimal? CostPrice, decimal? ReorderLevel,
|
||||
decimal WarehouseQty, decimal VehicleQty, decimal TotalQty, decimal StockValue,
|
||||
bool IsLowStock, bool IsActive);
|
||||
|
||||
public record ProductSalesRow(
|
||||
Guid ItemId, string Sku, string Name,
|
||||
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled,
|
||||
decimal SalesValue, decimal? CostValue, decimal? Margin, decimal? MarginPercent);
|
||||
|
||||
public record ProductReturnRow(
|
||||
Guid ItemId, string Sku, string Name,
|
||||
decimal ReturnedQty, decimal ReturnedValue, decimal SoldQty, decimal? ReturnPercent,
|
||||
Dictionary<ReturnReasonCode, decimal> QtyByReason);
|
||||
|
||||
public record ProductReportSummary(int ItemCount, decimal TotalQty, decimal TotalValue, decimal TotalSales);
|
||||
|
||||
public record ProductReportFilter(
|
||||
Guid? BrandId, Guid? CategoryId, Guid? SubCategoryId, Guid? WarehouseId,
|
||||
Guid? RegionId, Guid? RouteId, Guid? StoreId,
|
||||
DateTime? From, DateTime? To, bool ActiveOnly, bool LowStockOnly, string? Search);
|
||||
@@ -0,0 +1,156 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ProductReport;
|
||||
|
||||
public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
{
|
||||
public async Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var balances = db.StockBalances.AsNoTracking().AsQueryable();
|
||||
if (f.WarehouseId.HasValue)
|
||||
balances = balances.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
|
||||
|
||||
var items = db.Items.AsNoTracking().AsQueryable();
|
||||
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
|
||||
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) items = items.Where(i => i.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
|
||||
}
|
||||
|
||||
var withQty = items.Select(i => new
|
||||
{
|
||||
Item = i,
|
||||
Wh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Warehouse)
|
||||
.Sum(b => b.Quantity),
|
||||
Veh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Vehicle)
|
||||
.Sum(b => b.Quantity),
|
||||
});
|
||||
|
||||
if (f.LowStockOnly)
|
||||
withQty = withQty.Where(x => x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel);
|
||||
|
||||
var total = await withQty.CountAsync();
|
||||
var page_ = await withQty
|
||||
.OrderBy(x => x.Item.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Item.Id, x.Item.Sku, x.Item.Name,
|
||||
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
|
||||
Category = x.Item.Category.Name,
|
||||
SubCategory = x.Item.SubCategory != null ? x.Item.SubCategory.Name : null,
|
||||
Uom = x.Item.BaseUom.Name,
|
||||
x.Item.SalePrice, x.Item.CostPrice, x.Item.ReorderLevel, x.Item.IsActive,
|
||||
x.Wh, x.Veh,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rows = page_.Select(r => new ProductStockRow(
|
||||
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.SubCategory, r.Uom,
|
||||
r.SalePrice, r.CostPrice, r.ReorderLevel,
|
||||
r.Wh, r.Veh, r.Wh + r.Veh, (r.Wh + r.Veh) * (r.CostPrice ?? 0m),
|
||||
r.ReorderLevel != null && r.Wh <= r.ReorderLevel,
|
||||
r.IsActive)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
||||
{
|
||||
var lines =
|
||||
from li in db.ShopPOItems.AsNoTracking()
|
||||
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||
where inv.Status == InvoiceStatus.Finalized
|
||||
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
select li;
|
||||
|
||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
lines = lines.Where(l => l.Item.Name.Contains(s) || l.Item.Sku.Contains(s));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
public async Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var query = SoldLines(f)
|
||||
.GroupBy(l => new { l.FK_ItemId, l.Item.Sku, l.Item.Name, l.Item.CostPrice })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
||||
});
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||
var storePairs = await SoldLines(f)
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
||||
|
||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||
{
|
||||
var ids = itemIds.ToList();
|
||||
return await SoldLines(f with { Search = null })
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.GroupBy(l => l.FK_ItemId)
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||
}
|
||||
|
||||
public Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f)
|
||||
{
|
||||
var q = db.SaleReturnItems.AsNoTracking().AsQueryable();
|
||||
if (f.From.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt >= f.From.Value);
|
||||
if (f.To.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt < f.To.Value.Date.AddDays(1));
|
||||
if (f.BrandId.HasValue) q = q.Where(r => r.Item.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) q = q.Where(r => r.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (f.StoreId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
q = q.Where(r => r.Item.Name.Contains(s) || r.Item.Sku.Contains(s));
|
||||
}
|
||||
|
||||
return q.GroupBy(r => new { r.FK_ItemId, r.Item.Sku, r.Item.Name, r.ReasonCode })
|
||||
.Select(g => new ReturnAggregate(g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.ReasonCode,
|
||||
g.Sum(x => x.Quantity), g.Sum(x => x.Quantity * x.UnitPrice)))
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Distribution.Features.Stock;
|
||||
|
||||
namespace Distribution.Features.ProductReport;
|
||||
|
||||
public class ProductReportService(IProductReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
|
||||
public static string? Validate(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
|
||||
if (f.From.HasValue && f.To.HasValue)
|
||||
{
|
||||
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||
if ((f.To.Value - f.From.Value).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ProductStockRow>> GetStockAsync(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var (rows, total) = await repo.GetStockAsync(f, page, pageSize);
|
||||
return new PagedResponse<ProductStockRow>(page, pageSize, total, rows);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ProductSalesRow>> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var (rows, total) = await repo.GetSalesAsync(f, page, pageSize);
|
||||
var mapped = rows.Select(r =>
|
||||
{
|
||||
decimal? cost = r.CostPrice.HasValue ? r.CostPrice.Value * r.QtyFulfilled : null;
|
||||
decimal? margin = cost.HasValue ? r.SalesValue - cost.Value : null;
|
||||
decimal? pct = margin.HasValue && r.SalesValue != 0 ? Math.Round(margin.Value / r.SalesValue * 100, 2) : null;
|
||||
return new ProductSalesRow(r.ItemId, r.Sku, r.Name, r.Orders, r.Stores, r.QtyOrdered, r.QtyFulfilled,
|
||||
r.SalesValue, cost, margin, pct);
|
||||
}).ToList();
|
||||
return new PagedResponse<ProductSalesRow>(page, pageSize, total, mapped);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ProductReturnRow>> GetReturnsAsync(ProductReportFilter f, int page, int pageSize)
|
||||
{
|
||||
var agg = await repo.GetReturnsAsync(f);
|
||||
var grouped = agg.GroupBy(a => new { a.ItemId, a.Sku, a.Name })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key,
|
||||
Qty = g.Sum(x => x.Qty),
|
||||
Value = g.Sum(x => x.Value),
|
||||
ByReason = g.ToDictionary(x => x.Reason, x => x.Qty),
|
||||
})
|
||||
.OrderByDescending(x => x.Value).ThenBy(x => x.Key.Name)
|
||||
.ToList();
|
||||
|
||||
var pageRows = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||
var sold = await repo.GetSoldQtyAsync(f, pageRows.Select(r => r.Key.ItemId));
|
||||
|
||||
var rows = pageRows.Select(r =>
|
||||
{
|
||||
var soldQty = sold.GetValueOrDefault(r.Key.ItemId);
|
||||
decimal? pct = soldQty > 0 ? Math.Round(r.Qty / soldQty * 100, 2) : null;
|
||||
return new ProductReturnRow(r.Key.ItemId, r.Key.Sku, r.Key.Name, r.Qty, r.Value, soldQty, pct, r.ByReason);
|
||||
}).ToList();
|
||||
return new PagedResponse<ProductReturnRow>(page, pageSize, grouped.Count, rows);
|
||||
}
|
||||
|
||||
public async Task<ProductReportSummary> GetSummaryAsync(ProductReportFilter f)
|
||||
{
|
||||
var (rows, total) = await repo.GetStockAsync(f, 1, int.MaxValue);
|
||||
var totalSales = await repo.GetTotalSalesAsync(f);
|
||||
return new ProductReportSummary(total, rows.Sum(r => r.TotalQty), rows.Sum(r => r.StockValue), totalSales);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@ public interface IRegionRepository
|
||||
{
|
||||
Task<Models.Region?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Region>> GetAllAsync();
|
||||
Task<List<string>> GetAllCodesAsync();
|
||||
Task<bool> CodeExistsAsync(string code, Guid? excludeId = null);
|
||||
|
||||
Task<Models.User?> GetUserWithRoleAsync(Guid userId);
|
||||
Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId);
|
||||
Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId);
|
||||
Task AddRegionScopeAsync(Models.UserRegionScope scope);
|
||||
void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes);
|
||||
Task AddAsync(Models.Region entity);
|
||||
void Update(Models.Region entity);
|
||||
void Remove(Models.Region entity);
|
||||
|
||||
@@ -10,6 +10,11 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
// Suggested code for the Add Region form; the user may override it.
|
||||
[HttpGet("next-code")]
|
||||
public async Task<IActionResult> GetNextCode() =>
|
||||
Ok(await service.GetNextCodeAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
@@ -38,6 +43,34 @@ public class RegionController(RegionService service) : ControllerBase
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/manager")]
|
||||
public async Task<IActionResult> GetManager(Guid id)
|
||||
{
|
||||
var result = await service.GetManagerAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/manager")]
|
||||
public async Task<IActionResult> SetManager(Guid id, SetRegionManagerRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetManagerAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/manager")]
|
||||
public async Task<IActionResult> RemoveManager(Guid id, CancellationToken ct)
|
||||
{
|
||||
var removed = await service.RemoveManagerAsync(id, ct);
|
||||
return removed ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
//targets/budgets/agencies/tax-rule.
|
||||
[HttpPut("{id:guid}/manage")]
|
||||
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
public record CreateRegionRequest(string Name);
|
||||
// Code is optional: blank/null auto-generates the next REG-#### code; otherwise stored as entered (trimmed).
|
||||
public record CreateRegionRequest(string Name, string? Code = null);
|
||||
|
||||
public record UpdateRegionRequest(string Name);
|
||||
public record NextRegionCodeResponse(string Code);
|
||||
|
||||
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||
// Code is auto-generated on create; on update it is optional (null/blank keeps the current code).
|
||||
public record UpdateRegionRequest(string Name, string? Code = null);
|
||||
|
||||
public record RegionResponse(Guid Id, string Code, string Name, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||
|
||||
@@ -61,3 +65,9 @@ public record RegionManageEntryResponse(
|
||||
string? RuleConfig,
|
||||
Guid? AgencyUserId,
|
||||
DateTime CreatedAt);
|
||||
|
||||
// Region manager = the single active user with the RegionalManager role scoped to the region
|
||||
// (UserRegionScope). Users themselves are owned by the auth service; this only links one to a region.
|
||||
public record SetRegionManagerRequest(Guid UserId);
|
||||
|
||||
public record RegionManagerResponse(Guid RegionId, Guid UserId, string Username, string FullName);
|
||||
|
||||
@@ -11,6 +11,32 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
public async Task<List<Models.Region>> GetAllAsync() =>
|
||||
await context.Regions.ToListAsync();
|
||||
|
||||
// Includes soft-deleted regions: the unique index on Code doesn't know about IsActive.
|
||||
public async Task<List<string>> GetAllCodesAsync() =>
|
||||
await context.Regions.Select(r => r.Code).ToListAsync();
|
||||
|
||||
public async Task<bool> CodeExistsAsync(string code, Guid? excludeId = null) =>
|
||||
await context.Regions.AnyAsync(r => r.Code == code && (excludeId == null || r.Id != excludeId));
|
||||
|
||||
public async Task<Models.User?> GetUserWithRoleAsync(Guid userId) =>
|
||||
await context.Users.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
public async Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId) =>
|
||||
await context.UserRegionScopes
|
||||
.Include(s => s.User).ThenInclude(u => u.Role)
|
||||
.Where(s => s.FK_RegionId == regionId && s.User.IsActive && s.User.Role.Name == "RegionalManager")
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId) =>
|
||||
await context.UserRegionScopes.FirstOrDefaultAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId);
|
||||
|
||||
public async Task AddRegionScopeAsync(Models.UserRegionScope scope) =>
|
||||
await context.UserRegionScopes.AddAsync(scope);
|
||||
|
||||
// Scope rows are hard-deleted (composite key user+region), same as UserRepository.ReplaceRegionScopesAsync.
|
||||
public void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes) =>
|
||||
context.UserRegionScopes.RemoveRange(scopes);
|
||||
|
||||
public async Task AddAsync(Models.Region entity) =>
|
||||
await context.Regions.AddAsync(entity);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Distribution.Features.Region;
|
||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
{
|
||||
private const int RegionalAgencyRoleLevel = 60;
|
||||
private const string CodePrefix = "REG-";
|
||||
|
||||
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
||||
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
|
||||
@@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
.Select(r => new RegionResponse(r.Id, r.Name, r.IsActive, r.CreatedAt))
|
||||
.Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
public async Task<RegionResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(id);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
|
||||
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
||||
}
|
||||
|
||||
// Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused).
|
||||
public async Task<NextRegionCodeResponse> GetNextCodeAsync() => new(await GenerateCodeAsync());
|
||||
|
||||
private async Task<string> GenerateCodeAsync()
|
||||
{
|
||||
var max = (await repo.GetAllCodesAsync())
|
||||
.Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(c[CodePrefix.Length..], out _))
|
||||
.Select(c => int.Parse(c[CodePrefix.Length..]))
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return $"{CodePrefix}{max + 1:D4}";
|
||||
}
|
||||
|
||||
public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = new Models.Region { Name = request.Name };
|
||||
string code;
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
code = await GenerateCodeAsync();
|
||||
else
|
||||
{
|
||||
code = request.Code.Trim();
|
||||
if (await repo.CodeExistsAsync(code))
|
||||
throw new InvalidOperationException($"Region code '{code}' is already in use.");
|
||||
}
|
||||
var region = new Models.Region { Name = request.Name, Code = code };
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
|
||||
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
|
||||
@@ -46,6 +70,13 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
var region = await repo.GetByIdAsync(id);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
region.Name = request.Name;
|
||||
if (!string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
var code = request.Code.Trim();
|
||||
if (code != region.Code && await repo.CodeExistsAsync(code, id))
|
||||
throw new InvalidOperationException($"Region code '{code}' is already in use.");
|
||||
region.Code = code;
|
||||
}
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
@@ -57,7 +88,68 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
|
||||
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
||||
}
|
||||
|
||||
private const string RegionalManagerRole = "RegionalManager";
|
||||
|
||||
public async Task<RegionManagerResponse?> GetManagerAsync(Guid regionId)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault();
|
||||
return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName);
|
||||
}
|
||||
|
||||
// A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it.
|
||||
// Returns null when the region does not exist; throws InvalidOperationException for an invalid user.
|
||||
public async Task<RegionManagerResponse?> SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
var user = await repo.GetUserWithRoleAsync(request.UserId);
|
||||
if (user is null || !user.IsActive)
|
||||
throw new InvalidOperationException("User not found.");
|
||||
if (user.Role?.Name != RegionalManagerRole)
|
||||
throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region.");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
var existing = await repo.GetManagerScopesAsync(regionId);
|
||||
repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id));
|
||||
if (await repo.GetRegionScopeAsync(user.Id, regionId) is null)
|
||||
await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId });
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName);
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveManagerAsync(Guid regionId, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return false;
|
||||
var existing = await repo.GetManagerScopesAsync(regionId);
|
||||
if (existing.Count == 0) return false;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.RemoveRegionScopes(existing);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
|
||||
@@ -13,6 +13,8 @@ public record RideResponse(
|
||||
int Meterend,
|
||||
DateTime? StartedAt,
|
||||
DateTime? ClosedAt,
|
||||
CancelRequestType? CancelTypeCode,
|
||||
string? CancelReason,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record CreateRideRequest(
|
||||
|
||||
@@ -109,6 +109,8 @@ public class RideService(
|
||||
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
|
||||
|
||||
ride.Status = RideStatus.Cancelled;
|
||||
ride.CancelTypeCode = request.TypeCode;
|
||||
ride.CancelReason = request.Reason;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
|
||||
@@ -149,5 +151,5 @@ public class RideService(
|
||||
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
|
||||
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
|
||||
r.Meterstart, r.Meterend,
|
||||
r.StartedAt, r.ClosedAt, r.CreatedAt);
|
||||
r.StartedAt, r.ClosedAt, r.CancelTypeCode, r.CancelReason, r.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace Distribution.Features.Route;
|
||||
|
||||
public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
|
||||
public record CreateRouteRequest(Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length);
|
||||
|
||||
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
|
||||
public record UpdateRouteRequest(Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length, bool? IsActive = null);
|
||||
|
||||
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
|
||||
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record AssignRouteToRegionRequest(Guid RegionId);
|
||||
|
||||
|
||||
@@ -10,8 +10,12 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
|
||||
|
||||
private async Task<bool> CanManageRouteAsync(Guid routeId)
|
||||
{
|
||||
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
|
||||
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
|
||||
// TEMPORARY: role-floor and route-scope checks bypassed so routes can be assigned
|
||||
// while auth doesn't resolve the current user (testbug.md #28). Restore the original:
|
||||
// if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
|
||||
// return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
|
||||
await Task.CompletedTask;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
|
||||
@@ -36,7 +40,6 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
|
||||
|
||||
public async Task<List<RouteResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
@@ -74,12 +77,14 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
|
||||
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var route = await repo.GetByIdAsync(id);
|
||||
if (route is null || !route.IsActive) return null;
|
||||
// Inactive routes stay editable so they can be reactivated.
|
||||
if (route is null) return null;
|
||||
route.FK_RegionId = request.RegionId;
|
||||
route.RouteName = request.RouteName;
|
||||
route.StartPoint = request.StartPoint;
|
||||
route.EndPoint = request.EndPoint;
|
||||
route.Length = request.Length;
|
||||
if (request.IsActive.HasValue) route.IsActive = request.IsActive.Value;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -5,11 +5,12 @@ namespace Distribution.Features.ShopPO;
|
||||
public interface IShopPORepository
|
||||
{
|
||||
Task<Models.ShopPO?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed);
|
||||
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status);
|
||||
Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId);
|
||||
Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId);
|
||||
Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId);
|
||||
Task<Dictionary<Guid, (string Name, string Role)>> GetUserInfoAsync(IEnumerable<Guid> userIds);
|
||||
Task AddAsync(Models.ShopPO entity);
|
||||
void Update(Models.ShopPO entity);
|
||||
void Remove(Models.ShopPO entity);
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ShopPO;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/shop-pos")]
|
||||
public class ShopPOController(ShopPOService service) : ControllerBase
|
||||
public class ShopPOController(ShopPOService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? agentId,
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? regionId,
|
||||
[FromQuery] Guid? shopId,
|
||||
[FromQuery] bool? confirmed,
|
||||
[FromQuery] ShopPOStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
|
||||
var result = await service.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -25,4 +29,79 @@ public class ShopPOController(ShopPOService service) : ControllerBase
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.RaisedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "RaisedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/items")]
|
||||
public async Task<IActionResult> UpdateItems(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateItemsAsync(id, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm")]
|
||||
public async Task<IActionResult> Confirm(Guid id, ConfirmShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmAsync(id, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/confirm-and-create-invoice")]
|
||||
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, ConfirmShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmAndCreateInvoiceAsync(id, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancelShopPORequest request, CancellationToken ct)
|
||||
{
|
||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||
var userId = currentUser.UserId ?? request.CancelledByUserId;
|
||||
if (userId is null) return BadRequest(new { error = "CancelledByUserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.CancelAsync(id, userId.Value, request.Reason, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Distribution.Features.ShopPO;
|
||||
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
|
||||
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
|
||||
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
|
||||
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
|
||||
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
|
||||
|
||||
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
|
||||
|
||||
@@ -12,6 +14,8 @@ public record ShopPOResponse(
|
||||
Guid Id,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
string? StoreAddress,
|
||||
string? StoreContactNumber,
|
||||
Guid RaisedById,
|
||||
Guid? InvoiceId,
|
||||
decimal TotalAmount,
|
||||
@@ -21,4 +25,13 @@ public record ShopPOResponse(
|
||||
DateTime? FulfilledAt,
|
||||
List<ShopPOItemResponse> Items,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
DateTime CreatedAt,
|
||||
string? RaisedByName = null,
|
||||
string? RaisedByRole = null,
|
||||
string? ConfirmedByName = null,
|
||||
string? ConfirmedByRole = null,
|
||||
Guid? CancelledBy = null,
|
||||
DateTime? CancelledAt = null,
|
||||
string? CancelReason = null,
|
||||
string? CancelledByName = null,
|
||||
string? CancelledByRole = null);
|
||||
|
||||
@@ -13,27 +13,32 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository
|
||||
.ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
||||
|
||||
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) =>
|
||||
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize) =>
|
||||
await context.ShopPOs
|
||||
.Include(p => p.Store)
|
||||
.Include(p => p.Store).ThenInclude(s => s.Route)
|
||||
.Include(p => p.Items.Where(i => i.IsActive)).ThenInclude(i => i.Item)
|
||||
.Where(p => p.IsActive
|
||||
&& (agentId == null || p.RaisedById == agentId)
|
||||
&& (routeId == null || p.Store.FK_RouteId == routeId)
|
||||
&& (regionId == null || p.Store.Route.FK_RegionId == regionId)
|
||||
&& (shopId == null || p.FK_StoreId == shopId)
|
||||
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)))
|
||||
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))
|
||||
&& (status == null || p.Status == status))
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) =>
|
||||
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status) =>
|
||||
await context.ShopPOs
|
||||
.Include(p => p.Store)
|
||||
.Include(p => p.Store).ThenInclude(s => s.Route)
|
||||
.CountAsync(p => p.IsActive
|
||||
&& (agentId == null || p.RaisedById == agentId)
|
||||
&& (routeId == null || p.Store.FK_RouteId == routeId)
|
||||
&& (regionId == null || p.Store.Route.FK_RegionId == regionId)
|
||||
&& (shopId == null || p.FK_StoreId == shopId)
|
||||
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)));
|
||||
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))
|
||||
&& (status == null || p.Status == status));
|
||||
|
||||
public async Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) =>
|
||||
await context.ShopPOs
|
||||
@@ -59,6 +64,16 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository
|
||||
.Where(p => p.FK_StoreId == storeId && p.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<Dictionary<Guid, (string Name, string Role)>> GetUserInfoAsync(IEnumerable<Guid> userIds)
|
||||
{
|
||||
var ids = userIds.Distinct().ToList();
|
||||
var users = await context.Users
|
||||
.Where(u => ids.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, RoleName = u.Role.Name })
|
||||
.ToListAsync();
|
||||
return users.ToDictionary(u => u.Id, u => (u.FullName, u.RoleName));
|
||||
}
|
||||
|
||||
public async Task AddAsync(Models.ShopPO entity) =>
|
||||
await context.ShopPOs.AddAsync(entity);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -6,27 +8,229 @@ namespace Distribution.Features.ShopPO;
|
||||
|
||||
public class ShopPOService(
|
||||
IShopPORepository repo,
|
||||
IInvoiceRepository invoiceRepo,
|
||||
PriceListService priceListService,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p) =>
|
||||
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
|
||||
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
p.Items.Where(i => i.IsActive).Select(i =>
|
||||
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
|
||||
p.IsActive, p.CreatedAt);
|
||||
p.IsActive, p.CreatedAt,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
|
||||
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
|
||||
users != null && p.ConfirmedBy is Guid cb && users.TryGetValue(cb, out var c) ? c.Name : null,
|
||||
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
|
||||
p.CancelledBy, p.CancelledAt, p.CancelReason,
|
||||
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
|
||||
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
|
||||
|
||||
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)
|
||||
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
|
||||
{
|
||||
var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed);
|
||||
var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
|
||||
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
yield return p.RaisedById;
|
||||
if (p.ConfirmedBy is Guid cb) yield return cb;
|
||||
if (p.CancelledBy is Guid xb) yield return xb;
|
||||
}
|
||||
|
||||
private async Task<ShopPOResponse> ToResponseWithUserAsync(Models.ShopPO p) =>
|
||||
ToResponse(p, await repo.GetUserInfoAsync(UserIds(p)));
|
||||
|
||||
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(agentId, routeId, regionId, shopId, confirmed, status);
|
||||
var items = await repo.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
|
||||
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
|
||||
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(p => ToResponse(p, users)).ToList());
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id);
|
||||
return po is null ? null : ToResponse(po);
|
||||
if (po is null) return null;
|
||||
var users = await repo.GetUserInfoAsync(UserIds(po));
|
||||
return ToResponse(po, users);
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
|
||||
{
|
||||
if (request.Items.Count == 0)
|
||||
throw new InvalidOperationException("At least one item is required");
|
||||
|
||||
var items = new List<ShopPOItem>();
|
||||
decimal total = 0;
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
items.Add(new ShopPOItem
|
||||
{
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
}
|
||||
|
||||
var po = new Models.ShopPO
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
RaisedById = raisedById,
|
||||
TotalAmount = total,
|
||||
Status = ShopPOStatus.Pending
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(po);
|
||||
foreach (var item in items) item.FK_ShopPOId = po.Id;
|
||||
await repo.AddItemsAsync(items);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(po.Id);
|
||||
return await ToResponseWithUserAsync(saved!);
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot edit a cancelled Shop PO");
|
||||
|
||||
if (po.ConfirmedAt is not null)
|
||||
throw new InvalidOperationException("Items can only be changed before the Shop PO is confirmed");
|
||||
|
||||
if (request.Items.Count == 0)
|
||||
throw new InvalidOperationException("At least one item is required");
|
||||
|
||||
var newItems = new List<ShopPOItem>();
|
||||
decimal total = 0;
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var unitPrice = line.UnitPrice
|
||||
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
|
||||
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
|
||||
|
||||
newItems.Add(new ShopPOItem
|
||||
{
|
||||
FK_ShopPOId = id,
|
||||
FK_ItemId = line.ItemId,
|
||||
Quantity = line.Quantity,
|
||||
QuantityFulfilled = 0,
|
||||
UnitPrice = unitPrice
|
||||
});
|
||||
total += line.Quantity * unitPrice;
|
||||
}
|
||||
|
||||
po.TotalAmount = total;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.ReplaceItemsAsync(id, newItems);
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(id);
|
||||
return await ToResponseWithUserAsync(saved!);
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> ConfirmAsync(Guid id, Guid confirmedBy, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.ConfirmedAt is not null)
|
||||
throw new InvalidOperationException("Shop PO is already confirmed");
|
||||
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
|
||||
|
||||
po.ConfirmedAt = DateTime.Now;
|
||||
po.ConfirmedBy = confirmedBy;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> ConfirmAndCreateInvoiceAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.ConfirmedAt is not null)
|
||||
throw new InvalidOperationException("Shop PO is already confirmed");
|
||||
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
|
||||
|
||||
po.ConfirmedAt = DateTime.Now;
|
||||
po.ConfirmedBy = userId;
|
||||
|
||||
var invoice = new Models.Invoice
|
||||
{
|
||||
FK_ShopPOId = po.Id,
|
||||
Status = InvoiceStatus.Draft,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
CreatedBy = userId,
|
||||
TotalAmount = po.TotalAmount
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await invoiceRepo.AddAsync(invoice);
|
||||
po.InvoiceId = invoice.Id;
|
||||
repo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(id);
|
||||
return await ToResponseWithUserAsync(saved!);
|
||||
}
|
||||
|
||||
public async Task<ShopPOResponse> CancelAsync(Guid id, Guid cancelledBy, string reason, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
throw new InvalidOperationException("A cancellation reason is required");
|
||||
|
||||
var po = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Shop PO not found");
|
||||
|
||||
if (po.Status == ShopPOStatus.Cancelled)
|
||||
throw new InvalidOperationException("Shop PO is already cancelled");
|
||||
|
||||
if (po.Status is ShopPOStatus.PartiallyFulfilled or ShopPOStatus.Fulfilled)
|
||||
throw new InvalidOperationException($"Cannot cancel a Shop PO in {po.Status} status");
|
||||
|
||||
po.Status = ShopPOStatus.Cancelled;
|
||||
po.CancelledAt = DateTime.Now;
|
||||
po.CancelledBy = cancelledBy;
|
||||
po.CancelReason = reason.Trim();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return await ToResponseWithUserAsync(po);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ public interface IStockRepository
|
||||
{
|
||||
// Balance
|
||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
|
||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
|
||||
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
|
||||
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
|
||||
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
||||
Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId);
|
||||
Task<List<StockBalance>> GetLowStockAsync(Guid? locationId);
|
||||
Task AddBalanceAsync(StockBalance balance);
|
||||
void UpdateBalance(StockBalance balance);
|
||||
|
||||
@@ -19,4 +21,14 @@ public interface IStockRepository
|
||||
void UpdateMovement(StockMovement movement);
|
||||
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
|
||||
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
|
||||
|
||||
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
|
||||
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
|
||||
Task AddItemBatchAsync(ItemBatch batch);
|
||||
|
||||
// Serial numbers
|
||||
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
|
||||
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
|
||||
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
|
||||
Task AddItemSerialNumberAsync(ItemSerialNumber serial);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("low-stock")]
|
||||
public async Task<IActionResult> GetLowStock([FromQuery] Guid? locationId)
|
||||
{
|
||||
var result = await service.GetLowStockAsync(locationId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("balances/{itemId}/total")]
|
||||
public async Task<IActionResult> GetItemTotal(Guid itemId)
|
||||
{
|
||||
@@ -30,6 +37,14 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Next free number for auto-generated serials (e.g. after ...-0005 the answer is 6).</summary>
|
||||
[HttpGet("serials/next-sequence")]
|
||||
public async Task<IActionResult> GetNextSerialSequence([FromQuery] Guid itemId, [FromQuery] string prefix = "")
|
||||
{
|
||||
var next = await service.GetNextSerialSequenceAsync(itemId, prefix);
|
||||
return Ok(new { next });
|
||||
}
|
||||
|
||||
[HttpGet("movements")]
|
||||
public async Task<IActionResult> GetMovements(
|
||||
[FromQuery] Guid? locationId,
|
||||
|
||||
@@ -21,6 +21,14 @@ public record StockTotalResponse(
|
||||
decimal TotalQuantity,
|
||||
List<StockBalanceResponse> Breakdown);
|
||||
|
||||
public record LowStockItemResponse(
|
||||
Guid ItemId,
|
||||
string ItemName,
|
||||
Guid LocationId,
|
||||
string LocationName,
|
||||
decimal Quantity,
|
||||
decimal ReorderLevel);
|
||||
|
||||
// Movement
|
||||
public record StockMovementResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -9,7 +9,14 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
||||
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
|
||||
db.StockBalances
|
||||
.Include(b => b.Item)
|
||||
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId);
|
||||
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
|
||||
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
|
||||
|
||||
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) =>
|
||||
db.StockBalances
|
||||
.Include(b => b.Item)
|
||||
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
|
||||
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
|
||||
|
||||
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
|
||||
db.StockBalances
|
||||
@@ -41,6 +48,17 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
||||
.Where(b => b.FK_ItemId == itemId)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<StockBalance>> GetLowStockAsync(Guid? locationId)
|
||||
{
|
||||
var query = db.StockBalances
|
||||
.Include(b => b.Item)
|
||||
.Where(b => b.LocationType == LocationType.Warehouse
|
||||
&& b.Item.ReorderLevel != null
|
||||
&& b.Quantity < b.Item.ReorderLevel);
|
||||
if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value);
|
||||
return query.OrderBy(b => b.Quantity).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task AddBalanceAsync(StockBalance balance) =>
|
||||
await db.StockBalances.AddAsync(balance);
|
||||
|
||||
@@ -79,4 +97,36 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
||||
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
|
||||
return query.CountAsync();
|
||||
}
|
||||
|
||||
public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
|
||||
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
|
||||
|
||||
public async Task AddItemBatchAsync(ItemBatch batch) =>
|
||||
await db.ItemBatches.AddAsync(batch);
|
||||
|
||||
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
|
||||
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
|
||||
|
||||
public async Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix)
|
||||
{
|
||||
// Only serials of the form <prefix><digits> count; anything else (a supplier's own serial that
|
||||
// happens to start with the prefix) is ignored for numbering.
|
||||
var serials = await db.ItemSerialNumbers
|
||||
.AsNoTracking()
|
||||
.Where(s => s.FK_ItemId == itemId && s.SerialNumber.StartsWith(prefix))
|
||||
.Select(s => s.SerialNumber)
|
||||
.ToListAsync();
|
||||
|
||||
var max = 0L;
|
||||
foreach (var serial in serials)
|
||||
{
|
||||
var suffix = serial[prefix.Length..];
|
||||
if (suffix.Length is > 0 and <= 9 && suffix.All(char.IsDigit) && long.TryParse(suffix, out var n))
|
||||
max = Math.Max(max, n);
|
||||
}
|
||||
return (int)max + 1;
|
||||
}
|
||||
|
||||
public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) =>
|
||||
await db.ItemSerialNumbers.AddAsync(serial);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,19 @@ public class StockService(
|
||||
items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
|
||||
}
|
||||
|
||||
public async Task<List<LowStockItemResponse>> GetLowStockAsync(Guid? locationId)
|
||||
{
|
||||
var balances = await repo.GetLowStockAsync(locationId);
|
||||
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
|
||||
return balances.Select(b => new LowStockItemResponse(
|
||||
b.FK_ItemId, b.Item.Name, b.LocationId, names.GetValueOrDefault(b.LocationId, "—"),
|
||||
b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The next unused sequence number for generating serials like "<prefix>0001" for an item.</summary>
|
||||
public Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix) =>
|
||||
repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty);
|
||||
|
||||
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
|
||||
{
|
||||
var item = await itemRepo.GetByIdAsync(itemId);
|
||||
|
||||
@@ -7,8 +7,8 @@ public interface IStoreRepository
|
||||
Task<Models.Store?> GetByIdAsync(Guid id);
|
||||
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
|
||||
Task<List<Models.Store>> GetAllAsync();
|
||||
Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search);
|
||||
Task<List<Models.Store>> GetAllAsync(Guid? routeId, string? search, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? routeId, string? search);
|
||||
Task AddAsync(Models.Store entity);
|
||||
void Update(Models.Store entity);
|
||||
void Remove(Models.Store entity);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
public interface IStoreKycDocumentRepository
|
||||
{
|
||||
Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId);
|
||||
Task AddAsync(Models.StoreKycDocument entity);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
||||
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UploadAsync(storeId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
public record UploadKycDocumentRequest(string DocumentType, IFormFile File);
|
||||
|
||||
public record StoreKycDocumentResponse(Guid Id, Guid StoreId, string DocumentType, string FileReference, DateTime UploadedAt);
|
||||
@@ -1,13 +0,0 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
public class StoreKycDocumentRepository(AppDBContext context) : IStoreKycDocumentRepository
|
||||
{
|
||||
public async Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId) =>
|
||||
await context.StoreKycDocuments.Where(d => d.FK_StoreId == storeId && d.IsActive).ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.StoreKycDocument entity) =>
|
||||
await context.StoreKycDocuments.AddAsync(entity);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.Store.Kyc;
|
||||
|
||||
public class StoreKycDocumentService(
|
||||
IStoreKycDocumentRepository repo,
|
||||
IStoreRepository storeRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser,
|
||||
IScopeAccessService scopeAccess,
|
||||
IWebHostEnvironment env)
|
||||
{
|
||||
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 static StoreKycDocumentResponse ToResponse(Models.StoreKycDocument d) =>
|
||||
new(d.Id, d.FK_StoreId, d.DocumentType, d.FileReference, d.UploadedAt);
|
||||
|
||||
public async Task<List<StoreKycDocumentResponse>?> GetByStoreIdAsync(Guid storeId)
|
||||
{
|
||||
if (!await HasStoreScopeAsync(storeId)) return null;
|
||||
var docs = await repo.GetByStoreIdAsync(storeId);
|
||||
return docs.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<StoreKycDocumentResponse?> UploadAsync(Guid storeId, UploadKycDocumentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var store = await storeRepo.GetByIdAsync(storeId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(storeId)) return null;
|
||||
|
||||
var uploadsRoot = Path.Combine(env.ContentRootPath, "wwwroot", "uploads", "kyc", storeId.ToString());
|
||||
Directory.CreateDirectory(uploadsRoot);
|
||||
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(request.File.FileName)}";
|
||||
var filePath = Path.Combine(uploadsRoot, fileName);
|
||||
await using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await request.File.CopyToAsync(stream, ct);
|
||||
}
|
||||
var fileReference = $"uploads/kyc/{storeId}/{fileName}";
|
||||
|
||||
var doc = new Models.StoreKycDocument
|
||||
{
|
||||
FK_StoreId = storeId,
|
||||
DocumentType = request.DocumentType,
|
||||
FileReference = fileReference,
|
||||
UploadedAt = DateTime.Now
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(doc);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(doc);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,10 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] KycStatus? kycStatus,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20) =>
|
||||
Ok(await service.GetAllAsync(routeId, kycStatus, search, page, pageSize));
|
||||
Ok(await service.GetAllAsync(routeId, search, page, pageSize));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
@@ -65,13 +64,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/kyc/verify")]
|
||||
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.VerifyKycAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/segment")]
|
||||
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -100,13 +92,6 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -4,29 +4,34 @@ namespace Distribution.Features.Store;
|
||||
|
||||
public record CreateStoreRequest(
|
||||
Guid RouteId,
|
||||
string OwnerName,
|
||||
string StoreName,
|
||||
string StoreAddress,
|
||||
decimal Latitude,
|
||||
decimal Longitude,
|
||||
decimal OneTimeAmount,
|
||||
decimal CreditAmount);
|
||||
|
||||
public record UpdateStoreRequest(
|
||||
Guid RouteId,
|
||||
string OwnerName,
|
||||
string? OwnerName,
|
||||
string StoreName,
|
||||
string StoreAddress,
|
||||
decimal Latitude,
|
||||
decimal Longitude,
|
||||
decimal OneTimeAmount,
|
||||
decimal CreditAmount,
|
||||
bool IsActive = true);
|
||||
decimal CreditLimit = 0,
|
||||
string? ContactNumber = null);
|
||||
|
||||
public record UpdateStoreRequest(
|
||||
Guid RouteId,
|
||||
string? OwnerName,
|
||||
string StoreName,
|
||||
string StoreAddress,
|
||||
decimal Latitude,
|
||||
decimal Longitude,
|
||||
decimal OneTimeAmount,
|
||||
decimal CreditAmount,
|
||||
bool IsActive = true,
|
||||
decimal? CreditLimit = null,
|
||||
string? ContactNumber = null);
|
||||
|
||||
public record StoreResponse(
|
||||
Guid Id,
|
||||
Guid RouteId,
|
||||
string OwnerName,
|
||||
string? OwnerName,
|
||||
string? ContactNumber,
|
||||
string StoreName,
|
||||
string StoreAddress,
|
||||
decimal Latitude,
|
||||
@@ -36,7 +41,6 @@ public record StoreResponse(
|
||||
decimal CreditLimit,
|
||||
decimal OutstandingBalance,
|
||||
string? Segment,
|
||||
KycStatus KycStatus,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -44,8 +48,6 @@ public record UpdateCreditLimitRequest(decimal NewLimit, Guid ApproverId);
|
||||
|
||||
public record UpdateOutstandingBalanceRequest(decimal NewBalance);
|
||||
|
||||
public record VerifyKycRequest(KycStatus Status, string? Notes);
|
||||
|
||||
public record ClassifySegmentRequest(string Segment);
|
||||
|
||||
public record OutstandingBalanceResponse(Guid StoreId, decimal CreditLimit, decimal OutstandingBalance, decimal AvailableCredit);
|
||||
@@ -54,8 +56,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
|
||||
|
||||
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
||||
|
||||
public record PatchKycStatusRequest(KycStatus KycStatus);
|
||||
|
||||
public record PatchStoreStatusRequest(bool IsActive);
|
||||
|
||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||
|
||||
@@ -18,23 +18,22 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
|
||||
await context.Stores.ToListAsync();
|
||||
|
||||
private static IQueryable<Models.Store> ApplyFilters(
|
||||
IQueryable<Models.Store> query, Guid? routeId, KycStatus? kycStatus, string? search) =>
|
||||
IQueryable<Models.Store> query, Guid? routeId, string? search) =>
|
||||
query
|
||||
.Where(s => routeId == null || s.FK_RouteId == routeId)
|
||||
.Where(s => kycStatus == null || s.KycStatus == kycStatus)
|
||||
.Where(s => string.IsNullOrWhiteSpace(search)
|
||||
|| s.StoreName.Contains(search)
|
||||
|| s.OwnerName.Contains(search));
|
||||
|| (s.OwnerName != null && s.OwnerName.Contains(search)));
|
||||
|
||||
public async Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize) =>
|
||||
await ApplyFilters(context.Stores, routeId, kycStatus, search)
|
||||
public async Task<List<Models.Store>> GetAllAsync(Guid? routeId, string? search, int page, int pageSize) =>
|
||||
await ApplyFilters(context.Stores, routeId, search)
|
||||
.OrderBy(s => s.StoreName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search) =>
|
||||
await ApplyFilters(context.Stores, routeId, kycStatus, search).CountAsync();
|
||||
public async Task<int> GetCountAsync(Guid? routeId, string? search) =>
|
||||
await ApplyFilters(context.Stores, routeId, search).CountAsync();
|
||||
|
||||
public async Task AddAsync(Models.Store entity) =>
|
||||
await context.Stores.AddAsync(entity);
|
||||
|
||||
@@ -21,16 +21,16 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
}
|
||||
|
||||
private static StoreResponse ToResponse(Models.Store s) =>
|
||||
new(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
|
||||
s.CreditLimit, s.OutstandingBalance, s.Segment, s.KycStatus, s.IsActive, s.CreatedAt);
|
||||
new(s.Id, s.FK_RouteId, s.OwnerName, s.ContactNumber, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
|
||||
s.CreditLimit, s.OutstandingBalance, s.Segment, s.IsActive, s.CreatedAt);
|
||||
|
||||
public async Task<PagedResponse<StoreResponse>> GetAllAsync(
|
||||
Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize)
|
||||
Guid? routeId, string? search, int page, int pageSize)
|
||||
{
|
||||
// Deactivated stores stay visible in the list showing Inactive — deactivate never
|
||||
// removes the row, only marks it (same rule as Orders, see testbug.md #42).
|
||||
var total = await repo.GetCountAsync(routeId, kycStatus, search);
|
||||
var stores = await repo.GetAllAsync(routeId, kycStatus, search, page, pageSize);
|
||||
var total = await repo.GetCountAsync(routeId, search);
|
||||
var stores = await repo.GetAllAsync(routeId, search, page, pageSize);
|
||||
return new PagedResponse<StoreResponse>(page, pageSize, total, stores.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
@@ -48,12 +48,14 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
{
|
||||
FK_RouteId = request.RouteId,
|
||||
OwnerName = request.OwnerName,
|
||||
ContactNumber = request.ContactNumber,
|
||||
StoreName = request.StoreName,
|
||||
StoreAddress = request.StoreAddress,
|
||||
Latitude = request.Latitude,
|
||||
Longitude = request.Longitude,
|
||||
OneTimeAmount = request.OneTimeAmount,
|
||||
CreditAmount = request.CreditAmount
|
||||
CreditAmount = request.CreditAmount,
|
||||
CreditLimit = request.CreditLimit
|
||||
};
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -76,12 +78,14 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
store.FK_RouteId = request.RouteId;
|
||||
store.OwnerName = request.OwnerName;
|
||||
store.ContactNumber = request.ContactNumber;
|
||||
store.StoreName = request.StoreName;
|
||||
store.StoreAddress = request.StoreAddress;
|
||||
store.Latitude = request.Latitude;
|
||||
store.Longitude = request.Longitude;
|
||||
store.OneTimeAmount = request.OneTimeAmount;
|
||||
store.CreditAmount = request.CreditAmount;
|
||||
if (request.CreditLimit is not null) store.CreditLimit = request.CreditLimit.Value;
|
||||
store.IsActive = request.IsActive;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -165,27 +169,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
|
||||
}
|
||||
|
||||
public async Task<StoreResponse?> VerifyKycAsync(Guid id, VerifyKycRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
store.KycStatus = request.Status;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(store);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(store);
|
||||
}
|
||||
|
||||
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
|
||||
@@ -42,10 +42,11 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
|
||||
[HttpPut("{key}")]
|
||||
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
var userId = currentUser.UserId ?? request.UserId;
|
||||
if (userId is null) return BadRequest(new { error = "UserId is required while auth is unavailable" });
|
||||
try
|
||||
{
|
||||
var result = await service.SetAsync(key, request.Value, userId, ct);
|
||||
var result = await service.SetAsync(key, request.Value, userId.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace Distribution.Features.SysConfig;
|
||||
|
||||
public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy);
|
||||
|
||||
public record SetSysConfigRequest(string Value);
|
||||
public record SetSysConfigRequest(string Value, Guid? UserId);
|
||||
|
||||
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
|
||||
|
||||
/// <summary>Which taxes apply to a GRN, whether each must be filled in, and the rate new GRNs start with.</summary>
|
||||
public record GrnTaxSettings(TaxMode VatMode, decimal VatDefaultRate, TaxMode NbtMode, decimal NbtDefaultRate);
|
||||
|
||||
@@ -7,6 +7,30 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
public const string KeyInvoiceCreationMode = "InvoiceCreationMode";
|
||||
|
||||
public const string KeySerialNumberTrackingEnabled = "Feature.SerialNumberTracking";
|
||||
public const string KeyBatchTrackingEnabled = "Feature.BatchTracking";
|
||||
public const string KeyExpiryDateTrackingEnabled = "Feature.ExpiryDateTracking";
|
||||
public const string KeyWarrantyTrackingEnabled = "Feature.WarrantyTracking";
|
||||
public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride";
|
||||
public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays";
|
||||
|
||||
public const string KeyGrnVatMode = "Grn.Tax.Vat.Mode";
|
||||
public const string KeyGrnVatRate = "Grn.Tax.Vat.DefaultRate";
|
||||
public const string KeyGrnNbtMode = "Grn.Tax.Nbt.Mode";
|
||||
public const string KeyGrnNbtRate = "Grn.Tax.Nbt.DefaultRate";
|
||||
|
||||
private static readonly string[] TaxModeKeys = [KeyGrnVatMode, KeyGrnNbtMode];
|
||||
private static readonly string[] TaxRateKeys = [KeyGrnVatRate, KeyGrnNbtRate];
|
||||
|
||||
private static readonly string[] BooleanFeatureKeys =
|
||||
[
|
||||
KeySerialNumberTrackingEnabled,
|
||||
KeyBatchTrackingEnabled,
|
||||
KeyExpiryDateTrackingEnabled,
|
||||
KeyWarrantyTrackingEnabled,
|
||||
KeyCustomWarrantyOverrideEnabled,
|
||||
];
|
||||
|
||||
public async Task<List<SysConfigResponse>> GetAllAsync()
|
||||
{
|
||||
var configs = await repo.GetAllAsync();
|
||||
@@ -55,11 +79,71 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
|
||||
return Enum.TryParse<InvoiceCreationMode>(config.Value, out var mode) ? mode : InvoiceCreationMode.ModeB;
|
||||
}
|
||||
|
||||
public async Task<bool> GetSerialNumberTrackingEnabledAsync() => await GetBoolAsync(KeySerialNumberTrackingEnabled);
|
||||
public async Task<bool> GetBatchTrackingEnabledAsync() => await GetBoolAsync(KeyBatchTrackingEnabled);
|
||||
public async Task<bool> GetExpiryDateTrackingEnabledAsync() => await GetBoolAsync(KeyExpiryDateTrackingEnabled);
|
||||
public async Task<bool> GetWarrantyTrackingEnabledAsync() => await GetBoolAsync(KeyWarrantyTrackingEnabled);
|
||||
public async Task<bool> GetCustomWarrantyOverrideEnabledAsync() => await GetBoolAsync(KeyCustomWarrantyOverrideEnabled);
|
||||
|
||||
public async Task<int> GetDefaultWarrantyPeriodDaysAsync()
|
||||
{
|
||||
var config = await repo.GetByKeyAsync(KeyDefaultWarrantyPeriodDays);
|
||||
return config is not null && int.TryParse(config.Value, out var days) ? days : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GRN tax settings. Unconfigured, VAT is Optional (how GRNs behaved before this setting existed)
|
||||
/// and NBT is Disabled.
|
||||
/// </summary>
|
||||
public async Task<GrnTaxSettings> GetGrnTaxSettingsAsync() => new(
|
||||
await GetTaxModeAsync(KeyGrnVatMode, TaxMode.Optional),
|
||||
await GetRateAsync(KeyGrnVatRate),
|
||||
await GetTaxModeAsync(KeyGrnNbtMode, TaxMode.Disabled),
|
||||
await GetRateAsync(KeyGrnNbtRate));
|
||||
|
||||
private async Task<TaxMode> GetTaxModeAsync(string key, TaxMode fallback)
|
||||
{
|
||||
var config = await repo.GetByKeyAsync(key);
|
||||
return config is not null && Enum.TryParse<TaxMode>(config.Value, out var mode) && Enum.IsDefined(mode)
|
||||
? mode
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private async Task<decimal> GetRateAsync(string key)
|
||||
{
|
||||
var config = await repo.GetByKeyAsync(key);
|
||||
return config is not null
|
||||
&& decimal.TryParse(config.Value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var rate)
|
||||
? rate
|
||||
: 0m;
|
||||
}
|
||||
|
||||
private async Task<bool> GetBoolAsync(string key)
|
||||
{
|
||||
var config = await repo.GetByKeyAsync(key);
|
||||
return config is not null && bool.TryParse(config.Value, out var enabled) && enabled;
|
||||
}
|
||||
|
||||
private static void ValidateValue(string key, string value)
|
||||
{
|
||||
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<InvoiceCreationMode>())}.");
|
||||
|
||||
if (BooleanFeatureKeys.Contains(key) && !bool.TryParse(value, out _))
|
||||
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Allowed values: true, false.");
|
||||
|
||||
if (TaxModeKeys.Contains(key) && (!Enum.TryParse<TaxMode>(value, out var taxMode) || !Enum.IsDefined(taxMode)))
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<TaxMode>())}.");
|
||||
|
||||
if (TaxRateKeys.Contains(key)
|
||||
&& (!decimal.TryParse(value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var rate)
|
||||
|| rate < 0 || rate > 100))
|
||||
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a percentage from 0 to 100.");
|
||||
|
||||
if (key == KeyDefaultWarrantyPeriodDays && (!int.TryParse(value, out var days) || days < 0))
|
||||
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a non-negative integer.");
|
||||
}
|
||||
|
||||
private static SysConfigResponse ToResponse(Models.SysConfig c) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRideCancelReason : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CancelReason",
|
||||
table: "DeliveryRuns",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CancelTypeCode",
|
||||
table: "DeliveryRuns",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "DeliveryRuns",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("31000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "CancelReason", "CancelTypeCode" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "DeliveryRuns",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("31000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "CancelReason", "CancelTypeCode" },
|
||||
values: new object[] { null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelReason",
|
||||
table: "DeliveryRuns");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelTypeCode",
|
||||
table: "DeliveryRuns");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShopPOCancelInfo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CancelReason",
|
||||
table: "ShopPOs",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CancelledAt",
|
||||
table: "ShopPOs",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CancelledBy",
|
||||
table: "ShopPOs",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ShopPOs",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "CancelReason", "CancelledAt", "CancelledBy" },
|
||||
values: new object[] { null, null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ShopPOs",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "CancelReason", "CancelledAt", "CancelledBy" },
|
||||
values: new object[] { null, null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ShopPOs",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("33000000-0000-0000-0000-000000000003"),
|
||||
columns: new[] { "CancelReason", "CancelledAt", "CancelledBy" },
|
||||
values: new object[] { null, null, null });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelReason",
|
||||
table: "ShopPOs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelledAt",
|
||||
table: "ShopPOs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelledBy",
|
||||
table: "ShopPOs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStoreContactNumberAndNullableOwner : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OwnerName",
|
||||
table: "Stores",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ContactNumber",
|
||||
table: "Stores",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000001"),
|
||||
column: "ContactNumber",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000002"),
|
||||
column: "ContactNumber",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000003"),
|
||||
column: "ContactNumber",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000004"),
|
||||
column: "ContactNumber",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000005"),
|
||||
column: "ContactNumber",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000006"),
|
||||
column: "ContactNumber",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ContactNumber",
|
||||
table: "Stores");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "OwnerName",
|
||||
keyValue: null,
|
||||
column: "OwnerName",
|
||||
value: "");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OwnerName",
|
||||
table: "Stores",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext",
|
||||
oldNullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4022
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RouteOptionalFieldsAndStoreContactNumber : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoreKycDocuments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KycStatus",
|
||||
table: "Stores");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "StartPoint",
|
||||
table: "Routes",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "Length",
|
||||
table: "Routes",
|
||||
type: "decimal(65,30)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "EndPoint",
|
||||
table: "Routes",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext")
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "KycStatus",
|
||||
table: "Stores",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Routes",
|
||||
keyColumn: "StartPoint",
|
||||
keyValue: null,
|
||||
column: "StartPoint",
|
||||
value: "");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "StartPoint",
|
||||
table: "Routes",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext",
|
||||
oldNullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AlterColumn<decimal>(
|
||||
name: "Length",
|
||||
table: "Routes",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m,
|
||||
oldClrType: typeof(decimal),
|
||||
oldType: "decimal(65,30)",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Routes",
|
||||
keyColumn: "EndPoint",
|
||||
keyValue: null,
|
||||
column: "EndPoint",
|
||||
value: "");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "EndPoint",
|
||||
table: "Routes",
|
||||
type: "longtext",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "longtext",
|
||||
oldNullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
.OldAnnotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoreKycDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
DocumentType = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
FileReference = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
UploadedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoreKycDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StoreKycDocuments_Stores_FK_StoreId",
|
||||
column: x => x.FK_StoreId,
|
||||
principalTable: "Stores",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "StoreKycDocuments",
|
||||
columns: new[] { "Id", "CreatedAt", "DocumentType", "FK_StoreId", "FileReference", "IsActive", "UploadedAt" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("63000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), "Business Registration", new Guid("26000000-0000-0000-0000-000000000001"), "seed/kyc/fort-mini-mart-brc.pdf", true, new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified) },
|
||||
{ new Guid("63000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), "NIC", new Guid("26000000-0000-0000-0000-000000000003"), "seed/kyc/wattala-owner-nic.pdf", true, new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified) }
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000001"),
|
||||
column: "KycStatus",
|
||||
value: 1);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000002"),
|
||||
column: "KycStatus",
|
||||
value: 1);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000003"),
|
||||
column: "KycStatus",
|
||||
value: 0);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000004"),
|
||||
column: "KycStatus",
|
||||
value: 0);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000005"),
|
||||
column: "KycStatus",
|
||||
value: 1);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Stores",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("26000000-0000-0000-0000-000000000006"),
|
||||
column: "KycStatus",
|
||||
value: 0);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoreKycDocuments_FK_StoreId",
|
||||
table: "StoreKycDocuments",
|
||||
column: "FK_StoreId");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemReorderLevel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ReorderLevel",
|
||||
table: "Items",
|
||||
type: "decimal(65,30)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000001"),
|
||||
column: "ReorderLevel",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
column: "ReorderLevel",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000003"),
|
||||
column: "ReorderLevel",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000004"),
|
||||
column: "ReorderLevel",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReorderLevel",
|
||||
table: "Items");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,505 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemTrackingConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_ItemBatchId",
|
||||
table: "StockMovements",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_ItemSerialNumberId",
|
||||
table: "StockMovements",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_ItemBatchId",
|
||||
table: "StockBalances",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_ItemSerialNumberId",
|
||||
table: "StockBalances",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowCustomWarrantyPeriod",
|
||||
table: "Items",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HasWarranty",
|
||||
table: "Items",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TracksBatches",
|
||||
table: "Items",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TracksExpiryDate",
|
||||
table: "Items",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TracksSerialNumbers",
|
||||
table: "Items",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WarrantyPeriodDays",
|
||||
table: "Items",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ItemBatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
BatchNumber = table.Column<string>(type: "varchar(255)", nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
ManufactureDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ExpiryDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ItemBatches", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ItemBatches_Items_FK_ItemId",
|
||||
column: x => x.FK_ItemId,
|
||||
principalTable: "Items",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ItemSerialNumbers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
SerialNumber = table.Column<string>(type: "varchar(255)", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentLocationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||
CurrentLocationType = table.Column<int>(type: "int", nullable: true),
|
||||
ReceivedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
WarrantyPeriodDaysOverride = table.Column<int>(type: "int", nullable: true),
|
||||
WarrantyExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ItemSerialNumbers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ItemSerialNumbers_Items_FK_ItemId",
|
||||
column: x => x.FK_ItemId,
|
||||
principalTable: "Items",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GoodsReceiptItemBatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_GoodsReceiptItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ItemBatchId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GoodsReceiptItemBatches", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_GoodsReceiptItemBatches_GoodsReceiptItems_FK_GoodsReceiptIte~",
|
||||
column: x => x.FK_GoodsReceiptItemId,
|
||||
principalTable: "GoodsReceiptItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_GoodsReceiptItemBatches_ItemBatches_FK_ItemBatchId",
|
||||
column: x => x.FK_ItemBatchId,
|
||||
principalTable: "ItemBatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GoodsReceiptItemSerials",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_GoodsReceiptItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
FK_ItemSerialNumberId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GoodsReceiptItemSerials", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_GoodsReceiptItemSerials_GoodsReceiptItems_FK_GoodsReceiptIte~",
|
||||
column: x => x.FK_GoodsReceiptItemId,
|
||||
principalTable: "GoodsReceiptItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_GoodsReceiptItemSerials_ItemSerialNumbers_FK_ItemSerialNumbe~",
|
||||
column: x => x.FK_ItemSerialNumberId,
|
||||
principalTable: "ItemSerialNumbers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "AllowCustomWarrantyPeriod", "HasWarranty", "TracksBatches", "TracksExpiryDate", "TracksSerialNumbers", "WarrantyPeriodDays" },
|
||||
values: new object[] { false, false, false, false, false, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "AllowCustomWarrantyPeriod", "HasWarranty", "TracksBatches", "TracksExpiryDate", "TracksSerialNumbers", "WarrantyPeriodDays" },
|
||||
values: new object[] { false, false, false, false, false, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000003"),
|
||||
columns: new[] { "AllowCustomWarrantyPeriod", "HasWarranty", "TracksBatches", "TracksExpiryDate", "TracksSerialNumbers", "WarrantyPeriodDays" },
|
||||
values: new object[] { false, false, false, false, false, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Items",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("23000000-0000-0000-0000-000000000004"),
|
||||
columns: new[] { "AllowCustomWarrantyPeriod", "HasWarranty", "TracksBatches", "TracksExpiryDate", "TracksSerialNumbers", "WarrantyPeriodDays" },
|
||||
values: new object[] { false, false, false, false, false, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "StockBalances",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("61000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "StockBalances",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("61000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "StockBalances",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("61000000-0000-0000-0000-000000000003"),
|
||||
columns: new[] { "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "StockMovements",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("62000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "StockMovements",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("62000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "StockMovements",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("62000000-0000-0000-0000-000000000003"),
|
||||
columns: new[] { "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
values: new object[] { null, null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "SysConfigs",
|
||||
columns: new[] { "Key", "UpdatedAt", "UpdatedBy", "Value" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ "Feature.BatchTracking", new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "false" },
|
||||
{ "Feature.CustomWarrantyOverride", new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "false" },
|
||||
{ "Feature.DefaultWarrantyPeriodDays", new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "0" },
|
||||
{ "Feature.ExpiryDateTracking", new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "false" },
|
||||
{ "Feature.SerialNumberTracking", new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "false" },
|
||||
{ "Feature.WarrantyTracking", new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), null, "false" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockMovements_FK_ItemBatchId",
|
||||
table: "StockMovements",
|
||||
column: "FK_ItemBatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockMovements_FK_ItemSerialNumberId",
|
||||
table: "StockMovements",
|
||||
column: "FK_ItemSerialNumberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockBalances_FK_ItemBatchId",
|
||||
table: "StockBalances",
|
||||
column: "FK_ItemBatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockBalances_FK_ItemId_LocationId_FK_ItemBatchId_FK_ItemSer~",
|
||||
table: "StockBalances",
|
||||
columns: new[] { "FK_ItemId", "LocationId", "FK_ItemBatchId", "FK_ItemSerialNumberId" },
|
||||
unique: true);
|
||||
|
||||
// The old 2-column index must be dropped only after the new composite index exists,
|
||||
// since MySQL won't drop an index that's the only one backing a FK (FK_ItemId here).
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StockBalances_FK_ItemId_LocationId",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockBalances_FK_ItemSerialNumberId",
|
||||
table: "StockBalances",
|
||||
column: "FK_ItemSerialNumberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GoodsReceiptItemBatches_FK_GoodsReceiptItemId",
|
||||
table: "GoodsReceiptItemBatches",
|
||||
column: "FK_GoodsReceiptItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GoodsReceiptItemBatches_FK_ItemBatchId",
|
||||
table: "GoodsReceiptItemBatches",
|
||||
column: "FK_ItemBatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GoodsReceiptItemSerials_FK_GoodsReceiptItemId",
|
||||
table: "GoodsReceiptItemSerials",
|
||||
column: "FK_GoodsReceiptItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GoodsReceiptItemSerials_FK_ItemSerialNumberId",
|
||||
table: "GoodsReceiptItemSerials",
|
||||
column: "FK_ItemSerialNumberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItemBatches_FK_ItemId_BatchNumber",
|
||||
table: "ItemBatches",
|
||||
columns: new[] { "FK_ItemId", "BatchNumber" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItemSerialNumbers_FK_ItemId_SerialNumber",
|
||||
table: "ItemSerialNumbers",
|
||||
columns: new[] { "FK_ItemId", "SerialNumber" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_StockBalances_ItemBatches_FK_ItemBatchId",
|
||||
table: "StockBalances",
|
||||
column: "FK_ItemBatchId",
|
||||
principalTable: "ItemBatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_StockBalances_ItemSerialNumbers_FK_ItemSerialNumberId",
|
||||
table: "StockBalances",
|
||||
column: "FK_ItemSerialNumberId",
|
||||
principalTable: "ItemSerialNumbers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_StockMovements_ItemBatches_FK_ItemBatchId",
|
||||
table: "StockMovements",
|
||||
column: "FK_ItemBatchId",
|
||||
principalTable: "ItemBatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_StockMovements_ItemSerialNumbers_FK_ItemSerialNumberId",
|
||||
table: "StockMovements",
|
||||
column: "FK_ItemSerialNumberId",
|
||||
principalTable: "ItemSerialNumbers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_StockBalances_ItemBatches_FK_ItemBatchId",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_StockBalances_ItemSerialNumbers_FK_ItemSerialNumberId",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_StockMovements_ItemBatches_FK_ItemBatchId",
|
||||
table: "StockMovements");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_StockMovements_ItemSerialNumbers_FK_ItemSerialNumberId",
|
||||
table: "StockMovements");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "GoodsReceiptItemBatches");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "GoodsReceiptItemSerials");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ItemBatches");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ItemSerialNumbers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StockMovements_FK_ItemBatchId",
|
||||
table: "StockMovements");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StockMovements_FK_ItemSerialNumberId",
|
||||
table: "StockMovements");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StockBalances_FK_ItemBatchId",
|
||||
table: "StockBalances");
|
||||
|
||||
// Recreate the old 2-column index before dropping the composite one, since MySQL
|
||||
// won't drop an index that's the only one backing the FK_ItemId FK constraint.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockBalances_FK_ItemId_LocationId",
|
||||
table: "StockBalances",
|
||||
columns: new[] { "FK_ItemId", "LocationId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StockBalances_FK_ItemId_LocationId_FK_ItemBatchId_FK_ItemSer~",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_StockBalances_FK_ItemSerialNumberId",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SysConfigs",
|
||||
keyColumn: "Key",
|
||||
keyValue: "Feature.BatchTracking");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SysConfigs",
|
||||
keyColumn: "Key",
|
||||
keyValue: "Feature.CustomWarrantyOverride");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SysConfigs",
|
||||
keyColumn: "Key",
|
||||
keyValue: "Feature.DefaultWarrantyPeriodDays");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SysConfigs",
|
||||
keyColumn: "Key",
|
||||
keyValue: "Feature.ExpiryDateTracking");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SysConfigs",
|
||||
keyColumn: "Key",
|
||||
keyValue: "Feature.SerialNumberTracking");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "SysConfigs",
|
||||
keyColumn: "Key",
|
||||
keyValue: "Feature.WarrantyTracking");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_ItemBatchId",
|
||||
table: "StockMovements");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_ItemSerialNumberId",
|
||||
table: "StockMovements");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_ItemBatchId",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_ItemSerialNumberId",
|
||||
table: "StockBalances");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowCustomWarrantyPeriod",
|
||||
table: "Items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HasWarranty",
|
||||
table: "Items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TracksBatches",
|
||||
table: "Items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TracksExpiryDate",
|
||||
table: "Items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TracksSerialNumbers",
|
||||
table: "Items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WarrantyPeriodDays",
|
||||
table: "Items");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDirectGoodsReceipt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_PurchaseOrderId",
|
||||
table: "GoodsReceipts",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)")
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_SupplierId",
|
||||
table: "GoodsReceipts",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_PurchaseOrderItemId",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)")
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "FK_ItemId",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "char(36)",
|
||||
nullable: true,
|
||||
collation: "ascii_general_ci");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
|
||||
column: "FK_ItemId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
|
||||
column: "FK_ItemId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceipts",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
column: "FK_SupplierId",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GoodsReceipts_FK_SupplierId",
|
||||
table: "GoodsReceipts",
|
||||
column: "FK_SupplierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GoodsReceiptItems_FK_ItemId",
|
||||
table: "GoodsReceiptItems",
|
||||
column: "FK_ItemId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GoodsReceiptItems_Items_FK_ItemId",
|
||||
table: "GoodsReceiptItems",
|
||||
column: "FK_ItemId",
|
||||
principalTable: "Items",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GoodsReceipts_Suppliers_FK_SupplierId",
|
||||
table: "GoodsReceipts",
|
||||
column: "FK_SupplierId",
|
||||
principalTable: "Suppliers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_GoodsReceiptItems_Items_FK_ItemId",
|
||||
table: "GoodsReceiptItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_GoodsReceipts_Suppliers_FK_SupplierId",
|
||||
table: "GoodsReceipts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_GoodsReceipts_FK_SupplierId",
|
||||
table: "GoodsReceipts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_GoodsReceiptItems_FK_ItemId",
|
||||
table: "GoodsReceiptItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_SupplierId",
|
||||
table: "GoodsReceipts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FK_ItemId",
|
||||
table: "GoodsReceiptItems");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_PurchaseOrderId",
|
||||
table: "GoodsReceipts",
|
||||
type: "char(36)",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)",
|
||||
oldNullable: true)
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "FK_PurchaseOrderItemId",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "char(36)",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
collation: "ascii_general_ci",
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "char(36)",
|
||||
oldNullable: true)
|
||||
.OldAnnotation("Relational:Collation", "ascii_general_ci");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AppDBContext))]
|
||||
[Migration("20261006100000_AddRegionCode")]
|
||||
public partial class AddRegionCode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Code",
|
||||
table: "Regions",
|
||||
type: "varchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
// Backfill existing rows (seeded regions first) with REG-0001, REG-0002, ... before the unique index.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE `Regions` r
|
||||
JOIN (
|
||||
SELECT `Id`, ROW_NUMBER() OVER (
|
||||
ORDER BY CASE WHEN `Id` LIKE '24000000-%' THEN 0 ELSE 1 END, `CreatedAt`, `Id`) AS rn
|
||||
FROM `Regions`
|
||||
) x ON r.`Id` = x.`Id`
|
||||
SET r.`Code` = CONCAT('REG-', LPAD(x.rn, 4, '0'));");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Regions_Code",
|
||||
table: "Regions",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Regions_Code",
|
||||
table: "Regions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Code",
|
||||
table: "Regions");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnPricing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DiscountPercent",
|
||||
table: "GoodsReceipts",
|
||||
type: "decimal(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "VatPercent",
|
||||
table: "GoodsReceipts",
|
||||
type: "decimal(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DiscountPercent",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "decimal(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "UnitCost",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "VatPercent",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "decimal(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "DiscountPercent", "UnitCost", "VatPercent" },
|
||||
values: new object[] { 0m, 0m, 0m });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
|
||||
columns: new[] { "DiscountPercent", "UnitCost", "VatPercent" },
|
||||
values: new object[] { 0m, 0m, 0m });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceipts",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "DiscountPercent", "VatPercent" },
|
||||
values: new object[] { 0m, 0m });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DiscountPercent",
|
||||
table: "GoodsReceipts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VatPercent",
|
||||
table: "GoodsReceipts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DiscountPercent",
|
||||
table: "GoodsReceiptItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UnitCost",
|
||||
table: "GoodsReceiptItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VatPercent",
|
||||
table: "GoodsReceiptItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnFreeQuantity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FreeQuantity",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
|
||||
column: "FreeQuantity",
|
||||
value: 0);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
|
||||
column: "FreeQuantity",
|
||||
value: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FreeQuantity",
|
||||
table: "GoodsReceiptItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrnNbt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "NbtPercent",
|
||||
table: "GoodsReceipts",
|
||||
type: "decimal(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "NbtPercent",
|
||||
table: "GoodsReceiptItems",
|
||||
type: "decimal(5,2)",
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000001"),
|
||||
column: "NbtPercent",
|
||||
value: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceiptItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("44000000-0000-0000-0000-000000000002"),
|
||||
column: "NbtPercent",
|
||||
value: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GoodsReceipts",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
column: "NbtPercent",
|
||||
value: 0m);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NbtPercent",
|
||||
table: "GoodsReceipts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NbtPercent",
|
||||
table: "GoodsReceiptItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,12 @@ namespace Distribution.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("CancelReason")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int?>("CancelTypeCode")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("ClosedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -421,25 +427,42 @@ namespace Distribution.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_PurchaseOrderId")
|
||||
b.Property<decimal>("DiscountPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<Guid?>("FK_PurchaseOrderId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ReceivedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_SupplierId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_WarehouseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal>("NbtPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<DateTime>("ReceivedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("VatPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_PurchaseOrderId");
|
||||
|
||||
b.HasIndex("FK_SupplierId");
|
||||
|
||||
b.HasIndex("FK_WarehouseId");
|
||||
|
||||
b.ToTable("GoodsReceipts");
|
||||
@@ -449,11 +472,14 @@ namespace Distribution.Migrations
|
||||
{
|
||||
Id = new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DiscountPercent = 0m,
|
||||
FK_PurchaseOrderId = new Guid("41000000-0000-0000-0000-000000000001"),
|
||||
FK_ReceivedByUserId = new Guid("29000000-0000-0000-0000-000000000001"),
|
||||
FK_WarehouseId = new Guid("27000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
ReceivedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified)
|
||||
NbtPercent = 0m,
|
||||
ReceivedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
VatPercent = 0m
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,22 +495,46 @@ namespace Distribution.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("DiscountPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<Guid>("FK_GoodsReceiptId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_PurchaseOrderItemId")
|
||||
b.Property<Guid?>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_PurchaseOrderItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("FreeQuantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal>("NbtPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<int>("RejectedQuantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("UnitCost")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("VatPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_GoodsReceiptId");
|
||||
|
||||
b.HasIndex("FK_ItemId");
|
||||
|
||||
b.HasIndex("FK_PurchaseOrderItemId");
|
||||
|
||||
b.ToTable("GoodsReceiptItems");
|
||||
@@ -495,23 +545,90 @@ namespace Distribution.Migrations
|
||||
Id = new Guid("44000000-0000-0000-0000-000000000001"),
|
||||
AcceptedQuantity = 200,
|
||||
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DiscountPercent = 0m,
|
||||
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000001"),
|
||||
FreeQuantity = 0,
|
||||
IsActive = true,
|
||||
RejectedQuantity = 0
|
||||
NbtPercent = 0m,
|
||||
RejectedQuantity = 0,
|
||||
UnitCost = 0m,
|
||||
VatPercent = 0m
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("44000000-0000-0000-0000-000000000002"),
|
||||
AcceptedQuantity = 98,
|
||||
CreatedAt = new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DiscountPercent = 0m,
|
||||
FK_GoodsReceiptId = new Guid("43000000-0000-0000-0000-000000000001"),
|
||||
FK_PurchaseOrderItemId = new Guid("42000000-0000-0000-0000-000000000002"),
|
||||
FreeQuantity = 0,
|
||||
IsActive = true,
|
||||
RejectedQuantity = 2
|
||||
NbtPercent = 0m,
|
||||
RejectedQuantity = 2,
|
||||
UnitCost = 0m,
|
||||
VatPercent = 0m
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.GoodsReceiptItemBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_GoodsReceiptItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ItemBatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_GoodsReceiptItemId");
|
||||
|
||||
b.HasIndex("FK_ItemBatchId");
|
||||
|
||||
b.ToTable("GoodsReceiptItemBatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.GoodsReceiptItemSerial", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_GoodsReceiptItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ItemSerialNumberId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_GoodsReceiptItemId");
|
||||
|
||||
b.HasIndex("FK_ItemSerialNumberId");
|
||||
|
||||
b.ToTable("GoodsReceiptItemSerials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -594,6 +711,9 @@ namespace Distribution.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("AllowCustomWarrantyPeriod")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal?>("CostPrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -618,6 +738,9 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid?>("FK_SubCategoryId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("HasWarranty")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
@@ -628,6 +751,9 @@ namespace Distribution.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<decimal?>("ReorderLevel")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<decimal?>("SalePrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -638,6 +764,18 @@ namespace Distribution.Migrations
|
||||
b.Property<int>("SyncSource")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("TracksBatches")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("TracksExpiryDate")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("TracksSerialNumbers")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int?>("WarrantyPeriodDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_BaseUomId");
|
||||
@@ -654,61 +792,157 @@ namespace Distribution.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("23000000-0000-0000-0000-000000000001"),
|
||||
AllowCustomWarrantyPeriod = false,
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Description = "Coca-Cola 500ml bottle",
|
||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
|
||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||
HasWarranty = false,
|
||||
IsActive = true,
|
||||
Name = "Coca-Cola 500ml",
|
||||
SalePrice = 60m,
|
||||
Sku = "BEV-COKE-500",
|
||||
SyncSource = 0
|
||||
SyncSource = 0,
|
||||
TracksBatches = false,
|
||||
TracksExpiryDate = false,
|
||||
TracksSerialNumbers = false
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||
AllowCustomWarrantyPeriod = false,
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Description = "Coca-Cola 1.5L bottle",
|
||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
|
||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||
HasWarranty = false,
|
||||
IsActive = true,
|
||||
Name = "Coca-Cola 1.5L",
|
||||
SalePrice = 120m,
|
||||
Sku = "BEV-COKE-1500",
|
||||
SyncSource = 0
|
||||
SyncSource = 0,
|
||||
TracksBatches = false,
|
||||
TracksExpiryDate = false,
|
||||
TracksSerialNumbers = false
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("23000000-0000-0000-0000-000000000003"),
|
||||
AllowCustomWarrantyPeriod = false,
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Description = "Lays Classic Salted 52g pack",
|
||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
|
||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||
HasWarranty = false,
|
||||
IsActive = true,
|
||||
Name = "Lays Classic 52g",
|
||||
SalePrice = 50m,
|
||||
Sku = "SNK-LAYS-52",
|
||||
SyncSource = 0
|
||||
SyncSource = 0,
|
||||
TracksBatches = false,
|
||||
TracksExpiryDate = false,
|
||||
TracksSerialNumbers = false
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("23000000-0000-0000-0000-000000000004"),
|
||||
AllowCustomWarrantyPeriod = false,
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Description = "Lays Family Pack 150g",
|
||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
|
||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||
HasWarranty = false,
|
||||
IsActive = true,
|
||||
Name = "Lays Family Pack 150g",
|
||||
SalePrice = 150m,
|
||||
Sku = "SNK-LAYS-FAM",
|
||||
SyncSource = 0
|
||||
SyncSource = 0,
|
||||
TracksBatches = false,
|
||||
TracksExpiryDate = false,
|
||||
TracksSerialNumbers = false
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.ItemBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("BatchNumber")
|
||||
.HasColumnType("varchar(255)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("ExpiryDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime?>("ManufactureDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_ItemId", "BatchNumber");
|
||||
|
||||
b.ToTable("ItemBatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.ItemSerialNumber", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("CurrentLocationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int?>("CurrentLocationType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime>("ReceivedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("varchar(255)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("WarrantyExpiresAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("WarrantyPeriodDaysOverride")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_ItemId", "SerialNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemSerialNumbers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.LoadingSheet", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1284,6 +1518,11 @@ namespace Distribution.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -1296,12 +1535,16 @@ namespace Distribution.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Regions");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = new Guid("24000000-0000-0000-0000-000000000001"),
|
||||
Code = "REG-0001",
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
IsActive = true,
|
||||
Name = "Western Province"
|
||||
@@ -1309,6 +1552,7 @@ namespace Distribution.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("24000000-0000-0000-0000-000000000002"),
|
||||
Code = "REG-0002",
|
||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
IsActive = true,
|
||||
Name = "Central Province"
|
||||
@@ -1580,7 +1824,6 @@ namespace Distribution.Migrations
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("EndPoint")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid?>("FK_OwnerUserId")
|
||||
@@ -1592,7 +1835,7 @@ namespace Distribution.Migrations
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<decimal>("Length")
|
||||
b.Property<decimal?>("Length")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<string>("RouteName")
|
||||
@@ -1600,7 +1843,6 @@ namespace Distribution.Migrations
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("StartPoint")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
@@ -2056,6 +2298,15 @@ namespace Distribution.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("CancelReason")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime?>("CancelledAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("CancelledBy")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("ConfirmedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -2222,9 +2473,15 @@ namespace Distribution.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_ItemBatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_ItemSerialNumberId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("LocationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -2239,7 +2496,11 @@ namespace Distribution.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_ItemId", "LocationId")
|
||||
b.HasIndex("FK_ItemBatchId");
|
||||
|
||||
b.HasIndex("FK_ItemSerialNumberId");
|
||||
|
||||
b.HasIndex("FK_ItemId", "LocationId", "FK_ItemBatchId", "FK_ItemSerialNumberId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StockBalances");
|
||||
@@ -2286,9 +2547,15 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_ItemBatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("FK_ItemId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FK_ItemSerialNumberId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("FromLocationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -2315,8 +2582,12 @@ namespace Distribution.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_ItemBatchId");
|
||||
|
||||
b.HasIndex("FK_ItemId");
|
||||
|
||||
b.HasIndex("FK_ItemSerialNumberId");
|
||||
|
||||
b.ToTable("StockMovements");
|
||||
|
||||
b.HasData(
|
||||
@@ -2370,6 +2641,9 @@ namespace Distribution.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("ContactNumber")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -2385,9 +2659,6 @@ namespace Distribution.Migrations
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("KycStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Latitude")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
@@ -2401,7 +2672,6 @@ namespace Distribution.Migrations
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<string>("OwnerName")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Segment")
|
||||
@@ -2430,7 +2700,6 @@ namespace Distribution.Migrations
|
||||
CreditLimit = 50000m,
|
||||
FK_RouteId = new Guid("25000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
KycStatus = 1,
|
||||
Latitude = 6.9344m,
|
||||
Longitude = 79.8428m,
|
||||
OneTimeAmount = 0m,
|
||||
@@ -2448,7 +2717,6 @@ namespace Distribution.Migrations
|
||||
CreditLimit = 30000m,
|
||||
FK_RouteId = new Guid("25000000-0000-0000-0000-000000000001"),
|
||||
IsActive = true,
|
||||
KycStatus = 1,
|
||||
Latitude = 6.8508m,
|
||||
Longitude = 79.8654m,
|
||||
OneTimeAmount = 0m,
|
||||
@@ -2466,7 +2734,6 @@ namespace Distribution.Migrations
|
||||
CreditLimit = 40000m,
|
||||
FK_RouteId = new Guid("25000000-0000-0000-0000-000000000002"),
|
||||
IsActive = true,
|
||||
KycStatus = 0,
|
||||
Latitude = 6.9890m,
|
||||
Longitude = 79.8912m,
|
||||
OneTimeAmount = 0m,
|
||||
@@ -2484,7 +2751,6 @@ namespace Distribution.Migrations
|
||||
CreditLimit = 25000m,
|
||||
FK_RouteId = new Guid("25000000-0000-0000-0000-000000000002"),
|
||||
IsActive = true,
|
||||
KycStatus = 0,
|
||||
Latitude = 7.2094m,
|
||||
Longitude = 79.8380m,
|
||||
OneTimeAmount = 0m,
|
||||
@@ -2501,7 +2767,6 @@ namespace Distribution.Migrations
|
||||
CreditLimit = 35000m,
|
||||
FK_RouteId = new Guid("25000000-0000-0000-0000-000000000003"),
|
||||
IsActive = true,
|
||||
KycStatus = 1,
|
||||
Latitude = 7.2906m,
|
||||
Longitude = 80.6337m,
|
||||
OneTimeAmount = 0m,
|
||||
@@ -2519,7 +2784,6 @@ namespace Distribution.Migrations
|
||||
CreditLimit = 20000m,
|
||||
FK_RouteId = new Guid("25000000-0000-0000-0000-000000000004"),
|
||||
IsActive = true,
|
||||
KycStatus = 0,
|
||||
Latitude = 7.4675m,
|
||||
Longitude = 80.6234m,
|
||||
OneTimeAmount = 0m,
|
||||
@@ -2530,61 +2794,6 @@ namespace Distribution.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.StoreKycDocument", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("DocumentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<Guid>("FK_StoreId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("FileReference")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FK_StoreId");
|
||||
|
||||
b.ToTable("StoreKycDocuments");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = new Guid("63000000-0000-0000-0000-000000000001"),
|
||||
CreatedAt = new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DocumentType = "Business Registration",
|
||||
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000001"),
|
||||
FileReference = "seed/kyc/fort-mini-mart-brc.pdf",
|
||||
IsActive = true,
|
||||
UploadedAt = new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified)
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("63000000-0000-0000-0000-000000000002"),
|
||||
CreatedAt = new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DocumentType = "NIC",
|
||||
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000003"),
|
||||
FileReference = "seed/kyc/wattala-owner-nic.pdf",
|
||||
IsActive = true,
|
||||
UploadedAt = new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified)
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2808,6 +3017,42 @@ namespace Distribution.Migrations
|
||||
Key = "InvoiceCreationMode",
|
||||
UpdatedAt = new DateTime(2026, 9, 17, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "ModeB"
|
||||
},
|
||||
new
|
||||
{
|
||||
Key = "Feature.SerialNumberTracking",
|
||||
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Key = "Feature.BatchTracking",
|
||||
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Key = "Feature.ExpiryDateTracking",
|
||||
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Key = "Feature.WarrantyTracking",
|
||||
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Key = "Feature.CustomWarrantyOverride",
|
||||
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Key = "Feature.DefaultWarrantyPeriodDays",
|
||||
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Value = "0"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3301,8 +3546,12 @@ namespace Distribution.Migrations
|
||||
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_PurchaseOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Distribution.Models.Supplier", "Supplier")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_SupplierId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Distribution.Models.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
@@ -3312,6 +3561,8 @@ namespace Distribution.Migrations
|
||||
|
||||
b.Navigation("PurchaseOrder");
|
||||
|
||||
b.Navigation("Supplier");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
@@ -3323,17 +3574,61 @@ namespace Distribution.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_PurchaseOrderItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("GoodsReceipt");
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("PurchaseOrderItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.GoodsReceiptItemBatch", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.GoodsReceiptItem", "GoodsReceiptItem")
|
||||
.WithMany("Batches")
|
||||
.HasForeignKey("FK_GoodsReceiptItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.ItemBatch", "ItemBatch")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemBatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GoodsReceiptItem");
|
||||
|
||||
b.Navigation("ItemBatch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.GoodsReceiptItemSerial", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.GoodsReceiptItem", "GoodsReceiptItem")
|
||||
.WithMany("Serials")
|
||||
.HasForeignKey("FK_GoodsReceiptItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemSerialNumberId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GoodsReceiptItem");
|
||||
|
||||
b.Navigation("ItemSerialNumber");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.ShopPO", "ShopPO")
|
||||
@@ -3376,6 +3671,28 @@ namespace Distribution.Migrations
|
||||
b.Navigation("SubCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.ItemBatch", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.ItemSerialNumber", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.LoadingSheet", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
||||
@@ -3791,24 +4108,52 @@ namespace Distribution.Migrations
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.StockBalance", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.ItemBatch", "ItemBatch")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemBatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemSerialNumberId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("ItemBatch");
|
||||
|
||||
b.Navigation("ItemSerialNumber");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.StockMovement", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.ItemBatch", "ItemBatch")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemBatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Distribution.Models.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_ItemSerialNumberId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("ItemBatch");
|
||||
|
||||
b.Navigation("ItemSerialNumber");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Store", b =>
|
||||
@@ -3822,17 +4167,6 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Route");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.StoreKycDocument", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Store", "Store")
|
||||
.WithMany()
|
||||
.HasForeignKey("FK_StoreId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.SubCategory", b =>
|
||||
{
|
||||
b.HasOne("Distribution.Models.Category", "Category")
|
||||
@@ -4020,6 +4354,13 @@ namespace Distribution.Migrations
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b =>
|
||||
{
|
||||
b.Navigation("Batches");
|
||||
|
||||
b.Navigation("Serials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||
{
|
||||
b.Navigation("Payments");
|
||||
|
||||
@@ -19,5 +19,8 @@ namespace Distribution.Models
|
||||
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
|
||||
public CancelRequestType? CancelTypeCode { get; set; }
|
||||
public string? CancelReason { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Distribution.Models
|
||||
{ Warehouse, Vehicle, External }
|
||||
|
||||
|
||||
public enum KycStatus { Pending, Verified, Rejected }
|
||||
|
||||
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
|
||||
|
||||
@@ -45,4 +44,9 @@ namespace Distribution.Models
|
||||
/// ModeB: Invoice created as a separate explicit step.
|
||||
/// </summary>
|
||||
public enum InvoiceCreationMode { ModeA, ModeB }
|
||||
|
||||
/// <summary>How a tax applies on a document: not at all, may be left at 0, or must be filled in.</summary>
|
||||
public enum TaxMode { Disabled, Optional, Mandatory }
|
||||
|
||||
public enum SerialStatus { InStock, Sold, Returned, Damaged }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class GoodsReceipt : Template
|
||||
{
|
||||
public Guid FK_PurchaseOrderId { get; set; }
|
||||
/// <summary>Null for a direct receipt (goods received without a purchase order).</summary>
|
||||
public Guid? FK_PurchaseOrderId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderId")]
|
||||
public PurchaseOrder PurchaseOrder { get; set; }
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
|
||||
/// <summary>Optional supplier for a direct receipt; for PO receipts the supplier comes from the PO.</summary>
|
||||
public Guid? FK_SupplierId { get; set; }
|
||||
[ForeignKey("FK_SupplierId")]
|
||||
public Supplier? Supplier { get; set; }
|
||||
|
||||
public Guid FK_ReceivedByUserId { get; set; }
|
||||
|
||||
@@ -21,6 +28,18 @@ namespace Distribution.Models
|
||||
|
||||
public DateTime ReceivedAt { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>GRN-wide default discount %, applied to any line that doesn't set its own.</summary>
|
||||
[Precision(5, 2)]
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
/// <summary>GRN-wide default VAT %, applied to any line that doesn't set its own.</summary>
|
||||
[Precision(5, 2)]
|
||||
public decimal VatPercent { get; set; }
|
||||
|
||||
/// <summary>GRN-wide default NBT %, applied to any line that doesn't set its own.</summary>
|
||||
[Precision(5, 2)]
|
||||
public decimal NbtPercent { get; set; }
|
||||
|
||||
public ICollection<GoodsReceiptItem> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
@@ -8,14 +9,45 @@ namespace Distribution.Models
|
||||
[ForeignKey("FK_GoodsReceiptId")]
|
||||
public GoodsReceipt GoodsReceipt { get; set; }
|
||||
|
||||
public Guid FK_PurchaseOrderItemId { get; set; }
|
||||
/// <summary>Null for a direct receipt line (no purchase order); FK_ItemId is set instead.</summary>
|
||||
public Guid? FK_PurchaseOrderItemId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderItemId")]
|
||||
public PurchaseOrderItem PurchaseOrderItem { get; set; }
|
||||
public PurchaseOrderItem? PurchaseOrderItem { get; set; }
|
||||
|
||||
/// <summary>Set for direct receipt lines. PO-based lines resolve the item through PurchaseOrderItem.</summary>
|
||||
public Guid? FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item? Item { get; set; }
|
||||
|
||||
/// <summary>Added to warehouse stock.</summary>
|
||||
public int AcceptedQuantity { get; set; }
|
||||
|
||||
/// <summary>Damaged/short at receipt, not added to stock.</summary>
|
||||
public int RejectedQuantity { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Units the supplier gave at no charge. Received into stock like accepted units, but never
|
||||
/// costed and never counted against the purchase order's ordered quantity.
|
||||
/// </summary>
|
||||
public int FreeQuantity { get; set; } = 0;
|
||||
|
||||
/// <summary>Cost per base-UOM unit. Only accepted units are costed.</summary>
|
||||
[Precision(18, 2)]
|
||||
public decimal UnitCost { get; set; }
|
||||
|
||||
[Precision(5, 2)]
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
[Precision(5, 2)]
|
||||
public decimal VatPercent { get; set; }
|
||||
|
||||
[Precision(5, 2)]
|
||||
public decimal NbtPercent { get; set; }
|
||||
|
||||
/// <summary>Populated when the received item tracks batches and/or expiry dates.</summary>
|
||||
public ICollection<GoodsReceiptItemBatch> Batches { get; set; } = [];
|
||||
|
||||
/// <summary>Populated when the received item tracks serial numbers.</summary>
|
||||
public ICollection<GoodsReceiptItemSerial> Serials { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>One row per batch/expiry-lot split within a single GRN line.</summary>
|
||||
public class GoodsReceiptItemBatch : Template
|
||||
{
|
||||
public Guid FK_GoodsReceiptItemId { get; set; }
|
||||
[ForeignKey("FK_GoodsReceiptItemId")]
|
||||
public GoodsReceiptItem GoodsReceiptItem { get; set; }
|
||||
|
||||
public Guid FK_ItemBatchId { get; set; }
|
||||
[ForeignKey("FK_ItemBatchId")]
|
||||
public ItemBatch ItemBatch { get; set; }
|
||||
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>One row per serial number received within a single GRN line.</summary>
|
||||
public class GoodsReceiptItemSerial : Template
|
||||
{
|
||||
public Guid FK_GoodsReceiptItemId { get; set; }
|
||||
[ForeignKey("FK_GoodsReceiptItemId")]
|
||||
public GoodsReceiptItem GoodsReceiptItem { get; set; }
|
||||
|
||||
public Guid FK_ItemSerialNumberId { get; set; }
|
||||
[ForeignKey("FK_ItemSerialNumberId")]
|
||||
public ItemSerialNumber ItemSerialNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,19 @@ namespace Distribution.Models
|
||||
|
||||
public decimal? SalePrice { get; set; }
|
||||
|
||||
/// <summary>Warehouse quantity threshold below which the item shows up in low-stock alerts; null disables alerting for this item.</summary>
|
||||
public decimal? ReorderLevel { get; set; }
|
||||
|
||||
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
|
||||
public decimal? CostPrice { get; set; }
|
||||
|
||||
public bool TracksSerialNumbers { get; set; } = false;
|
||||
public bool TracksBatches { get; set; } = false;
|
||||
public bool TracksExpiryDate { get; set; } = false;
|
||||
public bool HasWarranty { get; set; } = false;
|
||||
public int? WarrantyPeriodDays { get; set; }
|
||||
public bool AllowCustomWarrantyPeriod { get; set; } = false;
|
||||
|
||||
public SyncSource SyncSource { get; set; } = SyncSource.Manual;
|
||||
public int? ExternalErpItemId { get; set; }
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Backs both batch/lot tracking and expiry-only tracking for an item. BatchNumber is null
|
||||
/// when the item tracks an expiry date but not a batch number (TracksExpiryDate without
|
||||
/// TracksBatches).
|
||||
/// </summary>
|
||||
public class ItemBatch : Template
|
||||
{
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public string? BatchNumber { get; set; }
|
||||
public DateTime? ManufactureDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>One row per physical serial-tracked unit. Quantity is always 1.</summary>
|
||||
public class ItemSerialNumber : Template
|
||||
{
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public string SerialNumber { get; set; }
|
||||
public SerialStatus Status { get; set; } = SerialStatus.InStock;
|
||||
|
||||
public Guid? CurrentLocationId { get; set; }
|
||||
public LocationType? CurrentLocationType { get; set; }
|
||||
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>Snapshotted at receipt when Item.AllowCustomWarrantyPeriod is true; otherwise null and the system default applies when the warranty starts.</summary>
|
||||
public int? WarrantyPeriodDaysOverride { get; set; }
|
||||
|
||||
/// <summary>Set when the serial is sold/delivered: sale date + (override ?? system default). Null while in stock or if Item.HasWarranty is false.</summary>
|
||||
public DateTime? WarrantyExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public class Region : Template
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ namespace Distribution.Models
|
||||
public class Route : Template
|
||||
{
|
||||
public String RouteName { get; set; }
|
||||
public String StartPoint { get; set; }
|
||||
public String EndPoint { get; set; }
|
||||
public decimal Length { get; set; } = 0;
|
||||
public String? StartPoint { get; set; }
|
||||
public String? EndPoint { get; set; }
|
||||
public decimal? Length { get; set; }
|
||||
|
||||
public Guid FK_RegionId { get; set; }
|
||||
[ForeignKey("FK_RegionId")]
|
||||
|
||||
@@ -20,6 +20,10 @@ namespace Distribution.Models
|
||||
|
||||
public DateTime? FulfilledAt { get; set; }
|
||||
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
public Guid? CancelledBy { get; set; }
|
||||
public string? CancelReason { get; set; }
|
||||
|
||||
public ICollection<ShopPOItem> Items { get; set; } = new List<ShopPOItem>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ namespace Distribution.Models
|
||||
public Guid LocationId { get; set; }
|
||||
public LocationType LocationType { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track batches/expiry — no behavior change for existing items.</summary>
|
||||
public Guid? FK_ItemBatchId { get; set; }
|
||||
[ForeignKey("FK_ItemBatchId")]
|
||||
public ItemBatch? ItemBatch { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track serial numbers.</summary>
|
||||
public Guid? FK_ItemSerialNumberId { get; set; }
|
||||
[ForeignKey("FK_ItemSerialNumberId")]
|
||||
public ItemSerialNumber? ItemSerialNumber { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,16 @@ namespace Distribution.Models
|
||||
public Guid? ToLocationId { get; set; }
|
||||
public LocationType? ToLocationType { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track batches/expiry — no behavior change for existing items.</summary>
|
||||
public Guid? FK_ItemBatchId { get; set; }
|
||||
[ForeignKey("FK_ItemBatchId")]
|
||||
public ItemBatch? ItemBatch { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track serial numbers. Quantity is always 1 when set.</summary>
|
||||
public Guid? FK_ItemSerialNumberId { get; set; }
|
||||
[ForeignKey("FK_ItemSerialNumberId")]
|
||||
public ItemSerialNumber? ItemSerialNumber { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public MovementType Type { get; set; }
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace Distribution.Models
|
||||
{
|
||||
public class Store : Template
|
||||
{
|
||||
public String OwnerName { get; set; }
|
||||
public String? OwnerName { get; set; }
|
||||
public String? ContactNumber { get; set; }
|
||||
public String StoreName { get; set; }
|
||||
public String StoreAddress { get; set; }
|
||||
public decimal Latitude { get; set; } = 0;
|
||||
@@ -14,7 +15,6 @@ namespace Distribution.Models
|
||||
public decimal CreditLimit { get; set; } = 0;
|
||||
public decimal OutstandingBalance { get; set; } = 0;
|
||||
public String? Segment { get; set; } //Gold ,silver,key Account,high risk
|
||||
public KycStatus KycStatus { get; set; } = KycStatus.Pending;
|
||||
|
||||
public Guid FK_RouteId { get; set; }
|
||||
[ForeignKey("FK_RouteId")]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class StoreKycDocument : Template
|
||||
{
|
||||
public Guid FK_StoreId { get; set; }
|
||||
[ForeignKey("FK_StoreId")]
|
||||
public Store Store { get; set; }
|
||||
public String DocumentType { get; set; }
|
||||
public String FileReference { get; set; }
|
||||
public DateTime UploadedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user