Merge pull request 'Dev' (#55) from dev into production
Reviewed-on: #55
This commit was merged in pull request #55.
This commit is contained in:
@@ -18,6 +18,7 @@ public class RegionServiceTests
|
|||||||
public RegionServiceTests()
|
public RegionServiceTests()
|
||||||
{
|
{
|
||||||
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||||
|
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||||
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
_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);
|
_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
|
// DeleteAsync
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -195,4 +313,93 @@ public class RegionServiceTests
|
|||||||
|
|
||||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
_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<Uom> Uoms { get; set; }
|
||||||
public DbSet<UomConversion> UomConversions { get; set; }
|
public DbSet<UomConversion> UomConversions { get; set; }
|
||||||
public DbSet<Item> Items { get; set; }
|
public DbSet<Item> Items { get; set; }
|
||||||
|
public DbSet<ItemBatch> ItemBatches { get; set; }
|
||||||
|
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
|
||||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
@@ -47,6 +49,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||||
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { 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<PurchaseReturn> PurchaseReturns { get; set; }
|
||||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||||
public DbSet<SaleReturn> SaleReturns { get; set; }
|
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||||
@@ -83,8 +87,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
|
|
||||||
// --- Unique indexes ---
|
// --- 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>()
|
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();
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<PriceListItem>()
|
modelBuilder.Entity<PriceListItem>()
|
||||||
@@ -292,6 +303,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
|
.HasOne(gr => gr.Supplier)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(gr => gr.FK_SupplierId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceipt>()
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
.HasOne(gr => gr.Warehouse)
|
.HasOne(gr => gr.Warehouse)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -310,12 +328,92 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceiptItem>()
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
.HasIndex(i => i.FK_GoodsReceiptId);
|
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceiptItem>()
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
.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 ---
|
// --- PurchaseReturn ---
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseReturn>()
|
modelBuilder.Entity<PurchaseReturn>()
|
||||||
@@ -446,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
|
|
||||||
// --- Regional ---
|
// --- Regional ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<Region>()
|
||||||
|
.Property(r => r.Code)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Region>()
|
||||||
|
.HasIndex(r => r.Code)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
|
||||||
modelBuilder.Entity<RegionalTarget>()
|
modelBuilder.Entity<RegionalTarget>()
|
||||||
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -478,7 +585,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasMaxLength(100);
|
.HasMaxLength(100);
|
||||||
|
|
||||||
modelBuilder.Entity<SysConfig>().HasData(
|
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);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ public static class MasterDataSeed
|
|||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Region>().HasData(
|
modelBuilder.Entity<Region>().HasData(
|
||||||
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Models.Route>().HasData(
|
modelBuilder.Entity<Models.Route>().HasData(
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
|||||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
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")]
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
namespace Distribution.Features.Grn;
|
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);
|
||||||
|
|
||||||
public record CreateGrnRequest(
|
public record CreateGrnRequest(
|
||||||
Guid WarehouseId,
|
Guid WarehouseId,
|
||||||
@@ -15,21 +28,35 @@ public record CreateFlatGrnRequest(
|
|||||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
Guid? ReceivedByUserId = null);
|
Guid? ReceivedByUserId = 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);
|
||||||
|
|
||||||
|
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
|
||||||
|
|
||||||
public record GrnItemResponse(
|
public record GrnItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderItemId,
|
Guid? PurchaseOrderItemId,
|
||||||
Guid ItemId,
|
Guid ItemId,
|
||||||
string Sku,
|
string Sku,
|
||||||
string ItemName,
|
string ItemName,
|
||||||
int AcceptedQuantity,
|
int AcceptedQuantity,
|
||||||
int RejectedQuantity);
|
int RejectedQuantity,
|
||||||
|
List<GrnBatchLineResponse> Batches,
|
||||||
|
List<string> SerialNumbers);
|
||||||
|
|
||||||
public record GrnResponse(
|
public record GrnResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderId,
|
Guid? PurchaseOrderId,
|
||||||
Guid ReceivedByUserId,
|
Guid ReceivedByUserId,
|
||||||
Guid WarehouseId,
|
Guid WarehouseId,
|
||||||
string WarehouseName,
|
string WarehouseName,
|
||||||
|
Guid? SupplierId,
|
||||||
|
string? SupplierName,
|
||||||
DateTime ReceivedAt,
|
DateTime ReceivedAt,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<GrnItemResponse> Items);
|
List<GrnItemResponse> Items);
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.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);
|
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||||
|
|
||||||
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.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))
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
.OrderByDescending(gr => gr.ReceivedAt)
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.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)
|
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||||
.OrderByDescending(gr => gr.ReceivedAt)
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceiptItems
|
await context.GoodsReceiptItems
|
||||||
.Include(i => i.PurchaseOrderItem)
|
.Include(i => i.PurchaseOrderItem)
|
||||||
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
|
||||||
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
||||||
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,23 @@ public class GrnService(
|
|||||||
IGrnRepository repo,
|
IGrnRepository repo,
|
||||||
IPoRepository poRepo,
|
IPoRepository poRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow,
|
||||||
|
Distribution.Features.Item.IItemRepository itemRepo,
|
||||||
|
Distribution.Features.Supplier.ISupplierRepository supplierRepo)
|
||||||
{
|
{
|
||||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
|
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);
|
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
|
||||||
|
var item = i.Item ?? i.PurchaseOrderItem!.Item;
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
||||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
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, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||||
@@ -36,10 +45,60 @@ public class GrnService(
|
|||||||
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
(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 (line.AcceptedQuantity > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != line.AcceptedQuantity))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Item {item.Sku} tracks serial numbers — expected {line.AcceptedQuantity} 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 (line.AcceptedQuantity > 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) != line.AcceptedQuantity)
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
||||||
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
|
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates
|
||||||
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
|
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
|
||||||
|
/// PartiallyReceived/Received. All in one unit of work.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -64,8 +123,13 @@ public class GrnService(
|
|||||||
ReceivedAt = DateTime.Now
|
ReceivedAt = DateTime.Now
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
||||||
|
|
||||||
foreach (var line in request.Items)
|
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)
|
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.");
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||||
|
|
||||||
@@ -79,14 +143,27 @@ public class GrnService(
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
$"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;
|
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||||
|
|
||||||
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
var entity = new Models.GoodsReceiptItem
|
||||||
{
|
{
|
||||||
FK_PurchaseOrderItemId = poItem.Id,
|
FK_PurchaseOrderItemId = poItem.Id,
|
||||||
AcceptedQuantity = line.AcceptedQuantity,
|
AcceptedQuantity = line.AcceptedQuantity,
|
||||||
RejectedQuantity = line.RejectedQuantity
|
RejectedQuantity = line.RejectedQuantity
|
||||||
});
|
};
|
||||||
|
goodsReceipt.Items.Add(entity);
|
||||||
|
grLines.Add((entity, line, poItem.Item));
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||||
@@ -94,22 +171,203 @@ public class GrnService(
|
|||||||
? PurchaseOrderStatus.Received
|
? PurchaseOrderStatus.Received
|
||||||
: PurchaseOrderStatus.PartiallyReceived;
|
: 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 goodsReceipt = new Models.GoodsReceipt
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = null,
|
||||||
|
FK_SupplierId = request.SupplierId,
|
||||||
|
FK_ReceivedByUserId = receivedByUserId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
ReceivedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity + line.RejectedQuantity == 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,
|
||||||
|
AcceptedQuantity = line.AcceptedQuantity,
|
||||||
|
RejectedQuantity = line.RejectedQuantity
|
||||||
|
};
|
||||||
|
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);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await repo.AddAsync(goodsReceipt);
|
await repo.AddAsync(goodsReceipt);
|
||||||
|
|
||||||
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
foreach (var (entity, line, item) in grLines.Where(x => x.Line.AcceptedQuantity > 0))
|
||||||
{
|
{
|
||||||
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
|
if (item.TracksSerialNumbers)
|
||||||
|
{
|
||||||
|
foreach (var serialNumber in line.SerialNumbers!)
|
||||||
|
{
|
||||||
|
var serial = new ItemSerialNumber
|
||||||
|
{
|
||||||
|
FK_ItemId = item.Id,
|
||||||
|
SerialNumber = serialNumber,
|
||||||
|
Status = SerialStatus.InStock,
|
||||||
|
CurrentLocationId = warehouseId,
|
||||||
|
CurrentLocationType = LocationType.Warehouse,
|
||||||
|
ReceivedAt = goodsReceipt.ReceivedAt
|
||||||
|
};
|
||||||
|
|
||||||
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
// 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)
|
if (balance is null)
|
||||||
{
|
{
|
||||||
await stockRepo.AddBalanceAsync(new StockBalance
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
{
|
{
|
||||||
FK_ItemId = poItem.FK_ItemId,
|
FK_ItemId = item.Id,
|
||||||
LocationId = request.WarehouseId,
|
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
|
||||||
|
{
|
||||||
|
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,
|
LocationType = LocationType.Warehouse,
|
||||||
Quantity = line.AcceptedQuantity
|
Quantity = line.AcceptedQuantity
|
||||||
});
|
});
|
||||||
@@ -123,10 +381,10 @@ public class GrnService(
|
|||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = poItem.FK_ItemId,
|
FK_ItemId = item.Id,
|
||||||
FromLocationId = StockService.ExternalLocationId,
|
FromLocationId = StockService.ExternalLocationId,
|
||||||
FromLocationType = LocationType.External,
|
FromLocationType = LocationType.External,
|
||||||
ToLocationId = request.WarehouseId,
|
ToLocationId = warehouseId,
|
||||||
ToLocationType = LocationType.Warehouse,
|
ToLocationType = LocationType.Warehouse,
|
||||||
Quantity = line.AcceptedQuantity,
|
Quantity = line.AcceptedQuantity,
|
||||||
Type = MovementType.PurchaseReceipt,
|
Type = MovementType.PurchaseReceipt,
|
||||||
@@ -134,8 +392,9 @@ public class GrnService(
|
|||||||
CreatedBy = receivedByUserId
|
CreatedBy = receivedByUserId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
poRepo.Update(po);
|
if (po is not null) poRepo.Update(po);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ public record CreateItemRequest(
|
|||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
List<UomConversionRequest>? Conversions,
|
List<UomConversionRequest>? Conversions,
|
||||||
decimal? ReorderLevel = null);
|
decimal? ReorderLevel = null,
|
||||||
|
bool TracksSerialNumbers = false,
|
||||||
|
bool TracksBatches = false,
|
||||||
|
bool TracksExpiryDate = false,
|
||||||
|
bool HasWarranty = false,
|
||||||
|
int? WarrantyPeriodDays = null,
|
||||||
|
bool AllowCustomWarrantyPeriod = false);
|
||||||
|
|
||||||
public record UpdateItemRequest(
|
public record UpdateItemRequest(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -23,7 +29,13 @@ public record UpdateItemRequest(
|
|||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
bool IsActive = true,
|
bool IsActive = true,
|
||||||
decimal? ReorderLevel = null);
|
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);
|
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
||||||
|
|
||||||
@@ -43,6 +55,12 @@ public record ItemSummaryResponse(
|
|||||||
string BaseUomName,
|
string BaseUomName,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
decimal? ReorderLevel,
|
decimal? ReorderLevel,
|
||||||
|
bool TracksSerialNumbers,
|
||||||
|
bool TracksBatches,
|
||||||
|
bool TracksExpiryDate,
|
||||||
|
bool HasWarranty,
|
||||||
|
int? WarrantyPeriodDays,
|
||||||
|
bool AllowCustomWarrantyPeriod,
|
||||||
Models.SyncSource SyncSource,
|
Models.SyncSource SyncSource,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
@@ -62,6 +80,12 @@ public record ItemDetailResponse(
|
|||||||
string BaseUomName,
|
string BaseUomName,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
decimal? ReorderLevel,
|
decimal? ReorderLevel,
|
||||||
|
bool TracksSerialNumbers,
|
||||||
|
bool TracksBatches,
|
||||||
|
bool TracksExpiryDate,
|
||||||
|
bool HasWarranty,
|
||||||
|
int? WarrantyPeriodDays,
|
||||||
|
bool AllowCustomWarrantyPeriod,
|
||||||
Models.SyncSource SyncSource,
|
Models.SyncSource SyncSource,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
|
|||||||
|
|
||||||
namespace Distribution.Features.Item;
|
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) =>
|
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
|
||||||
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
|
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_SubCategoryId, i.SubCategory?.Name,
|
||||||
i.FK_BrandId, i.Brand?.Name,
|
i.FK_BrandId, i.Brand?.Name,
|
||||||
i.FK_BaseUomId, i.BaseUom.Name,
|
i.FK_BaseUomId, i.BaseUom.Name,
|
||||||
i.SalePrice, i.ReorderLevel, 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) =>
|
private static ItemDetailResponse MapDetail(Models.Item i) =>
|
||||||
new(i.Id, i.Sku, i.Name, i.Description,
|
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_SubCategoryId, i.SubCategory?.Name,
|
||||||
i.FK_BrandId, i.Brand?.Name,
|
i.FK_BrandId, i.Brand?.Name,
|
||||||
i.FK_BaseUomId, i.BaseUom.Name,
|
i.FK_BaseUomId, i.BaseUom.Name,
|
||||||
i.SalePrice, i.ReorderLevel, 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());
|
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) =>
|
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
|
||||||
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
|
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
|
||||||
.Select(MapSummary)
|
.Select(MapSummary)
|
||||||
@@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
if (await repo.SkuExistsAsync(request.Sku))
|
if (await repo.SkuExistsAsync(request.Sku))
|
||||||
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
|
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
|
var item = new Models.Item
|
||||||
{
|
{
|
||||||
Sku = request.Sku,
|
Sku = request.Sku,
|
||||||
@@ -52,6 +93,12 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
FK_BaseUomId = request.BaseUomId,
|
FK_BaseUomId = request.BaseUomId,
|
||||||
SalePrice = request.SalePrice,
|
SalePrice = request.SalePrice,
|
||||||
ReorderLevel = request.ReorderLevel,
|
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
|
SyncSource = Models.SyncSource.Manual
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
if (item is null) return null;
|
if (item is null) return null;
|
||||||
if (item.SyncSource == Models.SyncSource.Erp) 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.Name = request.Name;
|
||||||
item.Description = request.Description;
|
item.Description = request.Description;
|
||||||
item.FK_CategoryId = request.CategoryId;
|
item.FK_CategoryId = request.CategoryId;
|
||||||
@@ -86,6 +136,12 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
item.FK_BaseUomId = request.BaseUomId;
|
item.FK_BaseUomId = request.BaseUomId;
|
||||||
item.SalePrice = request.SalePrice;
|
item.SalePrice = request.SalePrice;
|
||||||
item.ReorderLevel = request.ReorderLevel;
|
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;
|
item.IsActive = request.IsActive;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
|
|||||||
@@ -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<Models.Region?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.Region>> GetAllAsync();
|
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);
|
Task AddAsync(Models.Region entity);
|
||||||
void Update(Models.Region entity);
|
void Update(Models.Region entity);
|
||||||
void Remove(Models.Region entity);
|
void Remove(Models.Region entity);
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
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}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
@@ -38,6 +43,34 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
return deleted ? NoContent() : NotFound();
|
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.
|
//targets/budgets/agencies/tax-rule.
|
||||||
[HttpPut("{id:guid}/manage")]
|
[HttpPut("{id:guid}/manage")]
|
||||||
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
namespace Distribution.Features.Region;
|
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);
|
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||||
|
|
||||||
@@ -61,3 +65,9 @@ public record RegionManageEntryResponse(
|
|||||||
string? RuleConfig,
|
string? RuleConfig,
|
||||||
Guid? AgencyUserId,
|
Guid? AgencyUserId,
|
||||||
DateTime CreatedAt);
|
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() =>
|
public async Task<List<Models.Region>> GetAllAsync() =>
|
||||||
await context.Regions.ToListAsync();
|
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) =>
|
public async Task AddAsync(Models.Region entity) =>
|
||||||
await context.Regions.AddAsync(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)
|
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||||
{
|
{
|
||||||
private const int RegionalAgencyRoleLevel = 60;
|
private const int RegionalAgencyRoleLevel = 60;
|
||||||
|
private const string CodePrefix = "REG-";
|
||||||
|
|
||||||
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
// 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.
|
// 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() =>
|
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||||
(await repo.GetAllAsync())
|
(await repo.GetAllAsync())
|
||||||
.Where(r => r.IsActive)
|
.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();
|
.ToList();
|
||||||
|
|
||||||
public async Task<RegionResponse?> GetByIdAsync(Guid id)
|
public async Task<RegionResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var region = await repo.GetByIdAsync(id);
|
var region = await repo.GetByIdAsync(id);
|
||||||
if (region is null || !region.IsActive) return null;
|
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)
|
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);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
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)
|
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);
|
var region = await repo.GetByIdAsync(id);
|
||||||
if (region is null || !region.IsActive) return null;
|
if (region is null || !region.IsActive) return null;
|
||||||
region.Name = request.Name;
|
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);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -57,7 +88,68 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
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)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public interface IStockRepository
|
|||||||
{
|
{
|
||||||
// Balance
|
// Balance
|
||||||
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
|
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>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
|
||||||
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
|
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
|
||||||
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
||||||
@@ -20,4 +21,12 @@ public interface IStockRepository
|
|||||||
void UpdateMovement(StockMovement movement);
|
void UpdateMovement(StockMovement movement);
|
||||||
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
|
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);
|
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);
|
||||||
|
Task AddItemSerialNumberAsync(ItemSerialNumber serial);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
|||||||
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
|
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
|
||||||
db.StockBalances
|
db.StockBalances
|
||||||
.Include(b => b.Item)
|
.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) =>
|
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
|
||||||
db.StockBalances
|
db.StockBalances
|
||||||
@@ -90,4 +97,16 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
|||||||
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
|
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
|
||||||
return query.CountAsync();
|
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 AddItemSerialNumberAsync(ItemSerialNumber serial) =>
|
||||||
|
await db.ItemSerialNumbers.AddAsync(serial);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
|
|||||||
[HttpPut("{key}")]
|
[HttpPut("{key}")]
|
||||||
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
|
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
|
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);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ namespace Distribution.Features.SysConfig;
|
|||||||
|
|
||||||
public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy);
|
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);
|
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
|
|||||||
{
|
{
|
||||||
public const string KeyInvoiceCreationMode = "InvoiceCreationMode";
|
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";
|
||||||
|
|
||||||
|
private static readonly string[] BooleanFeatureKeys =
|
||||||
|
[
|
||||||
|
KeySerialNumberTrackingEnabled,
|
||||||
|
KeyBatchTrackingEnabled,
|
||||||
|
KeyExpiryDateTrackingEnabled,
|
||||||
|
KeyWarrantyTrackingEnabled,
|
||||||
|
KeyCustomWarrantyOverrideEnabled,
|
||||||
|
];
|
||||||
|
|
||||||
public async Task<List<SysConfigResponse>> GetAllAsync()
|
public async Task<List<SysConfigResponse>> GetAllAsync()
|
||||||
{
|
{
|
||||||
var configs = await repo.GetAllAsync();
|
var configs = await repo.GetAllAsync();
|
||||||
@@ -55,11 +71,35 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
|
|||||||
return Enum.TryParse<InvoiceCreationMode>(config.Value, out var mode) ? mode : InvoiceCreationMode.ModeB;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
private static void ValidateValue(string key, string value)
|
||||||
{
|
{
|
||||||
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
|
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<InvoiceCreationMode>())}.");
|
$"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 (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) =>
|
private static SysConfigResponse ToResponse(Models.SysConfig c) =>
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -427,12 +427,15 @@ namespace Distribution.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_PurchaseOrderId")
|
b.Property<Guid?>("FK_PurchaseOrderId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_ReceivedByUserId")
|
b.Property<Guid>("FK_ReceivedByUserId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_SupplierId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_WarehouseId")
|
b.Property<Guid>("FK_WarehouseId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -446,6 +449,8 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.HasIndex("FK_PurchaseOrderId");
|
b.HasIndex("FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SupplierId");
|
||||||
|
|
||||||
b.HasIndex("FK_WarehouseId");
|
b.HasIndex("FK_WarehouseId");
|
||||||
|
|
||||||
b.ToTable("GoodsReceipts");
|
b.ToTable("GoodsReceipts");
|
||||||
@@ -478,7 +483,10 @@ namespace Distribution.Migrations
|
|||||||
b.Property<Guid>("FK_GoodsReceiptId")
|
b.Property<Guid>("FK_GoodsReceiptId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_PurchaseOrderItemId")
|
b.Property<Guid?>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_PurchaseOrderItemId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
@@ -491,6 +499,8 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.HasIndex("FK_GoodsReceiptId");
|
b.HasIndex("FK_GoodsReceiptId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
b.HasIndex("FK_PurchaseOrderItemId");
|
b.HasIndex("FK_PurchaseOrderItemId");
|
||||||
|
|
||||||
b.ToTable("GoodsReceiptItems");
|
b.ToTable("GoodsReceiptItems");
|
||||||
@@ -518,6 +528,63 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 =>
|
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -600,6 +667,9 @@ namespace Distribution.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowCustomWarrantyPeriod")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<decimal?>("CostPrice")
|
b.Property<decimal?>("CostPrice")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
@@ -624,6 +694,9 @@ namespace Distribution.Migrations
|
|||||||
b.Property<Guid?>("FK_SubCategoryId")
|
b.Property<Guid?>("FK_SubCategoryId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("HasWarranty")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
@@ -634,7 +707,6 @@ namespace Distribution.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
|
||||||
b.Property<decimal?>("ReorderLevel")
|
b.Property<decimal?>("ReorderLevel")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
@@ -648,6 +720,18 @@ namespace Distribution.Migrations
|
|||||||
b.Property<int>("SyncSource")
|
b.Property<int>("SyncSource")
|
||||||
.HasColumnType("int");
|
.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.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("FK_BaseUomId");
|
b.HasIndex("FK_BaseUomId");
|
||||||
@@ -664,61 +748,157 @@ namespace Distribution.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("23000000-0000-0000-0000-000000000001"),
|
Id = new Guid("23000000-0000-0000-0000-000000000001"),
|
||||||
|
AllowCustomWarrantyPeriod = false,
|
||||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Coca-Cola 500ml bottle",
|
Description = "Coca-Cola 500ml bottle",
|
||||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
|
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
|
||||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
|
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||||
|
HasWarranty = false,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Name = "Coca-Cola 500ml",
|
Name = "Coca-Cola 500ml",
|
||||||
SalePrice = 60m,
|
SalePrice = 60m,
|
||||||
Sku = "BEV-COKE-500",
|
Sku = "BEV-COKE-500",
|
||||||
SyncSource = 0
|
SyncSource = 0,
|
||||||
|
TracksBatches = false,
|
||||||
|
TracksExpiryDate = false,
|
||||||
|
TracksSerialNumbers = false
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("23000000-0000-0000-0000-000000000002"),
|
Id = new Guid("23000000-0000-0000-0000-000000000002"),
|
||||||
|
AllowCustomWarrantyPeriod = false,
|
||||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Coca-Cola 1.5L bottle",
|
Description = "Coca-Cola 1.5L bottle",
|
||||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
|
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
|
||||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
|
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||||
|
HasWarranty = false,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Name = "Coca-Cola 1.5L",
|
Name = "Coca-Cola 1.5L",
|
||||||
SalePrice = 120m,
|
SalePrice = 120m,
|
||||||
Sku = "BEV-COKE-1500",
|
Sku = "BEV-COKE-1500",
|
||||||
SyncSource = 0
|
SyncSource = 0,
|
||||||
|
TracksBatches = false,
|
||||||
|
TracksExpiryDate = false,
|
||||||
|
TracksSerialNumbers = false
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("23000000-0000-0000-0000-000000000003"),
|
Id = new Guid("23000000-0000-0000-0000-000000000003"),
|
||||||
|
AllowCustomWarrantyPeriod = false,
|
||||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Lays Classic Salted 52g pack",
|
Description = "Lays Classic Salted 52g pack",
|
||||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
|
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
|
||||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
|
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||||
|
HasWarranty = false,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Name = "Lays Classic 52g",
|
Name = "Lays Classic 52g",
|
||||||
SalePrice = 50m,
|
SalePrice = 50m,
|
||||||
Sku = "SNK-LAYS-52",
|
Sku = "SNK-LAYS-52",
|
||||||
SyncSource = 0
|
SyncSource = 0,
|
||||||
|
TracksBatches = false,
|
||||||
|
TracksExpiryDate = false,
|
||||||
|
TracksSerialNumbers = false
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("23000000-0000-0000-0000-000000000004"),
|
Id = new Guid("23000000-0000-0000-0000-000000000004"),
|
||||||
|
AllowCustomWarrantyPeriod = false,
|
||||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Description = "Lays Family Pack 150g",
|
Description = "Lays Family Pack 150g",
|
||||||
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
|
||||||
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
|
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
|
||||||
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
|
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||||
|
HasWarranty = false,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Name = "Lays Family Pack 150g",
|
Name = "Lays Family Pack 150g",
|
||||||
SalePrice = 150m,
|
SalePrice = 150m,
|
||||||
Sku = "SNK-LAYS-FAM",
|
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 =>
|
modelBuilder.Entity("Distribution.Models.LoadingSheet", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1294,6 +1474,11 @@ namespace Distribution.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("varchar(50)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
@@ -1306,6 +1491,9 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Regions");
|
b.ToTable("Regions");
|
||||||
|
|
||||||
b.HasData(
|
b.HasData(
|
||||||
@@ -1314,6 +1502,7 @@ namespace Distribution.Migrations
|
|||||||
Id = new Guid("24000000-0000-0000-0000-000000000001"),
|
Id = new Guid("24000000-0000-0000-0000-000000000001"),
|
||||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
Code = "REG-0001",
|
||||||
Name = "Western Province"
|
Name = "Western Province"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -1321,6 +1510,7 @@ namespace Distribution.Migrations
|
|||||||
Id = new Guid("24000000-0000-0000-0000-000000000002"),
|
Id = new Guid("24000000-0000-0000-0000-000000000002"),
|
||||||
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
Code = "REG-0002",
|
||||||
Name = "Central Province"
|
Name = "Central Province"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2239,9 +2429,15 @@ namespace Distribution.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_ItemBatchId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_ItemId")
|
b.Property<Guid>("FK_ItemId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_ItemSerialNumberId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("LocationId")
|
b.Property<Guid>("LocationId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -2256,7 +2452,11 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
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();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("StockBalances");
|
b.ToTable("StockBalances");
|
||||||
@@ -2303,9 +2503,15 @@ namespace Distribution.Migrations
|
|||||||
b.Property<Guid>("CreatedBy")
|
b.Property<Guid>("CreatedBy")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_ItemBatchId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_ItemId")
|
b.Property<Guid>("FK_ItemId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_ItemSerialNumberId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid?>("FromLocationId")
|
b.Property<Guid?>("FromLocationId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
@@ -2332,8 +2538,12 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemBatchId");
|
||||||
|
|
||||||
b.HasIndex("FK_ItemId");
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemSerialNumberId");
|
||||||
|
|
||||||
b.ToTable("StockMovements");
|
b.ToTable("StockMovements");
|
||||||
|
|
||||||
b.HasData(
|
b.HasData(
|
||||||
@@ -2763,6 +2973,42 @@ namespace Distribution.Migrations
|
|||||||
Key = "InvoiceCreationMode",
|
Key = "InvoiceCreationMode",
|
||||||
UpdatedAt = new DateTime(2026, 9, 17, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
UpdatedAt = new DateTime(2026, 9, 17, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
Value = "ModeB"
|
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"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3256,8 +3502,12 @@ namespace Distribution.Migrations
|
|||||||
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("FK_PurchaseOrderId")
|
.HasForeignKey("FK_PurchaseOrderId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
.IsRequired();
|
|
||||||
|
b.HasOne("Distribution.Models.Supplier", "Supplier")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_SupplierId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Warehouse", "Warehouse")
|
b.HasOne("Distribution.Models.Warehouse", "Warehouse")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -3267,6 +3517,8 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
b.Navigation("PurchaseOrder");
|
b.Navigation("PurchaseOrder");
|
||||||
|
|
||||||
|
b.Navigation("Supplier");
|
||||||
|
|
||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3278,17 +3530,61 @@ namespace Distribution.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem")
|
b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("FK_PurchaseOrderItemId")
|
.HasForeignKey("FK_PurchaseOrderItemId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("GoodsReceipt");
|
b.Navigation("GoodsReceipt");
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
b.Navigation("PurchaseOrderItem");
|
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 =>
|
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.ShopPO", "ShopPO")
|
b.HasOne("Distribution.Models.ShopPO", "ShopPO")
|
||||||
@@ -3331,6 +3627,28 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("SubCategory");
|
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 =>
|
modelBuilder.Entity("Distribution.Models.LoadingSheet", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
||||||
@@ -3746,24 +4064,52 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.StockBalance", b =>
|
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")
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("FK_ItemId")
|
.HasForeignKey("FK_ItemId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemSerialNumberId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("Item");
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("ItemBatch");
|
||||||
|
|
||||||
|
b.Navigation("ItemSerialNumber");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.StockMovement", b =>
|
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")
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("FK_ItemId")
|
.HasForeignKey("FK_ItemId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemSerialNumberId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("Item");
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("ItemBatch");
|
||||||
|
|
||||||
|
b.Navigation("ItemSerialNumber");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Store", b =>
|
modelBuilder.Entity("Distribution.Models.Store", b =>
|
||||||
@@ -3964,6 +4310,13 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Batches");
|
||||||
|
|
||||||
|
b.Navigation("Serials");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
modelBuilder.Entity("Distribution.Models.Invoice", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Payments");
|
b.Navigation("Payments");
|
||||||
|
|||||||
@@ -44,4 +44,6 @@ namespace Distribution.Models
|
|||||||
/// ModeB: Invoice created as a separate explicit step.
|
/// ModeB: Invoice created as a separate explicit step.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum InvoiceCreationMode { ModeA, ModeB }
|
public enum InvoiceCreationMode { ModeA, ModeB }
|
||||||
|
|
||||||
|
public enum SerialStatus { InStock, Sold, Returned, Damaged }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ namespace Distribution.Models
|
|||||||
{
|
{
|
||||||
public class GoodsReceipt : Template
|
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")]
|
[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; }
|
public Guid FK_ReceivedByUserId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,26 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_GoodsReceiptId")]
|
[ForeignKey("FK_GoodsReceiptId")]
|
||||||
public GoodsReceipt GoodsReceipt { get; set; }
|
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")]
|
[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>
|
/// <summary>Added to warehouse stock.</summary>
|
||||||
public int AcceptedQuantity { get; set; }
|
public int AcceptedQuantity { get; set; }
|
||||||
|
|
||||||
/// <summary>Damaged/short at receipt, not added to stock.</summary>
|
/// <summary>Damaged/short at receipt, not added to stock.</summary>
|
||||||
public int RejectedQuantity { get; set; } = 0;
|
public int RejectedQuantity { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ namespace Distribution.Models
|
|||||||
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
|
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
|
||||||
public decimal? CostPrice { get; set; }
|
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 SyncSource SyncSource { get; set; } = SyncSource.Manual;
|
||||||
public int? ExternalErpItemId { get; set; }
|
public int? ExternalErpItemId { get; set; }
|
||||||
public DateTime? LastSyncedAt { 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 class Region : Template
|
||||||
{
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ namespace Distribution.Models
|
|||||||
public Guid LocationId { get; set; }
|
public Guid LocationId { get; set; }
|
||||||
public LocationType LocationType { 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 decimal Quantity { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ namespace Distribution.Models
|
|||||||
public Guid? ToLocationId { get; set; }
|
public Guid? ToLocationId { get; set; }
|
||||||
public LocationType? ToLocationType { 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 decimal Quantity { get; set; }
|
||||||
public MovementType Type { get; set; }
|
public MovementType Type { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ using Distribution.Features.Sync;
|
|||||||
using Distribution.Features.Uom;
|
using Distribution.Features.Uom;
|
||||||
using Distribution.Features.User;
|
using Distribution.Features.User;
|
||||||
using Distribution.Features.Vehicle;
|
using Distribution.Features.Vehicle;
|
||||||
|
using Distribution.Features.ProductReport;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.SysConfig;
|
using Distribution.Features.SysConfig;
|
||||||
using Distribution.Features.Warehouse;
|
using Distribution.Features.Warehouse;
|
||||||
@@ -62,6 +63,8 @@ builder.Services.AddScoped<IBrandRepository, BrandRepository>();
|
|||||||
builder.Services.AddScoped<IUomRepository, UomRepository>();
|
builder.Services.AddScoped<IUomRepository, UomRepository>();
|
||||||
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
||||||
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
||||||
|
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
||||||
|
builder.Services.AddScoped<ProductReportService>();
|
||||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||||
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
|
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
|
||||||
builder.Services.AddScoped<IRideRepository, RideRepository>();
|
builder.Services.AddScoped<IRideRepository, RideRepository>();
|
||||||
@@ -75,6 +78,7 @@ builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>(
|
|||||||
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
|
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
|
||||||
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
|
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
|
||||||
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
|
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
|
||||||
|
builder.Services.AddScoped<ISysConfigRepository, SysConfigRepository>();
|
||||||
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
|
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
|
||||||
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
||||||
|
|
||||||
@@ -109,6 +113,7 @@ builder.Services.AddScoped<ClaimService>();
|
|||||||
builder.Services.AddScoped<SupplierClaimService>();
|
builder.Services.AddScoped<SupplierClaimService>();
|
||||||
builder.Services.AddScoped<DeliveryTaskService>();
|
builder.Services.AddScoped<DeliveryTaskService>();
|
||||||
builder.Services.AddScoped<PaymentService>();
|
builder.Services.AddScoped<PaymentService>();
|
||||||
|
builder.Services.AddScoped<SysConfigService>();
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# [Feature] Item Tracking Configuration — Serial Numbers, Batches, Warranty, Expiry
|
||||||
|
|
||||||
|
**Status**: Implemented (see [item_tracking_plan.md](./item_tracking_plan.md) for per-phase status). Open items: apply migration `AddDirectGoodsReceipt`; build the sale/delivery step that starts warranty.
|
||||||
|
**Touches**: `Item` catalogue, GRN intake, stock ledger (`StockBalance`/`StockMovement`), new
|
||||||
|
Configuration page (`distribution_fe/app/(dashboard)/configuration`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let specific catalogue items opt into serial-number tracking, batch/lot tracking, and/or expiry-date
|
||||||
|
tracking, with an optional warranty period (system default or per-item custom override), while:
|
||||||
|
|
||||||
|
1. Each feature (serial tracking, batch tracking, expiry-date tracking, warranty) can be turned
|
||||||
|
**on/off system-wide** from the new Configuration page, independent of any single item.
|
||||||
|
2. Each **item** can independently track serial numbers, batches, expiry dates, any combination of
|
||||||
|
the three, or none of them — never forced into a tracking mode it doesn't need. Expiry-date
|
||||||
|
tracking does not require batch tracking — a perishable item can carry a single expiry date per
|
||||||
|
goods receipt without ever being assigned a batch number.
|
||||||
|
3. Items that track none of these continue to work exactly as today — this must be additive, not a
|
||||||
|
breaking change to the existing stock ledger or GRN flow.
|
||||||
|
4. Batches and/or expiry-only lots carry an **expiry date**; serials carry a **warranty expiry**,
|
||||||
|
derived from a period in days that is either the system default or a per-item custom value (only
|
||||||
|
if the item is flagged to allow a custom override).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (as found in the codebase, before implementation)
|
||||||
|
|
||||||
|
- **`Item`** (`Models/Item.cs`) has no tracking-related fields today — just SKU, category, UOM,
|
||||||
|
pricing, `ReorderLevel` (added in the most recent migration, `20260928101132_AddItemReorderLevel`).
|
||||||
|
- **Stock ledger** (`Models/StockBalance.cs`, `Models/StockMovement.cs`) is keyed purely on
|
||||||
|
`FK_ItemId` + `LocationId`/`LocationType`. There is no concept of a batch or a serial unit
|
||||||
|
anywhere in the ledger — one aggregate quantity per item per location.
|
||||||
|
- **GRN** (`Features/Grn/GrnService.cs`, `Models/GoodsReceiptItem.cs`) records only
|
||||||
|
`AcceptedQuantity`/`RejectedQuantity` per PO line. No batch number, expiry, or serial capture
|
||||||
|
point exists anywhere in intake.
|
||||||
|
- **No existing entity** anywhere in the backend represents a batch, lot, serial number, or
|
||||||
|
warranty period — this is a net-new domain concept, not a rename/extension of something existing.
|
||||||
|
- **`SysConfig`** (`Models/SysConfig.cs`, `Features/SysConfig/*`) is a generic key/value store
|
||||||
|
already used for one system-wide toggle (`InvoiceCreationMode`, see
|
||||||
|
`SysConfigService.KeyInvoiceCreationMode`). This is the existing mechanism for global on/off
|
||||||
|
switches and is the natural home for the new feature toggles — no new entity needed for that part.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two Layers of Configuration
|
||||||
|
|
||||||
|
### 1. Global feature toggles (configuration page) — reuse `SysConfig`
|
||||||
|
|
||||||
|
New well-known keys, following the exact pattern `KeyInvoiceCreationMode` already uses
|
||||||
|
(`SysConfigService.cs:8`, validated in `ValidateValue`):
|
||||||
|
|
||||||
|
| Key | Values | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `Feature.SerialNumberTracking` | `"true"` / `"false"` | Whether any item is allowed to track serial numbers. |
|
||||||
|
| `Feature.BatchTracking` | `"true"` / `"false"` | Whether any item is allowed to track batches/lots. |
|
||||||
|
| `Feature.ExpiryDateTracking` | `"true"` / `"false"` | Whether any item is allowed to require an expiry date at intake — independent of batch tracking. |
|
||||||
|
| `Feature.WarrantyTracking` | `"true"` / `"false"` | Whether warranty periods apply at all. |
|
||||||
|
| `Feature.CustomWarrantyOverride` | `"true"` / `"false"` | Whether individual items may override the system default warranty period. |
|
||||||
|
| `Feature.DefaultWarrantyPeriodDays` | integer string | System-wide default warranty length in days, used when an item has warranty enabled but no custom override. |
|
||||||
|
|
||||||
|
`SysConfigService` gains typed getters (`GetSerialTrackingEnabledAsync()`, etc.) mirroring
|
||||||
|
`GetInvoiceCreationModeAsync()`, plus validation in `ValidateValue` for the boolean/int keys.
|
||||||
|
`SysConfigController` already exposes generic `GET/PUT /api/sys-config/{key}`, so the
|
||||||
|
configuration page frontend needs no new backend endpoints for this layer — just calls to the
|
||||||
|
existing key/value routes.
|
||||||
|
|
||||||
|
Turning a global toggle **off** does not delete per-item settings or existing batch/serial data —
|
||||||
|
it only hides the corresponding controls in the Item Sheet and disables new intake of that kind
|
||||||
|
(enforced in `ItemService`/`GrnService`, not just the UI).
|
||||||
|
|
||||||
|
### 2. Per-item settings — new columns on `Item`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `TracksSerialNumbers` | `bool` | Default `false`. Can only be set `true` if `Feature.SerialNumberTracking` is on. |
|
||||||
|
| `TracksBatches` | `bool` | Default `false`. Can only be set `true` if `Feature.BatchTracking` is on. |
|
||||||
|
| `TracksExpiryDate` | `bool` | Default `false`. Can only be set `true` if `Feature.ExpiryDateTracking` is on. Independent of `TracksBatches` — see below. |
|
||||||
|
| `HasWarranty` | `bool` | Default `false`. Can only be set `true` if `Feature.WarrantyTracking` is on. |
|
||||||
|
| `WarrantyPeriodDays` | `int?` | Only meaningful if `HasWarranty`. Null = use `Feature.DefaultWarrantyPeriodDays`. |
|
||||||
|
| `AllowCustomWarrantyPeriod` | `bool` | Default `false`. Gates whether `WarrantyPeriodDays` may be set on this item at all — requires `Feature.CustomWarrantyOverride` on. |
|
||||||
|
|
||||||
|
`TracksSerialNumbers`, `TracksBatches`, and `TracksExpiryDate` are independent booleans, so every
|
||||||
|
combination (none / any one / any two / all three) falls out naturally — no extra enum needed. An
|
||||||
|
item can have `TracksExpiryDate = true` with `TracksBatches = false`: it gets a per-receipt expiry
|
||||||
|
date without ever needing a batch number (see `ItemBatch` below, whose `BatchNumber` becomes
|
||||||
|
optional to support this case).
|
||||||
|
|
||||||
|
Validation lives in `ItemService.CreateAsync`/`UpdateAsync`: reject the request if a per-item flag
|
||||||
|
is `true` while its global feature is off, with a message like *"Serial number tracking is
|
||||||
|
disabled system-wide."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Entities — Batches and Serial Numbers
|
||||||
|
|
||||||
|
### `ItemBatch`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Id | Guid | PK |
|
||||||
|
| FK_ItemId | Guid | FK → Item. Item must have `TracksBatches = true` and/or `TracksExpiryDate = true`. |
|
||||||
|
| BatchNumber | string? | Supplier/internal lot code. **Required if `TracksBatches`, otherwise null** — an expiry-only item (`TracksExpiryDate` with `TracksBatches = false`) gets a row here purely to carry the expiry date, with no batch number. |
|
||||||
|
| ManufactureDate | DateTime? | Optional. |
|
||||||
|
| ExpiryDate | DateTime? | Required if `TracksExpiryDate`. Drives expiry alerts and (later phase) dispatch blocking. |
|
||||||
|
| IsActive | bool | Standard soft-delete/versioning flag. |
|
||||||
|
| CreatedAt | DateTime | From `Template`. |
|
||||||
|
|
||||||
|
`ItemBatch` is the single mechanism behind both "batch tracking" and "expiry-date tracking" — a
|
||||||
|
batch is a subdivision of one item's stock, not a new stock-holding location, and quantity for a
|
||||||
|
batch/lot still lives in the ledger (see below). The row only exists at all when the item tracks
|
||||||
|
batches or expiry dates (or both); an item that tracks neither never gets `ItemBatch` rows.
|
||||||
|
|
||||||
|
### `ItemSerialNumber`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Id | Guid | PK |
|
||||||
|
| FK_ItemId | Guid | FK → Item. Item must have `TracksSerialNumbers = true`. |
|
||||||
|
| SerialNumber | string | Unique per item. |
|
||||||
|
| Status | enum `SerialStatus { InStock, Sold, Returned, Damaged }` | Current lifecycle state. |
|
||||||
|
| CurrentLocationId / CurrentLocationType | Guid? / `LocationType?` | Mirrors the ledger's location model — where this specific unit currently sits. |
|
||||||
|
| ReceivedAt | DateTime | When it entered stock (GRN). |
|
||||||
|
| WarrantyPeriodDaysOverride | int? | Snapshotted at receipt from `Item.WarrantyPeriodDays` when `Item.HasWarranty` and `Item.AllowCustomWarrantyPeriod`; otherwise null (system default applies when the warranty starts). |
|
||||||
|
| WarrantyExpiresAt | DateTime? | **Set at sale/delivery, not receipt**: sale date + (override ?? `Feature.DefaultWarrantyPeriodDays`). Null while in stock or if `Item.HasWarranty` is false. *(Original design computed this at receipt; changed so warehouse time doesn't consume the customer's warranty.)* |
|
||||||
|
| IsActive | bool | Standard flag. |
|
||||||
|
|
||||||
|
A serial number represents exactly one physical unit — quantity is always 1, so serials are
|
||||||
|
tracked as rows, not as a quantity column.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stock Ledger Changes (additive, nullable)
|
||||||
|
|
||||||
|
`StockBalance` and `StockMovement` (`Models/StockBalance.cs`, `Models/StockMovement.cs`) each gain:
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `FK_ItemBatchId` | Guid? | Null for items that don't track batches — **no behavior change for existing items.** |
|
||||||
|
| `FK_ItemSerialNumberId` | Guid? | Null for items that don't track serials. |
|
||||||
|
|
||||||
|
- Untracked items: both columns stay null, ledger behaves exactly as it does today (one aggregate
|
||||||
|
row per item+location).
|
||||||
|
- Batch-tracked items: one `StockBalance` row per (Item, Location, Batch) instead of per (Item,
|
||||||
|
Location) — the existing `StockRepository.GetBalanceAsync`/`AddBalanceAsync` calls in
|
||||||
|
`GrnService.CreateAsync` (`Features/Grn/GrnService.cs:106-122`) need a batch-aware overload.
|
||||||
|
- Serial-tracked items: quantity is always 1 per movement; a `StockMovement` moves one serial from
|
||||||
|
one location to another and updates `ItemSerialNumber.CurrentLocationId/Type` + `Status` in the
|
||||||
|
same transaction. A running `StockBalance` row can still exist (count of serials at a location)
|
||||||
|
for consistency with existing balance-reporting UI, kept in sync alongside the serial's own
|
||||||
|
status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GRN Changes (intake is where batches/serials are born)
|
||||||
|
|
||||||
|
`GoodsReceiptItem` (`Models/GoodsReceiptItem.cs`) needs a capture point matching the item's
|
||||||
|
tracking mode:
|
||||||
|
|
||||||
|
- **Batch-tracked item**: the GRN line must supply a batch number (+ expiry date if the item also
|
||||||
|
has `TracksExpiryDate`) instead of a bare accepted quantity. New table `GoodsReceiptItemBatch`
|
||||||
|
(FK_GoodsReceiptItemId, BatchNumber?, ExpiryDate?, Quantity) — a single PO line can be split across
|
||||||
|
multiple batches in one receipt.
|
||||||
|
- **Expiry-only item** (`TracksExpiryDate` without `TracksBatches`): the GRN line must supply an
|
||||||
|
expiry date but no batch number — same `GoodsReceiptItemBatch` table, `BatchNumber` left null.
|
||||||
|
- **Serial-tracked item**: the GRN line must supply exactly `AcceptedQuantity` serial numbers. New
|
||||||
|
table `GoodsReceiptItemSerial` (FK_GoodsReceiptItemId, SerialNumber).
|
||||||
|
- **None of the above**: unchanged — today's `AcceptedQuantity`/`RejectedQuantity` flow stays as-is.
|
||||||
|
|
||||||
|
`GrnService.CreateAsync` (`Features/Grn/GrnService.cs:44`) needs a branch per tracking mode when
|
||||||
|
writing `StockBalance`/`StockMovement`: create/lookup the `ItemBatch` (or create each
|
||||||
|
`ItemSerialNumber`) before writing the ledger rows, and validate the request shape matches the
|
||||||
|
item's `TracksSerialNumbers`/`TracksBatches`/`TracksExpiryDate` flags (e.g. reject a batch number on
|
||||||
|
an item that doesn't track batches, or a missing expiry date on an item that requires one).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
- **configuration page** (`distribution_fe/app/(dashboard)/configuration/page.tsx`, currently a
|
||||||
|
placeholder): add an "Item Tracking" section with toggles for the five `Feature.*` keys and a
|
||||||
|
number input for the default warranty period, following the same form pattern as
|
||||||
|
`drivers/compliance/page.tsx` (a single-purpose settings form backed by a GET on load / PUT on
|
||||||
|
save) but hitting the generic `/api/sys-config/{key}` routes instead of a dedicated controller.
|
||||||
|
- **Item Sheet** (`catalogue/items/_components/item-sheet.tsx`): add a "Tracking" `FieldGroup` with
|
||||||
|
`Track serial numbers` / `Track batches` / `Track expiry date` checkboxes, a `Has warranty` checkbox revealing a
|
||||||
|
warranty-period number input, and (only when `Feature.CustomWarrantyOverride` is on) an
|
||||||
|
`Allow custom warranty period` checkbox. Checkboxes for a disabled global feature are hidden
|
||||||
|
entirely, not just disabled, to avoid confusing item editors.
|
||||||
|
- **GRN entry** — built as two full pages (against a PO: `/procurement/grn/po/new`; direct:
|
||||||
|
`/procurement/grn/direct/new`). Tracked lines show a status chip that opens a panel for serial
|
||||||
|
entry (scan / auto-generate / type-paste-edit) or batch rows (batch no. with Auto, expiry, qty).
|
||||||
|
Save is disabled until every tracked line is complete. Details in the plan, Phases 10–12.
|
||||||
|
- **configuration page**: a single Products card on `/configuration` holds the switches and warranty
|
||||||
|
defaults (no separate sub-page). **Item view** shows tracking badges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct Goods Receipt (no purchase order)
|
||||||
|
|
||||||
|
A goods receipt may exist without a PO. `GoodsReceipt.FK_PurchaseOrderId` and
|
||||||
|
`GoodsReceiptItem.FK_PurchaseOrderItemId` are nullable; direct lines carry `FK_ItemId` instead;
|
||||||
|
an optional `FK_SupplierId` identifies the supplier. `POST /api/goods-receipts/direct` creates one.
|
||||||
|
Serial/batch/expiry capture, stock balances and movements are identical to a PO receipt (shared
|
||||||
|
`PostReceiptAsync`); the only differences are that no PO quantity or status is updated and no PO
|
||||||
|
validation applies. See the plan, Phase 11.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Process — How Item Tracking Works End to End
|
||||||
|
|
||||||
|
### 1. Setup (once)
|
||||||
|
1. **Configuration → Products card**: switch on the capabilities the business needs (serial, batch,
|
||||||
|
expiry, warranty; optionally per-item custom warranty) and set the default warranty days. Changes
|
||||||
|
save immediately and are stored in `SysConfig` (`Feature.*` keys).
|
||||||
|
2. **Catalogue → Items → edit item**: tick `Track serial numbers` / `Track batches` /
|
||||||
|
`Track expiry date` and, if applicable, `Has warranty` (+ `Use a custom warranty period` and days).
|
||||||
|
A flag can only be set if its global feature is on. Untracked items are unaffected.
|
||||||
|
|
||||||
|
### 2. Receiving goods (where serials / batches are entered)
|
||||||
|
Entered **only at goods receipt**, never on the item or PO forms.
|
||||||
|
|
||||||
|
| Item tracks | GRN line needs | Stored as |
|
||||||
|
|---|---|---|
|
||||||
|
| nothing | accepted / rejected qty | one `StockBalance` row per item+warehouse |
|
||||||
|
| serial numbers | exactly *accepted qty* unique serials | one `ItemSerialNumber` (InStock) + one `StockBalance` (qty 1) + one `StockMovement` per unit |
|
||||||
|
| batches | batch rows (batch no., [expiry], qty) summing to accepted qty | `ItemBatch` (reused by item + batch no.) + `StockBalance` per (item, warehouse, batch) + `StockMovement` per batch |
|
||||||
|
| expiry only | lot rows (expiry, qty), **no** batch number | fresh `ItemBatch` per row carrying only the expiry date |
|
||||||
|
| serial + batch/expiry | serials only (serial wins; batch/expiry not linked — follow-up) | as serial |
|
||||||
|
|
||||||
|
Rejected quantity never touches stock. Everything posts in one unit of work and rolls back on any
|
||||||
|
failure. A serial that already exists for the item, or a mismatch with the item's flags, is rejected.
|
||||||
|
|
||||||
|
**Two ways to receive**: *against a PO* (lines come from the PO, PO received-quantities and status
|
||||||
|
update) or *direct* (no PO; item chosen per line, optional supplier). The UI exposes them as two
|
||||||
|
pages and both share the same serial/batch entry panel.
|
||||||
|
|
||||||
|
### 3. Entering serials / batches in the UI
|
||||||
|
- Each tracked line shows a chip (amber until complete, green ✓ when complete) → opens the panel.
|
||||||
|
- **Serials**: scan (Enter adds one), auto-generate (prefix + start number, fills only missing units),
|
||||||
|
or type/paste one per line; every entry stays editable; duplicates are flagged.
|
||||||
|
- **Batches**: add rows; batch number can be typed, scanned or auto-generated; expiry date warns if
|
||||||
|
expired or within 30 days; quantities must add up to the accepted quantity.
|
||||||
|
- Save is disabled until all tracked lines are complete; the server re-validates.
|
||||||
|
- Prefer real supplier serials/batch numbers where available — auto-generated values suit internal tags.
|
||||||
|
|
||||||
|
### 4. Warranty lifecycle
|
||||||
|
1. *Receipt*: serial created `InStock`; only the item's custom period (if any) is snapshotted.
|
||||||
|
2. *Sale/delivery* (**not built yet**): serial → `Sold`; `WarrantyExpiresAt = sale date + (override ??
|
||||||
|
default days)`.
|
||||||
|
3. *Claim / return* (future): `WarrantyExpiresAt` decides eligibility; status moves to
|
||||||
|
`Returned` / `Damaged`.
|
||||||
|
|
||||||
|
### 5. Viewing
|
||||||
|
GRN view shows batches (with expiry) and an expandable serial list per line; the item view shows
|
||||||
|
tracking badges; the receipts list marks direct receipts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicitly Out of Scope for This Phase
|
||||||
|
|
||||||
|
- **Picking/dispatch by batch or serial** (loading sheets, delivery tasks, sales returns) — this
|
||||||
|
doc only covers catalogue configuration and stock intake (GRN) + ledger schema. Selecting *which*
|
||||||
|
batch or serial goes out on a given ride/loading sheet is a separate follow-up doc once this
|
||||||
|
foundation exists.
|
||||||
|
- **FEFO (first-expiry-first-out) enforcement** at dispatch time.
|
||||||
|
- **Expired-batch blocking** — whether dispatch should hard-block an expired batch or just warn is
|
||||||
|
an open question below, deferred to the dispatch-side follow-up doc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should an expired `ItemBatch` hard-block further dispatch, or just surface a warning? (Decision
|
||||||
|
deferred to the dispatch follow-up doc, but the `ExpiryDate` column needs to exist now either way.)
|
||||||
|
2. Is `WarrantyPeriodDays` always in days, or should the Item Sheet offer months/years and convert?
|
||||||
|
~~Resolved:~~ warranty timing — decided to start at sale, not receipt (see Process §4).
|
||||||
|
|
||||||
|
3. Can serial numbers ever be added outside a GRN (e.g. a manual stock adjustment for an
|
||||||
|
already-tracked item), or is GRN the only entry point in this phase?
|
||||||
|
4. When batch tracking splits one item's stock into multiple `StockBalance` rows per location, does
|
||||||
|
the existing Stock Balances page (`stock/_components/stock-balances-client.tsx`) need a
|
||||||
|
drill-down, or is an aggregated total (sum across batches) sufficient for now?
|
||||||
|
5. Naming for the migration: proposed `AddItemTrackingConfig`, continuing from the most recent
|
||||||
|
migration `20260928101132_AddItemReorderLevel`.
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
# Item Tracking Configuration — Implementation Plan
|
||||||
|
|
||||||
|
**Status**: Implemented — serial/batch/expiry capture on both receipt flows (against a PO and direct), config + item UI done. Pending: DB migration `AddDirectGoodsReceipt`, and the sale/delivery step that starts warranty (not built)
|
||||||
|
**Design doc**: [item_tracking_config.md](./item_tracking_config.md)
|
||||||
|
|
||||||
|
| Phase | Title | Status |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| 1 | Enums | ✅ Done |
|
||||||
|
| 2 | Global feature toggles (`SysConfig`) | ✅ Done |
|
||||||
|
| 3 | `Item` per-item tracking fields | ✅ Done |
|
||||||
|
| 4 | New entities — `ItemBatch`, `ItemSerialNumber` | ✅ Done |
|
||||||
|
| 5 | Stock ledger — batch/serial columns | ✅ Done |
|
||||||
|
| 6 | GRN intake — batch/serial capture | ✅ Done (serial path takes precedence when an item tracks both serials and batches — see `GrnService.ValidateLineShape`). **Warranty is no longer computed here** — see "Warranty timing" below |
|
||||||
|
| 7 | Migration | ✅ Done — `20261001080630_AddItemTrackingConfig`, applied |
|
||||||
|
| 8 | configuration page UI | ✅ Done — single Products card on `/configuration` (no separate `/configuration/products` route) |
|
||||||
|
| 9 | Item Sheet UI | ✅ Done (also fixed pre-existing `reorderLevel` drift in `types/api.ts`/`ItemBody`) |
|
||||||
|
| 10 | GRN form UI — serial/batch entry | ✅ Done — see Phase 10 |
|
||||||
|
| 11 | Direct goods receipt (no PO) — backend | ✅ Code done · ⏳ migration `AddDirectGoodsReceipt` generated, **not yet applied** |
|
||||||
|
| 12 | GRN pages — two routes (against PO / direct) | ✅ Done |
|
||||||
|
| 13 | Sale/delivery step — start warranty, mark serial `Sold` | ❌ Not built (needs a decision on which document triggers it) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add opt-in serial-number and batch/lot tracking to catalogue items, each independently on/off
|
||||||
|
system-wide via the new configuration page, with warranty periods (system default or per-item
|
||||||
|
custom override) and batch expiry dates. Fully additive to the existing schema — items that track
|
||||||
|
neither must behave exactly as they do today. See the design doc for full rationale, entity shapes,
|
||||||
|
and open questions; this file only sequences the work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 (migration)
|
||||||
|
→ Phase 8, 9, 10 (frontend, independent of each other, after Phase 7)
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend phases 2–6 touch shared files (`Item.cs`, `AppDBContext.cs`) in sequence and should not be
|
||||||
|
parallelized. Frontend phases 8–10 have no dependencies on each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Enums
|
||||||
|
|
||||||
|
**File**: `Models/Enums.cs`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum SerialStatus { InStock, Sold, Returned, Damaged }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Global feature toggles (`SysConfig`)
|
||||||
|
|
||||||
|
**Files**: `Features/SysConfig/SysConfigService.cs`, `Features/SysConfig/SysConfigController.cs`
|
||||||
|
|
||||||
|
### 2.1 New well-known keys
|
||||||
|
|
||||||
|
In `SysConfigService`, alongside `KeyInvoiceCreationMode`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Typed getters
|
||||||
|
|
||||||
|
Mirror `GetInvoiceCreationModeAsync()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Validation
|
||||||
|
|
||||||
|
Extend `ValidateValue` to accept `"true"`/`"false"` for the five boolean keys and a non-negative
|
||||||
|
integer string for `KeyDefaultWarrantyPeriodDays`.
|
||||||
|
|
||||||
|
### 2.4 Controller
|
||||||
|
|
||||||
|
No new routes — the existing generic `GET/PUT /api/sys-config/{key}` already covers reading and
|
||||||
|
setting these keys (same pattern the frontend will already use for `InvoiceCreationMode`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — `Item` per-item tracking fields
|
||||||
|
|
||||||
|
**File**: `Models/Item.cs`
|
||||||
|
|
||||||
|
Add after `ReorderLevel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
**File**: `Features/Item/ItemDTOs.cs`
|
||||||
|
|
||||||
|
Add the same six fields to `CreateItemRequest`, `UpdateItemRequest`, `ItemSummaryResponse`, and
|
||||||
|
`ItemDetailResponse` (all optional/defaulted, matching how `ReorderLevel` was added).
|
||||||
|
|
||||||
|
**File**: `Features/Item/ItemService.cs`
|
||||||
|
|
||||||
|
In `CreateAsync`/`UpdateAsync`, before saving, validate each per-item flag against the matching
|
||||||
|
`SysConfigService` getter from Phase 2 and throw `InvalidOperationException` with a message like
|
||||||
|
*"Serial number tracking is disabled system-wide."* if a flag is `true` while its feature is off
|
||||||
|
(same check for `TracksExpiryDate` against `KeyExpiryDateTrackingEnabled`). Same check for
|
||||||
|
`AllowCustomWarrantyPeriod` against `KeyCustomWarrantyOverrideEnabled`, and for
|
||||||
|
`WarrantyPeriodDays` requiring `HasWarranty = true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — New entities: `ItemBatch`, `ItemSerialNumber`
|
||||||
|
|
||||||
|
**New file**: `Models/ItemBatch.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ItemBatch : Template
|
||||||
|
{
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
// Null when the item tracks expiry dates but not batch numbers (TracksExpiryDate without TracksBatches).
|
||||||
|
public string? BatchNumber { get; set; }
|
||||||
|
public DateTime? ManufactureDate { get; set; }
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This single entity backs both `TracksBatches` and `TracksExpiryDate` — an item can have one, the
|
||||||
|
other, or both; `ItemBatch` rows only get created at all when at least one of the two is true.
|
||||||
|
|
||||||
|
**New file**: `Models/ItemSerialNumber.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ItemSerialNumber : Template
|
||||||
|
{
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
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; }
|
||||||
|
public int? WarrantyPeriodDaysOverride { get; set; }
|
||||||
|
public DateTime? WarrantyExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File**: `Data/AppDBContext.cs` — add `DbSet<ItemBatch> ItemBatches`,
|
||||||
|
`DbSet<ItemSerialNumber> ItemSerialNumbers`, unique index on `(FK_ItemId, SerialNumber)` for
|
||||||
|
`ItemSerialNumber`, and index on `(FK_ItemId, BatchNumber)` for `ItemBatch`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Stock ledger: batch/serial columns
|
||||||
|
|
||||||
|
**Files**: `Models/StockBalance.cs`, `Models/StockMovement.cs`
|
||||||
|
|
||||||
|
Add to both:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Guid? FK_ItemBatchId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemBatchId")]
|
||||||
|
public ItemBatch? ItemBatch { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_ItemSerialNumberId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemSerialNumberId")]
|
||||||
|
public ItemSerialNumber? ItemSerialNumber { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files**: `Features/Stock/IStockRepository.cs`, `Features/Stock/StockRepository.cs`
|
||||||
|
|
||||||
|
Add batch-aware overloads: `GetBalanceAsync(itemId, locationId, batchId)` alongside the existing
|
||||||
|
`GetBalanceAsync(itemId, locationId)`, used only when `Item.TracksBatches` is true so untracked
|
||||||
|
items keep calling the existing method unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — GRN intake: batch/serial capture
|
||||||
|
|
||||||
|
**New file**: `Models/GoodsReceiptItemBatch.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class GoodsReceiptItemBatch : Template
|
||||||
|
{
|
||||||
|
public Guid FK_GoodsReceiptItemId { get; set; }
|
||||||
|
public GoodsReceiptItem GoodsReceiptItem { get; set; }
|
||||||
|
|
||||||
|
public string BatchNumber { get; set; }
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New file**: `Models/GoodsReceiptItemSerial.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class GoodsReceiptItemSerial : Template
|
||||||
|
{
|
||||||
|
public Guid FK_GoodsReceiptItemId { get; set; }
|
||||||
|
public GoodsReceiptItem GoodsReceiptItem { get; set; }
|
||||||
|
|
||||||
|
public string SerialNumber { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File**: `Features/Grn/GrnDTOs.cs` — extend the create-GRN line request with optional
|
||||||
|
`List<GrnBatchLineRequest>? Batches` (each carrying `BatchNumber?`, `ExpiryDate?`, `Quantity`) and
|
||||||
|
`List<string>? SerialNumbers`, validated so exactly one of {bare quantity, batches, serials} is
|
||||||
|
populated depending on the item's tracking flags.
|
||||||
|
|
||||||
|
**File**: `Features/Grn/GrnService.cs`
|
||||||
|
|
||||||
|
In `CreateAsync` (`Features/Grn/GrnService.cs:44`), branch per line on
|
||||||
|
`poItem.Item.TracksSerialNumbers` / `TracksBatches` / `TracksExpiryDate`:
|
||||||
|
|
||||||
|
- None of the three: unchanged — today's `AcceptedQuantity`/`RejectedQuantity` path.
|
||||||
|
- Batches and/or expiry-only: reject if `line.Batches` is null/empty or its quantities don't sum to
|
||||||
|
`AcceptedQuantity`; reject a populated `BatchNumber` if `TracksBatches` is false, and reject a
|
||||||
|
missing `ExpiryDate` if `TracksExpiryDate` is true. For each batch entry, create or look up an
|
||||||
|
`ItemBatch` (by `FK_ItemId` + `BatchNumber` when present, otherwise a fresh expiry-only row),
|
||||||
|
write one `GoodsReceiptItemBatch`, and write/increment `StockBalance` keyed by
|
||||||
|
`(ItemId, WarehouseId, BatchId)` plus a `StockMovement` carrying `FK_ItemBatchId`.
|
||||||
|
- Serials: reject if `line.SerialNumbers.Count != AcceptedQuantity` or any serial number already
|
||||||
|
exists for the item; create one `ItemSerialNumber` per entry (`Status = InStock`; **no `WarrantyExpiresAt` yet** — see "Warranty timing"), one
|
||||||
|
`GoodsReceiptItemSerial`, and one `StockMovement` per serial carrying `FK_ItemSerialNumberId`
|
||||||
|
(quantity always 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Warranty timing (changed from the original plan)
|
||||||
|
|
||||||
|
The original plan computed `WarrantyExpiresAt = ReceivedAt + period` at GRN. That made stock sitting in
|
||||||
|
a warehouse consume the customer's warranty, so it was changed: **the warranty clock starts when the
|
||||||
|
serial is sold/delivered**, not when it is received.
|
||||||
|
|
||||||
|
- At GRN, `GrnService` only snapshots the item's custom period onto the serial
|
||||||
|
(`WarrantyPeriodDaysOverride = Item.WarrantyPeriodDays`, and only when `HasWarranty` and
|
||||||
|
`AllowCustomWarrantyPeriod`). `WarrantyExpiresAt` stays `null`.
|
||||||
|
- `GrnService` no longer depends on `SysConfigService`.
|
||||||
|
- When the sale/delivery step exists (Phase 13), it sets
|
||||||
|
`WarrantyExpiresAt = saleDate + (WarrantyPeriodDaysOverride ?? Feature.DefaultWarrantyPeriodDays)`
|
||||||
|
and moves the serial to `SerialStatus.Sold`.
|
||||||
|
- Caveat: with the default `Feature.DefaultWarrantyPeriodDays = 0` and no per-item period, the
|
||||||
|
warranty would expire the day it starts — set a default or per-item periods before enabling warranty.
|
||||||
|
|
||||||
|
### Shared posting code (refactor done while adding Phase 11)
|
||||||
|
|
||||||
|
`GrnService.PostReceiptAsync(goodsReceipt, lines, warehouseId, userId, po?, ct)` now holds the
|
||||||
|
unit-of-work block: add the receipt, per line write serials / batches / `StockBalance` /
|
||||||
|
`StockMovement` (type `PurchaseReceipt`), update the PO when present, commit. Both
|
||||||
|
`CreateAsync` (PO receipt) and `CreateDirectAsync` (no PO) validate their own input and then call
|
||||||
|
it, so tracking behaviour is identical in both flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Migration
|
||||||
|
|
||||||
|
(Applied.) Run `dotnet ef migrations add AddItemTrackingConfig` after Phases 3–6 are code-complete, covering:
|
||||||
|
`Item` new columns, `ItemBatch`, `ItemSerialNumber`, `GoodsReceiptItemBatch`,
|
||||||
|
`GoodsReceiptItemSerial` tables, and the new nullable FK columns on `StockBalance`/`StockMovement`.
|
||||||
|
Review the generated migration for the FK-drop pitfall noted in `refactor_plan.md` Phase 5 before
|
||||||
|
running `dotnet ef database update`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — configuration page UI
|
||||||
|
|
||||||
|
**Files**: `distribution_fe/app/(dashboard)/configuration/page.tsx`,
|
||||||
|
`configuration/_components/product-capabilities-card.tsx`
|
||||||
|
|
||||||
|
**As built**: `/configuration` shows one **Products** card whose body holds the switches and warranty
|
||||||
|
defaults directly (each switch saves immediately via `sysConfig.set`, keys in
|
||||||
|
`ITEM_TRACKING_KEYS`). There is no separate `/configuration/products` page. The original
|
||||||
|
description follows. Replace the placeholder with a form (pattern: `drivers/compliance/page.tsx`) that on load calls
|
||||||
|
`GET /api/sys-config` (or the five individual `GET /api/sys-config/{key}` calls) and on save calls
|
||||||
|
`PUT /api/sys-config/{key}` once per changed key:
|
||||||
|
|
||||||
|
- Checkbox — "Enable serial number tracking" (`Feature.SerialNumberTracking`)
|
||||||
|
- Checkbox — "Enable batch/lot tracking" (`Feature.BatchTracking`)
|
||||||
|
- Checkbox — "Enable expiry date tracking" (`Feature.ExpiryDateTracking`)
|
||||||
|
- Checkbox — "Enable warranty periods" (`Feature.WarrantyTracking`)
|
||||||
|
- Checkbox — "Allow items to set a custom warranty period" (`Feature.CustomWarrantyOverride`), only
|
||||||
|
shown/enabled when warranty tracking is on
|
||||||
|
- Number input — "Default warranty period (days)" (`Feature.DefaultWarrantyPeriodDays`), only shown
|
||||||
|
when warranty tracking is on
|
||||||
|
|
||||||
|
Add `sysConfig` helpers to `lib/api/index.ts` (`get(key)`, `set(key, value)`) if not already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — Item Sheet UI
|
||||||
|
|
||||||
|
**File**: `distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx`
|
||||||
|
|
||||||
|
Add a "Tracking" `FieldGroup` after the existing fields:
|
||||||
|
|
||||||
|
- Checkbox `TracksSerialNumbers` — rendered only if the global serial-tracking toggle is on
|
||||||
|
- Checkbox `TracksBatches` — rendered only if the global batch-tracking toggle is on
|
||||||
|
- Checkbox `TracksExpiryDate` — rendered only if the global expiry-date toggle is on; independent
|
||||||
|
of `TracksBatches`, so it can be checked on its own for perishables with no batch numbering
|
||||||
|
- Checkbox `HasWarranty` — rendered only if the global warranty toggle is on
|
||||||
|
- Number input `WarrantyPeriodDays` — shown only when `HasWarranty` is checked
|
||||||
|
- Checkbox `AllowCustomWarrantyPeriod` — shown only when `HasWarranty` is checked **and** the
|
||||||
|
global custom-override toggle is on
|
||||||
|
|
||||||
|
Fetch the four global toggles once (e.g. via a small `useSysConfig` hook or inline `Promise.all`
|
||||||
|
next to the existing `categoriesApi`/`brandsApi`/`uomsApi` calls) when the sheet opens, so hidden
|
||||||
|
fields never get submitted as `true`.
|
||||||
|
|
||||||
|
Also update `ItemSummaryResponse`/`ItemDetailResponse` in `distribution_fe/types/api.ts` with the
|
||||||
|
five new fields (this file was already found to be missing `reorderLevel` from a prior backend
|
||||||
|
change — fix that drift in the same pass).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — GRN form UI (serial / batch entry)
|
||||||
|
|
||||||
|
**Files** (`distribution_fe/app/(dashboard)/procurement/grn/_components/`): `grn-tracking.tsx`
|
||||||
|
(shared), `grn-view-sheet.tsx`; types in `types/api.ts`, `lib/api/procurement.ts`.
|
||||||
|
|
||||||
|
- The form loads each line's item (`items.get`) to read its tracking flags — the PO line response
|
||||||
|
does not carry them.
|
||||||
|
- Tracked lines show a **status chip** ("Enter serials · 0/50", amber until complete, green "✓" when
|
||||||
|
complete). Clicking it opens a panel (`LineTracking`):
|
||||||
|
- **Serial items**: scan field (Enter adds one serial, duplicates rejected), **auto-generate**
|
||||||
|
(prefix + start number, fills only the missing units, skips taken values), and an editable
|
||||||
|
list box (type / paste / edit / clear). Live "n of N entered" count, duplicates listed.
|
||||||
|
- **Batch / expiry items**: rows of batch number (+ **Auto** button), expiry date, quantity;
|
||||||
|
"Add batch" pre-fills the remaining quantity; warns "Already expired" / "Expires within 30
|
||||||
|
days"; live "allocated of accepted" count. Batch-number column hidden if the item does not track
|
||||||
|
batches; expiry column hidden if it does not track expiry.
|
||||||
|
- Warranty is a read-only note ("starts at sale") — never an input at GRN.
|
||||||
|
- `trackingProgress()` drives chip state; **Save is disabled until every tracked line is complete**.
|
||||||
|
`trackingPayload()` mirrors `GrnService.ValidateLineShape` and builds the request (or an error
|
||||||
|
toast). Serial tracking takes precedence over batch/expiry, as on the server.
|
||||||
|
- **GRN view** lists batches per line (batch no., qty, expiry) and serials in an expandable
|
||||||
|
"n serial numbers" row. **Item view sheet** shows tracking badges (Serial / Batch / Expiry /
|
||||||
|
Warranty period).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11 — Direct goods receipt (no purchase order)
|
||||||
|
|
||||||
|
Goods can now be received without a PO. Both flows work independently and share the posting code.
|
||||||
|
|
||||||
|
**Models / migration `AddDirectGoodsReceipt`** (generated; **apply with `dotnet ef database update`**):
|
||||||
|
- `GoodsReceipt.FK_PurchaseOrderId` → nullable; new optional `FK_SupplierId` (+ `Supplier` nav).
|
||||||
|
- `GoodsReceiptItem.FK_PurchaseOrderItemId` → nullable; new `FK_ItemId` (+ `Item` nav) set on
|
||||||
|
direct lines. Existing PO lines resolve their item through `PurchaseOrderItem` (`MapItem`:
|
||||||
|
`i.Item ?? i.PurchaseOrderItem!.Item`).
|
||||||
|
|
||||||
|
**DTOs** (`GrnDTOs.cs`): `GrnItemRequest.PurchaseOrderItemId` is now `Guid?`, plus
|
||||||
|
`Guid? ItemId = null` for direct lines; new `CreateDirectGrnRequest(WarehouseId, Items, SupplierId?,
|
||||||
|
ReceivedByUserId?)`; `GrnResponse` gains `SupplierId`/`SupplierName` and a nullable
|
||||||
|
`PurchaseOrderId`; `GrnItemResponse.PurchaseOrderItemId` is nullable.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/goods-receipts/direct` (`GrnController.CreateDirect`).
|
||||||
|
`GrnService.CreateDirectAsync` validates: at least one line; supplier (if given) exists and is
|
||||||
|
active; each line has an `ItemId` of an active item; quantities non-negative and not both zero;
|
||||||
|
`ValidateLineShape`; serials not already in stock and not repeated across lines of the same
|
||||||
|
request. It then calls `PostReceiptAsync` with no PO — no PO quantities or status change.
|
||||||
|
|
||||||
|
**Not included (by choice)**: unit cost on direct lines; a received-date input (the server stamps
|
||||||
|
`DateTime.Now` — also true of the PO flow).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 12 — GRN pages (two routes)
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/procurement/grn` | Receipts list. "Receipt Against PO" and "Direct Receipt" buttons navigate to the pages below. PO column shows "Direct receipt" when there is no PO. |
|
||||||
|
| `/procurement/grn/po/new` | Pick an issued / partially received PO, warehouse, date, user; PO lines pre-filled with remaining quantity; serial/batch chips on tracked lines. |
|
||||||
|
| `/procurement/grn/direct/new` | Warehouse, optional supplier, user, then "Add item" rows (item + accepted / rejected + serial/batch chip). |
|
||||||
|
|
||||||
|
Both are full pages (not dialogs), using the shared `grn-tracking.tsx`. The old popup components
|
||||||
|
`grn-sheet.tsx` / `direct-grn-sheet.tsx` were removed. The GRN view remains a dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 13 — Sale/delivery step (not built)
|
||||||
|
|
||||||
|
Needed to complete warranty: on sale/delivery of a serial, set `Status = Sold`,
|
||||||
|
`WarrantyExpiresAt`, and (for batch items) consume batch balances (FEFO suggestion). Also still
|
||||||
|
missing: stock screen drill-down by batch/serial and any warranty-status lookup UI. Requires a
|
||||||
|
decision on which document (invoice vs delivery) triggers it.
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
|
||||||
|
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
sku: z.string().min(1, 'SKU is required'),
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
categoryId: z.string().min(1, 'Category is required'),
|
||||||
|
brandId: z.string().optional(),
|
||||||
|
baseUomId: z.string().min(1, 'Base UOM is required'),
|
||||||
|
salePrice: z.number().min(0, 'Sale price must be 0 or more'),
|
||||||
|
tracksSerialNumbers: z.boolean(),
|
||||||
|
tracksBatches: z.boolean(),
|
||||||
|
tracksExpiryDate: z.boolean(),
|
||||||
|
hasWarranty: z.boolean(),
|
||||||
|
warrantyPeriodDays: z.number().min(0).optional(),
|
||||||
|
allowCustomWarrantyPeriod: z.boolean(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
interface TrackingToggles {
|
||||||
|
serialNumberTracking: boolean
|
||||||
|
batchTracking: boolean
|
||||||
|
expiryDateTracking: boolean
|
||||||
|
warrantyTracking: boolean
|
||||||
|
customWarrantyOverride: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_TRACKING_ENABLED: TrackingToggles = {
|
||||||
|
serialNumberTracking: false,
|
||||||
|
batchTracking: false,
|
||||||
|
expiryDateTracking: false,
|
||||||
|
warrantyTracking: false,
|
||||||
|
customWarrantyOverride: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_VALUES: FormValues = {
|
||||||
|
sku: '',
|
||||||
|
name: '',
|
||||||
|
categoryId: '',
|
||||||
|
brandId: undefined,
|
||||||
|
baseUomId: '',
|
||||||
|
salePrice: 0,
|
||||||
|
tracksSerialNumbers: false,
|
||||||
|
tracksBatches: false,
|
||||||
|
tracksExpiryDate: false,
|
||||||
|
hasWarranty: false,
|
||||||
|
warrantyPeriodDays: undefined,
|
||||||
|
allowCustomWarrantyPeriod: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFormProps {
|
||||||
|
item: ItemSummaryResponse | null
|
||||||
|
onSuccess: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
twoColumn?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemForm({ item, onSuccess, onCancel, twoColumn = false }: ItemFormProps) {
|
||||||
|
const isEdit = !!item
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<CategoryResponse[]>([])
|
||||||
|
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
|
||||||
|
const [uomsList, setUomsList] = useState<UomResponse[]>([])
|
||||||
|
const [tracking, setTracking] = useState<TrackingToggles>(NO_TRACKING_ENABLED)
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: EMPTY_VALUES,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
categoriesApi.list(),
|
||||||
|
brandsApi.list(),
|
||||||
|
uomsApi.list(),
|
||||||
|
sysConfig.getAll(),
|
||||||
|
]).then(([cats, brandsRes, uomsRes, configs]) => {
|
||||||
|
setCategories(cats)
|
||||||
|
setBrandsList(brandsRes)
|
||||||
|
setUomsList(uomsRes)
|
||||||
|
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
|
||||||
|
setTracking({
|
||||||
|
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
|
||||||
|
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
|
||||||
|
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
|
||||||
|
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
|
||||||
|
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
|
||||||
|
})
|
||||||
|
}).catch((err) => {
|
||||||
|
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
form.reset(
|
||||||
|
item
|
||||||
|
? {
|
||||||
|
sku: item.sku,
|
||||||
|
name: item.name,
|
||||||
|
categoryId: item.categoryId,
|
||||||
|
brandId: item.brandId ?? undefined,
|
||||||
|
baseUomId: item.baseUomId,
|
||||||
|
salePrice: item.salePrice,
|
||||||
|
tracksSerialNumbers: item.tracksSerialNumbers,
|
||||||
|
tracksBatches: item.tracksBatches,
|
||||||
|
tracksExpiryDate: item.tracksExpiryDate,
|
||||||
|
hasWarranty: item.hasWarranty,
|
||||||
|
warrantyPeriodDays: item.warrantyPeriodDays ?? undefined,
|
||||||
|
allowCustomWarrantyPeriod: item.allowCustomWarrantyPeriod,
|
||||||
|
isActive: item.isActive,
|
||||||
|
}
|
||||||
|
: EMPTY_VALUES
|
||||||
|
)
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
const hasWarranty = form.watch('hasWarranty')
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...values,
|
||||||
|
brandId: values.brandId || null,
|
||||||
|
warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null,
|
||||||
|
}
|
||||||
|
if (isEdit) {
|
||||||
|
await itemsApi.update(item!.id, body)
|
||||||
|
toast.add({ title: 'Product updated', type: 'success' })
|
||||||
|
} else {
|
||||||
|
await itemsApi.create(body)
|
||||||
|
toast.add({ title: 'Product created', type: 'success' })
|
||||||
|
}
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<FieldGroup className={twoColumn ? 'grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2' : undefined}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sku"
|
||||||
|
label="SKU"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="sku"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. ITEM-001"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Product Name"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Coca-Cola 330ml"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categoryId"
|
||||||
|
label="Category"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select category..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="brandId"
|
||||||
|
label="Brand"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string ?? ''} onValueChange={(v) => field.onChange(v === '' ? undefined : v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select brand..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">None</SelectItem>
|
||||||
|
{brandsList.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="baseUomId"
|
||||||
|
label="Base UOM"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select unit of measure..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uomsList.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="salePrice"
|
||||||
|
label="Sale Price"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="salePrice"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={String(field.value as number)}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="0.00"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && (
|
||||||
|
<div className="flex flex-col gap-3 rounded-md border p-3 md:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">Tracking</span>
|
||||||
|
|
||||||
|
{tracking.serialNumberTracking && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tracksSerialNumbers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="tracksSerialNumbers" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="tracksSerialNumbers" className="text-sm font-medium cursor-pointer">Track serial numbers</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tracking.batchTracking && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tracksBatches"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="tracksBatches" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="tracksBatches" className="text-sm font-medium cursor-pointer">Track batches</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tracking.expiryDateTracking && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tracksExpiryDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="tracksExpiryDate" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="tracksExpiryDate" className="text-sm font-medium cursor-pointer">Track expiry date</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tracking.warrantyTracking && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hasWarranty"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="hasWarranty" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="hasWarranty" className="text-sm font-medium cursor-pointer">Has warranty</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasWarranty && tracking.customWarrantyOverride && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowCustomWarrantyPeriod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="allowCustomWarrantyPeriod" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||||
|
<label htmlFor="allowCustomWarrantyPeriod" className="text-sm font-medium cursor-pointer">Use a custom warranty period</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.watch('allowCustomWarrantyPeriod') && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="warrantyPeriodDays"
|
||||||
|
label="Warranty period (days)"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id="warrantyPeriodDays"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={field.value === undefined ? '' : String(field.value)}
|
||||||
|
onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="System default"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isActive"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isActive"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Product'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,290 +1,40 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
import type { ItemSummaryResponse } from '@/types/api'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, getApiError } from '@/lib/api/index'
|
|
||||||
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
|
|
||||||
|
|
||||||
const schema = z.object({
|
import { ItemForm } from './item-form'
|
||||||
sku: z.string().min(1, 'SKU is required'),
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
categoryId: z.string().min(1, 'Category is required'),
|
|
||||||
brandId: z.string().optional(),
|
|
||||||
baseUomId: z.string().min(1, 'Base UOM is required'),
|
|
||||||
salePrice: z.number().min(0, 'Sale price must be 0 or more'),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface ItemSheetProps {
|
interface ItemSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
item: ItemSummaryResponse | null
|
item: ItemSummaryResponse
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
||||||
const isEdit = !!item
|
|
||||||
|
|
||||||
const [categories, setCategories] = useState<CategoryResponse[]>([])
|
|
||||||
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
|
|
||||||
const [uomsList, setUomsList] = useState<UomResponse[]>([])
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
sku: '',
|
|
||||||
name: '',
|
|
||||||
categoryId: '',
|
|
||||||
brandId: undefined,
|
|
||||||
baseUomId: '',
|
|
||||||
salePrice: 0,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
Promise.all([
|
|
||||||
categoriesApi.list(),
|
|
||||||
brandsApi.list(),
|
|
||||||
uomsApi.list(),
|
|
||||||
]).then(([cats, brandsRes, uomsRes]) => {
|
|
||||||
setCategories(cats)
|
|
||||||
setBrandsList(brandsRes)
|
|
||||||
setUomsList(uomsRes)
|
|
||||||
}).catch((err) => {
|
|
||||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
|
||||||
})
|
|
||||||
|
|
||||||
form.reset(
|
|
||||||
item
|
|
||||||
? {
|
|
||||||
sku: item.sku,
|
|
||||||
name: item.name,
|
|
||||||
categoryId: item.categoryId,
|
|
||||||
brandId: item.brandId ?? undefined,
|
|
||||||
baseUomId: item.baseUomId,
|
|
||||||
salePrice: item.salePrice,
|
|
||||||
isActive: item.isActive,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
sku: '',
|
|
||||||
name: '',
|
|
||||||
categoryId: '',
|
|
||||||
brandId: undefined,
|
|
||||||
baseUomId: '',
|
|
||||||
salePrice: 0,
|
|
||||||
isActive: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [open, item])
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
...values,
|
|
||||||
brandId: values.brandId || null,
|
|
||||||
}
|
|
||||||
if (isEdit) {
|
|
||||||
await itemsApi.update(item!.id, body)
|
|
||||||
toast.add({ title: 'Item updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
await itemsApi.create(body)
|
|
||||||
toast.add({ title: 'Item created', type: 'success' })
|
|
||||||
}
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? 'Edit Item' : 'Add Item'}</DialogTitle>
|
<DialogTitle>Edit Product</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Update the product details below.</DialogDescription>
|
||||||
{isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
<ItemForm
|
||||||
<FieldGroup>
|
item={item}
|
||||||
<FormField
|
onSuccess={() => {
|
||||||
control={form.control}
|
onSuccess()
|
||||||
name="sku"
|
onClose()
|
||||||
label="SKU"
|
}}
|
||||||
required
|
onCancel={onClose}
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="sku"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. ITEM-001"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
label="Item Name"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. Coca-Cola 330ml"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="categoryId"
|
|
||||||
label="Category"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select category..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<SelectItem key={cat.id} value={cat.id}>
|
|
||||||
{cat.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="brandId"
|
|
||||||
label="Brand"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string ?? ''} onValueChange={(v) => field.onChange(v === '' ? undefined : v)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select brand..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">None</SelectItem>
|
|
||||||
{brandsList.map((b) => (
|
|
||||||
<SelectItem key={b.id} value={b.id}>
|
|
||||||
{b.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="baseUomId"
|
|
||||||
label="Base UOM"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select unit of measure..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{uomsList.map((u) => (
|
|
||||||
<SelectItem key={u.id} value={u.id}>
|
|
||||||
{u.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="salePrice"
|
|
||||||
label="Sale Price"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="salePrice"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
value={String(field.value as number)}
|
|
||||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="0.00"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isActive"
|
|
||||||
checked={field.value as boolean}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Item'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
|
|||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Item Details</DialogTitle>
|
<DialogTitle>Product Details</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -44,6 +44,27 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
|
|||||||
<Row label="Brand" value={item.brandName ?? '—'} />
|
<Row label="Brand" value={item.brandName ?? '—'} />
|
||||||
<Row label="Base UOM" value={item.baseUomName} />
|
<Row label="Base UOM" value={item.baseUomName} />
|
||||||
<Row label="Sale Price" value={`Rs. ${item.salePrice?.toFixed(2) ?? '0.00'}`} />
|
<Row label="Sale Price" value={`Rs. ${item.salePrice?.toFixed(2) ?? '0.00'}`} />
|
||||||
|
<Row
|
||||||
|
label="Tracking"
|
||||||
|
value={
|
||||||
|
item.tracksSerialNumbers || item.tracksBatches || item.tracksExpiryDate || item.hasWarranty ? (
|
||||||
|
<span className="flex flex-wrap justify-end gap-1">
|
||||||
|
{item.tracksSerialNumbers && <Badge variant="outline">Serial</Badge>}
|
||||||
|
{item.tracksBatches && <Badge variant="outline">Batch</Badge>}
|
||||||
|
{item.tracksExpiryDate && <Badge variant="outline">Expiry</Badge>}
|
||||||
|
{item.hasWarranty && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
Warranty {item.allowCustomWarrantyPeriod && item.warrantyPeriodDays != null
|
||||||
|
? `${item.warrantyPeriodDays}d`
|
||||||
|
: '(default period)'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Row label="Sync Source" value={item.syncSource} />
|
<Row label="Sync Source" value={item.syncSource} />
|
||||||
<Row label="Status" value={<Badge variant={item.isActive ? 'default' : 'secondary'}>{item.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
<Row label="Status" value={<Badge variant={item.isActive ? 'default' : 'secondary'}>{item.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
||||||
<Row label="Created At" value={fmtDate(item.createdAt)} />
|
<Row label="Created At" value={fmtDate(item.createdAt)} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import { ItemSheet } from './item-sheet'
|
|||||||
import { ItemViewSheet } from './item-view-sheet'
|
import { ItemViewSheet } from './item-view-sheet'
|
||||||
|
|
||||||
export function ItemsClient() {
|
export function ItemsClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<ItemSummaryResponse[]>([])
|
const [data, setData] = useState<ItemSummaryResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
@@ -34,7 +36,7 @@ export function ItemsClient() {
|
|||||||
setData(getPaginatedItems(res))
|
setData(getPaginatedItems(res))
|
||||||
setTotal(res.total)
|
setTotal(res.total)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load products', description: getApiError(err), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -44,11 +46,6 @@ export function ItemsClient() {
|
|||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
}, [fetchData, page, pageSize])
|
}, [fetchData, page, pageSize])
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
setEditing(null)
|
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(item: ItemSummaryResponse) {
|
function openEdit(item: ItemSummaryResponse) {
|
||||||
setEditing(item)
|
setEditing(item)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
@@ -62,10 +59,10 @@ export function ItemsClient() {
|
|||||||
async function handleDelete(item: ItemSummaryResponse) {
|
async function handleDelete(item: ItemSummaryResponse) {
|
||||||
try {
|
try {
|
||||||
await itemsApi.remove(item.id)
|
await itemsApi.remove(item.id)
|
||||||
toast.add({ title: 'Item deactivated', type: 'success' })
|
toast.add({ title: 'Product deactivated', type: 'success' })
|
||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to deactivate item', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to deactivate product', description: getApiError(err), type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +70,7 @@ export function ItemsClient() {
|
|||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
serverSide: true,
|
serverSide: true,
|
||||||
stickyHeader: true,
|
stickyHeader: true,
|
||||||
emptyMessage: 'No items yet. Add one to get started.',
|
emptyMessage: 'No products yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@@ -137,17 +134,17 @@ export function ItemsClient() {
|
|||||||
actions: () => [
|
actions: () => [
|
||||||
{
|
{
|
||||||
action: 'view',
|
action: 'view',
|
||||||
label: 'View item',
|
label: 'View product',
|
||||||
onClick: openView,
|
onClick: openView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit item',
|
label: 'Edit product',
|
||||||
onClick: openEdit,
|
onClick: openEdit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'cancel',
|
action: 'cancel',
|
||||||
label: 'Delete item',
|
label: 'Delete product',
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
visible: (target) => target.isActive,
|
visible: (target) => target.isActive,
|
||||||
},
|
},
|
||||||
@@ -159,14 +156,14 @@ export function ItemsClient() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Items</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Products</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
Products available for sale and distribution
|
Products available for sale and distribution
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={() => router.push('/catalogue/items/new')}>
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
Add Item
|
Add Product
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,12 +178,14 @@ export function ItemsClient() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
<ItemSheet
|
<ItemSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
item={editing}
|
item={editing}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ItemViewSheet
|
<ItemViewSheet
|
||||||
open={viewOpen}
|
open={viewOpen}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
import { ItemForm } from '../_components/item-form'
|
||||||
|
|
||||||
|
export default function NewItemPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/catalogue/items')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-4xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to products">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Add Product</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Add a new catalogue product.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<ItemForm item={null} onSuccess={goBack} onCancel={goBack} twoColumn />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Package01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { FieldLabel, FieldDescription } from '@/components/ui/field'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
serialNumberTracking: boolean
|
||||||
|
batchTracking: boolean
|
||||||
|
expiryDateTracking: boolean
|
||||||
|
warrantyTracking: boolean
|
||||||
|
customWarrantyOverride: boolean
|
||||||
|
defaultWarrantyPeriodDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: FormState = {
|
||||||
|
serialNumberTracking: false,
|
||||||
|
batchTracking: false,
|
||||||
|
expiryDateTracking: false,
|
||||||
|
warrantyTracking: false,
|
||||||
|
customWarrantyOverride: false,
|
||||||
|
defaultWarrantyPeriodDays: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapabilityToggleProps {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
checked: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldDescription className="text-xs leading-relaxed">{description}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 pt-0.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{checked ? 'ON' : 'OFF'}</span>
|
||||||
|
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={(v) => onCheckedChange(!!v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Products configuration card: tracking capability switches and warranty defaults, saved immediately. */
|
||||||
|
export function ProductCapabilitiesCard() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [form, setForm] = useState<FormState>(DEFAULTS)
|
||||||
|
const [pendingKeys, setPendingKeys] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sysConfig.getAll()
|
||||||
|
.then((configs) => {
|
||||||
|
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
|
||||||
|
setForm({
|
||||||
|
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
|
||||||
|
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
|
||||||
|
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
|
||||||
|
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
|
||||||
|
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
|
||||||
|
defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) {
|
||||||
|
const previous = form[formKey]
|
||||||
|
setForm(f => ({ ...f, [formKey]: value }))
|
||||||
|
setPendingKeys(p => ({ ...p, [configKey]: true }))
|
||||||
|
try {
|
||||||
|
await sysConfig.set(configKey, String(value), user.id)
|
||||||
|
} catch (err) {
|
||||||
|
setForm(f => ({ ...f, [formKey]: previous }))
|
||||||
|
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setPendingKeys(p => ({ ...p, [configKey]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDefaultWarrantyDays(value: number) {
|
||||||
|
const previous = form.defaultWarrantyPeriodDays
|
||||||
|
setForm(f => ({ ...f, defaultWarrantyPeriodDays: value }))
|
||||||
|
if (value === previous) return
|
||||||
|
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true }))
|
||||||
|
try {
|
||||||
|
await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id)
|
||||||
|
} catch (err) {
|
||||||
|
setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous }))
|
||||||
|
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<Card className="max-w-4xl rounded-3xl border-border/60">
|
||||||
|
<CardHeader className="border-b border-border/60 pb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<HugeiconsIcon icon={Package01Icon} size={18} />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">Products</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="mt-3">
|
||||||
|
Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately;
|
||||||
|
items then opt into the enabled capabilities from the item's edit form.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<CapabilityToggle
|
||||||
|
id="serialNumberTracking"
|
||||||
|
label="Serial number tracking"
|
||||||
|
description="Track individual units of an item by a unique serial number."
|
||||||
|
checked={form.serialNumberTracking}
|
||||||
|
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
|
||||||
|
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CapabilityToggle
|
||||||
|
id="batchTracking"
|
||||||
|
label="Batch / lot tracking"
|
||||||
|
description="Group stock into batches or lots for traceability."
|
||||||
|
checked={form.batchTracking}
|
||||||
|
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
|
||||||
|
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CapabilityToggle
|
||||||
|
id="expiryDateTracking"
|
||||||
|
label="Expiry date tracking"
|
||||||
|
description="Record and monitor expiry dates on perishable stock."
|
||||||
|
checked={form.expiryDateTracking}
|
||||||
|
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
|
||||||
|
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CapabilityToggle
|
||||||
|
id="warrantyTracking"
|
||||||
|
label="Warranty periods"
|
||||||
|
description="Attach a warranty period to items at the time of sale."
|
||||||
|
checked={form.warrantyTracking}
|
||||||
|
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
|
||||||
|
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.warrantyTracking && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-5" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
Warranty defaults
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<CapabilityToggle
|
||||||
|
id="customWarrantyOverride"
|
||||||
|
label="Per-item custom warranty"
|
||||||
|
description="Allow individual items to override the default warranty period."
|
||||||
|
checked={form.customWarrantyOverride}
|
||||||
|
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
|
||||||
|
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4">
|
||||||
|
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
|
||||||
|
Default warranty period
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="defaultWarrantyPeriodDays"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={String(form.defaultWarrantyPeriodDays)}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))}
|
||||||
|
onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)}
|
||||||
|
className="max-w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">days</span>
|
||||||
|
</div>
|
||||||
|
<FieldDescription className="text-xs">
|
||||||
|
Applied to items that don't set their own warranty period.
|
||||||
|
</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
||||||
|
|
||||||
|
export default function ConfigurationPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Configuration</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductCapabilitiesCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { RegionResponse, RegionManagerResponse, UserResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const MANAGER_ROLE = 'RegionalManager'
|
||||||
|
|
||||||
|
interface RegionManagerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
region: RegionResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegionManagerDialog({ open, onClose, region }: RegionManagerDialogProps) {
|
||||||
|
const [current, setCurrent] = useState<RegionManagerResponse | null>(null)
|
||||||
|
const [candidates, setCandidates] = useState<UserResponse[]>([])
|
||||||
|
const [selected, setSelected] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !region) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setSelected('')
|
||||||
|
setCurrent(null)
|
||||||
|
Promise.all([
|
||||||
|
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
|
||||||
|
usersApi.list({ pageSize: 500 }),
|
||||||
|
])
|
||||||
|
.then(([manager, userRes]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCurrent(manager)
|
||||||
|
setSelected(manager?.userId ?? '')
|
||||||
|
setCandidates(getPaginatedItems(userRes).filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => !cancelled && setLoading(false))
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [open, region])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!region || !selected) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await regionsApi.setManager(region.id, selected)
|
||||||
|
toast.add({ title: 'Region manager assigned', type: 'success' })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to assign manager', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!region) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await regionsApi.removeManager(region.id)
|
||||||
|
toast.add({ title: 'Region manager removed', type: 'success' })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to remove manager', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && !saving && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Region Manager</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{region ? `${region.code} – ${region.name}. ` : ''}
|
||||||
|
Each region has one manager, chosen from users with the RegionalManager role.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current manager:{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{loading ? 'Loading…' : current ? `${current.fullName} (${current.username})` : 'None'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Select value={selected} onValueChange={(v) => setSelected(v ?? '')} disabled={loading || saving}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={candidates.length ? 'Select a manager' : 'No RegionalManager users available'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{candidates.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.fullName} ({u.username})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{current && (
|
||||||
|
<Button type="button" variant="destructive" onClick={remove} disabled={saving}>
|
||||||
|
Remove manager
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={save} disabled={saving || loading || !selected || selected === current?.userId}>
|
||||||
|
{saving ? 'Saving…' : 'Assign manager'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useEffect } from 'react'
|
|||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
import { FormField } from '@/components/shared/form-field'
|
import { FormField } from '@/components/shared/form-field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,8 +19,8 @@ import { regions as regionsApi, getApiError } from '@/lib/api/index'
|
|||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
code: z.string().max(50, 'Code must be under 50 characters').optional(),
|
||||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'),
|
name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'),
|
||||||
isActive: z.boolean(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
type FormValues = z.infer<typeof schema>
|
||||||
@@ -30,30 +29,39 @@ interface RegionSheetProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
region: RegionResponse | null
|
region: RegionResponse | null
|
||||||
|
readOnly?: boolean
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetProps) {
|
export function RegionSheet({ open, onClose, region, readOnly = false, onSuccess }: RegionSheetProps) {
|
||||||
const isEdit = !!region
|
const isEdit = !!region
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { name: '', isActive: true },
|
defaultValues: { code: '', name: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
form.reset(region ? { name: region.name, isActive: region.isActive } : { name: '', isActive: true })
|
form.reset(region ? { code: region.code, name: region.name } : { code: '', name: '' })
|
||||||
|
if (!region) {
|
||||||
|
// Suggest the next code; the user can overwrite it, or clear it to let the server generate one.
|
||||||
|
regionsApi.nextCode()
|
||||||
|
.then((res) => {
|
||||||
|
if (!form.getValues('code')) form.setValue('code', res.code)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [open, region])
|
}, [open, region])
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await regionsApi.update(region!.id, values)
|
await regionsApi.update(region!.id, { name: values.name, code: values.code?.trim() || undefined })
|
||||||
toast.add({ title: 'Region updated', type: 'success' })
|
toast.add({ title: 'Region updated', type: 'success' })
|
||||||
} else {
|
} else {
|
||||||
await regionsApi.create(values)
|
await regionsApi.create({ name: values.name, code: values.code?.trim() || undefined })
|
||||||
toast.add({ title: 'Region created', type: 'success' })
|
toast.add({ title: 'Region created', type: 'success' })
|
||||||
}
|
}
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@@ -67,14 +75,37 @@ export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetPro
|
|||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
|
<DialogTitle>{readOnly ? 'View Region' : isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'}
|
{readOnly ? 'Region details.' : isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
label="Region Code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={(field.value as string) ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. REG-0001"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isEdit && !readOnly && (
|
||||||
|
<p className="-mt-3 text-xs text-muted-foreground">
|
||||||
|
Suggested automatically — edit it, or clear it to auto-generate on save.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -89,36 +120,20 @@ export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetPro
|
|||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
placeholder="e.g. Western Province"
|
placeholder="e.g. Western Province"
|
||||||
|
disabled={readOnly}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isActive"
|
|
||||||
checked={field.value as boolean}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{readOnly ? 'Close' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
{!readOnly && (<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Region'}
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Region'}
|
||||||
</Button>
|
</Button>)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { DataTable } from '@/components/shared/data-table'
|
|||||||
import type { TableConfig } from '@/components/shared/data-table'
|
import type { TableConfig } from '@/components/shared/data-table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RegionSheet } from './region-sheet'
|
import { RegionSheet } from './region-sheet'
|
||||||
|
import { RegionManagerDialog } from './region-manager-dialog'
|
||||||
|
|
||||||
export function RegionsClient() {
|
export function RegionsClient() {
|
||||||
const [data, setData] = useState<RegionResponse[]>([])
|
const [data, setData] = useState<RegionResponse[]>([])
|
||||||
@@ -23,6 +25,10 @@ export function RegionsClient() {
|
|||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
||||||
|
const [viewOnly, setViewOnly] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState<RegionResponse | null>(null)
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false)
|
||||||
|
const [managerRegion, setManagerRegion] = useState<RegionResponse | null>(null)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -43,24 +49,34 @@ export function RegionsClient() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
setViewOnly(false)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(region: RegionResponse) {
|
function openEdit(region: RegionResponse) {
|
||||||
setEditing(region)
|
setEditing(region)
|
||||||
|
setViewOnly(false)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(region: RegionResponse) {
|
function openView(region: RegionResponse) {
|
||||||
|
setEditing(region)
|
||||||
|
setViewOnly(true)
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleting) return
|
||||||
|
setDeleteBusy(true)
|
||||||
try {
|
try {
|
||||||
await regionsApi.update(region.id, { isActive: !region.isActive })
|
await regionsApi.remove(deleting.id)
|
||||||
toast.add({
|
toast.add({ title: 'Region deleted', type: 'success' })
|
||||||
title: region.isActive ? 'Region deactivated' : 'Region activated',
|
setDeleting(null)
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to update region', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to delete region', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +87,13 @@ export function RegionsClient() {
|
|||||||
emptyMessage: 'No regions yet. Add one to get started.',
|
emptyMessage: 'No regions yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
columns: [
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
title: 'Code',
|
||||||
|
fields: [{ key: 'code', primary: true }],
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
title: 'Region',
|
title: 'Region',
|
||||||
@@ -103,6 +126,16 @@ export function RegionsClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'confirm',
|
||||||
|
label: 'Manage region manager',
|
||||||
|
onClick: (region: RegionResponse) => setManagerRegion(region),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
label: 'View region',
|
||||||
|
onClick: openView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit region',
|
label: 'Edit region',
|
||||||
@@ -110,19 +143,12 @@ export function RegionsClient() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'deactivate',
|
action: 'deactivate',
|
||||||
label: 'Deactivate region',
|
label: 'Delete region',
|
||||||
onClick: toggleActive,
|
onClick: (region: RegionResponse) => setDeleting(region),
|
||||||
visible: (target) => target.isActive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'activate',
|
|
||||||
label: 'Activate region',
|
|
||||||
onClick: toggleActive,
|
|
||||||
visible: (target) => !target.isActive,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}), [pageSize, openEdit, toggleActive])
|
}), [pageSize, openEdit, openView])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,8 +180,34 @@ export function RegionsClient() {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
region={editing}
|
region={editing}
|
||||||
|
readOnly={viewOnly}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
/>
|
/>
|
||||||
|
<RegionManagerDialog
|
||||||
|
open={!!managerRegion}
|
||||||
|
onClose={() => setManagerRegion(null)}
|
||||||
|
region={managerRegion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={!!deleting} onOpenChange={(v) => !v && !deleteBusy && setDeleting(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete region?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{deleting ? `${deleting.code} – ${deleting.name} will be removed from the regions list.` : null}
|
||||||
|
{' '}This action cannot be undone from this screen.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDeleting(null)} disabled={deleteBusy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" onClick={confirmDelete} disabled={deleteBusy}>
|
||||||
|
{deleteBusy ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const STEPS = [
|
|||||||
n: 3,
|
n: 3,
|
||||||
label: 'Items',
|
label: 'Items',
|
||||||
title: 'What should be loaded from Shop POs?',
|
title: 'What should be loaded from Shop POs?',
|
||||||
help: "Expand a shop to tick items and set quantities. If the main warehouse doesn't have enough stock, use \"Take remaining from another warehouse\" to top up the rest.",
|
help: "Expand a shop to tick items and set quantities. If the main warehouse can't cover an item, a top-up row is added automatically — just pick which other warehouse to take the rest from.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: 4,
|
n: 4,
|
||||||
@@ -321,10 +321,16 @@ function LoadingSheetForm() {
|
|||||||
let changed = false
|
let changed = false
|
||||||
const next: Record<string, PickedLine> = {}
|
const next: Record<string, PickedLine> = {}
|
||||||
for (const [key, l] of Object.entries(prev)) {
|
for (const [key, l] of Object.entries(prev)) {
|
||||||
const extras = l.extras.filter((e) => e.warehouseId !== warehouseId)
|
let extras = l.extras.filter((e) => e.warehouseId !== warehouseId)
|
||||||
const avail = stockByWh[warehouseId]?.[l.itemId] ?? 0
|
const avail = stockByWh[warehouseId]?.[l.itemId] ?? 0
|
||||||
const quantity = Math.min(l.quantity, avail)
|
const quantity = Math.min(l.quantity, avail)
|
||||||
const checked = l.checked && (quantity > 0 || extras.length > 0)
|
const checked = l.checked && (quantity > 0 || extras.length > 0)
|
||||||
|
// Pre-add an empty top-up row if the new main warehouse leaves this line short
|
||||||
|
// and one isn't already pending, mirroring toggleLine's auto-suggestion.
|
||||||
|
const shortBy = l.remainingQty - quantity - extras.reduce((sum, e) => sum + (Number(e.quantity) || 0), 0)
|
||||||
|
if (checked && shortBy > 0 && !extras.some((e) => !e.warehouseId)) {
|
||||||
|
extras = [...extras, { warehouseId: '', quantity: 0 }]
|
||||||
|
}
|
||||||
if (extras.length !== l.extras.length || quantity !== l.quantity || checked !== l.checked) changed = true
|
if (extras.length !== l.extras.length || quantity !== l.quantity || checked !== l.checked) changed = true
|
||||||
next[key] = { ...l, extras, quantity, checked }
|
next[key] = { ...l, extras, quantity, checked }
|
||||||
}
|
}
|
||||||
@@ -452,7 +458,12 @@ function LoadingSheetForm() {
|
|||||||
const avail = availIn(warehouseId, line.itemId)
|
const avail = availIn(warehouseId, line.itemId)
|
||||||
if (!stockLoaded || avail <= 0) return
|
if (!stockLoaded || avail <= 0) return
|
||||||
const quantity = Math.max(0, Math.min(line.remainingQty - extrasTotal(line), avail))
|
const quantity = Math.max(0, Math.min(line.remainingQty - extrasTotal(line), avail))
|
||||||
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked, quantity } }))
|
// The main warehouse can't cover the full remaining quantity, so a top-up row is
|
||||||
|
// pre-added (still editable/removable) instead of making the user click "Take
|
||||||
|
// remaining from another warehouse" themselves.
|
||||||
|
const shortBy = line.remainingQty - quantity - extrasTotal(line)
|
||||||
|
const extras = shortBy > 0 ? [...line.extras, { warehouseId: '', quantity: 0 }] : line.extras
|
||||||
|
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked, quantity, extras } }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
|
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
|
||||||
@@ -912,10 +923,18 @@ function LoadingSheetForm() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 pb-2"></td>
|
<td className="px-3 pb-2"></td>
|
||||||
<td className="px-3 pb-2" colSpan={7}>
|
<td className="px-3 pb-2" colSpan={7}>
|
||||||
|
{line.extras.some((e) => !e.warehouseId) ? (
|
||||||
|
<span className="text-xs text-amber-600">
|
||||||
|
{shortBy} short — pick a warehouse above to top it up, or it stays open on the PO for another sheet.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<span className="mr-3 text-xs text-muted-foreground">{shortBy} will stay open on the PO for another sheet</span>
|
<span className="mr-3 text-xs text-muted-foreground">{shortBy} will stay open on the PO for another sheet</span>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => addExtra(line)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => addExtra(line)}>
|
||||||
Take remaining from another warehouse
|
Take remaining from another warehouse
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
import { DataTable } from '@/components/shared/data-table'
|
import { DataTable } from '@/components/shared/data-table'
|
||||||
@@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { GrnResponse } from '@/types/api'
|
import type { GrnResponse } from '@/types/api'
|
||||||
import { GrnSheet } from './grn-sheet'
|
|
||||||
import { GrnViewSheet } from './grn-view-sheet'
|
import { GrnViewSheet } from './grn-view-sheet'
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
@@ -18,12 +18,12 @@ const fmtDate = (v: string) =>
|
|||||||
const truncId = (id: string) => id.substring(0, 8)
|
const truncId = (id: string) => id.substring(0, 8)
|
||||||
|
|
||||||
export function GrnClient() {
|
export function GrnClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<GrnResponse[]>([])
|
const [data, setData] = useState<GrnResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const [viewing, setViewing] = useState<GrnResponse | null>(null)
|
const [viewing, setViewing] = useState<GrnResponse | null>(null)
|
||||||
const [viewOpen, setViewOpen] = useState(false)
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
|
|
||||||
@@ -71,7 +71,9 @@ export function GrnClient() {
|
|||||||
fields: ['purchaseOrderId'],
|
fields: ['purchaseOrderId'],
|
||||||
width: 140,
|
width: 140,
|
||||||
cellRender: (row) => (
|
cellRender: (row) => (
|
||||||
<span className="font-mono text-sm">{truncId(row.purchaseOrderId)}</span>
|
row.purchaseOrderId
|
||||||
|
? <span className="font-mono text-sm">{truncId(row.purchaseOrderId)}</span>
|
||||||
|
: <span className="text-sm text-muted-foreground">Direct receipt</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,13 +121,19 @@ export function GrnClient() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
Stock received into a warehouse against a purchase order
|
Stock received into a warehouse, against a purchase order or directly
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setSheetOpen(true)}>
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||||
Record Receipt
|
Direct Receipt
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => router.push('/procurement/grn/po/new')}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||||
|
Receipt Against PO
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -139,12 +147,6 @@ export function GrnClient() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GrnSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GrnViewSheet
|
<GrnViewSheet
|
||||||
open={viewOpen}
|
open={viewOpen}
|
||||||
onClose={() => setViewOpen(false)}
|
onClose={() => setViewOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import type { GrnBatchLineBody } from '@/lib/api/procurement'
|
||||||
|
import type { ItemSummaryResponse } from '@/types/api'
|
||||||
|
|
||||||
|
export function parseSerials(text: string): string[] {
|
||||||
|
return text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BatchRow = { batchNumber: string; expiryDate: string; quantity: number }
|
||||||
|
|
||||||
|
interface Progress {
|
||||||
|
kind: 'serial' | 'batch'
|
||||||
|
done: number
|
||||||
|
complete: boolean
|
||||||
|
duplicates: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far a tracked line is from satisfying the server rules; null for untracked items. */
|
||||||
|
export function trackingProgress(
|
||||||
|
info: ItemSummaryResponse | undefined,
|
||||||
|
accepted: number,
|
||||||
|
serialText: string,
|
||||||
|
batches: BatchRow[],
|
||||||
|
): Progress | null {
|
||||||
|
if (info?.tracksSerialNumbers) {
|
||||||
|
const serials = parseSerials(serialText)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const dupes = new Set<string>()
|
||||||
|
for (const s of serials) {
|
||||||
|
if (seen.has(s)) dupes.add(s)
|
||||||
|
seen.add(s)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'serial',
|
||||||
|
done: serials.length,
|
||||||
|
duplicates: [...dupes],
|
||||||
|
complete: accepted === 0 || (serials.length === accepted && dupes.size === 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info?.tracksBatches || info?.tracksExpiryDate) {
|
||||||
|
const done = batches.reduce((s, b) => s + (b.quantity || 0), 0)
|
||||||
|
const rowsOk = batches.every(
|
||||||
|
b =>
|
||||||
|
b.quantity > 0 &&
|
||||||
|
(!info.tracksBatches || b.batchNumber.trim() !== '') &&
|
||||||
|
(!info.tracksExpiryDate || b.expiryDate !== ''),
|
||||||
|
)
|
||||||
|
return { kind: 'batch', done, duplicates: [], complete: accepted === 0 || (done === accepted && rowsOk) }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineTrackingProps {
|
||||||
|
info: ItemSummaryResponse
|
||||||
|
name: string
|
||||||
|
accepted: number
|
||||||
|
serialText: string
|
||||||
|
batches: BatchRow[]
|
||||||
|
onSerialText: (v: string) => void
|
||||||
|
onBatches: (v: BatchRow[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact status chip for one GRN line; clicking it opens a panel for the actual serial/batch
|
||||||
|
* entry, so long serial lists don't stretch the form. Serial tracking takes precedence over
|
||||||
|
* batch/expiry (same as the server). Warranty is not entered here — its clock starts at sale.
|
||||||
|
*/
|
||||||
|
export function LineTracking({ info, name, accepted, serialText, batches, onSerialText, onBatches }: LineTrackingProps) {
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
|
const [scanValue, setScanValue] = useState('')
|
||||||
|
const [genPrefix, setGenPrefix] = useState(`${info.sku}-`)
|
||||||
|
const [genStart, setGenStart] = useState(1)
|
||||||
|
const progress = trackingProgress(info, accepted, serialText, batches)
|
||||||
|
if (!progress) return null
|
||||||
|
|
||||||
|
// Scanner (keyboard-wedge) input: each scan ends with Enter and appends one serial.
|
||||||
|
const addScanned = () => {
|
||||||
|
const value = scanValue.trim()
|
||||||
|
if (!value) return
|
||||||
|
if (parseSerials(serialText).includes(value)) {
|
||||||
|
toast.add({ title: `Serial ${value} is already in the list`, type: 'error' })
|
||||||
|
} else {
|
||||||
|
onSerialText(serialText.trim() ? `${serialText.trimEnd()}\n${value}` : value)
|
||||||
|
}
|
||||||
|
setScanValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appends generated serials (prefix + zero-padded sequence) for the units still missing,
|
||||||
|
// skipping any value already in the list. They stay editable in the list below.
|
||||||
|
const generateSerials = () => {
|
||||||
|
const existing = parseSerials(serialText)
|
||||||
|
const taken = new Set(existing)
|
||||||
|
const missing = Math.max(0, accepted - existing.length)
|
||||||
|
const generated: string[] = []
|
||||||
|
for (let n = genStart; generated.length < missing; n++) {
|
||||||
|
const sn = `${genPrefix}${String(n).padStart(4, '0')}`
|
||||||
|
if (!taken.has(sn)) generated.push(sn)
|
||||||
|
}
|
||||||
|
onSerialText([...existing, ...generated].join('\n'))
|
||||||
|
setGenStart(genStart + missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSerial = progress.kind === 'serial'
|
||||||
|
const noun = isSerial ? 'serials' : info.tracksBatches ? 'batches' : 'expiry lots'
|
||||||
|
const warrantyNote = info.hasWarranty
|
||||||
|
? `Warranty: ${info.warrantyPeriodDays != null && info.allowCustomWarrantyPeriod ? `${info.warrantyPeriodDays} days` : 'system default period'}, starts at sale.`
|
||||||
|
: null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const today = now.toISOString().slice(0, 10)
|
||||||
|
const inThirtyDays = new Date(now)
|
||||||
|
inThirtyDays.setDate(inThirtyDays.getDate() + 30)
|
||||||
|
const soon = inThirtyDays.toISOString().slice(0, 10)
|
||||||
|
const update = (i: number, patch: Partial<BatchRow>) =>
|
||||||
|
onBatches(batches.map((b, j) => (j === i ? { ...b, ...patch } : b)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-full flex flex-wrap items-center gap-2 border-t pt-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={progress.complete ? 'border-green-600/40 text-green-700' : 'border-amber-500/50 text-amber-700'}
|
||||||
|
onClick={() => setPanelOpen(true)}
|
||||||
|
>
|
||||||
|
{progress.complete ? '✓' : 'Enter'} {noun} · {progress.done}/{accepted}
|
||||||
|
</Button>
|
||||||
|
{warrantyNote && <span className="text-xs text-muted-foreground">{warrantyNote}</span>}
|
||||||
|
|
||||||
|
<Dialog open={panelOpen} onOpenChange={setPanelOpen}>
|
||||||
|
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{name} ({info.sku}) — accepting {accepted}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||||
|
{isSerial ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Scan a serial number and press Enter"
|
||||||
|
value={scanValue}
|
||||||
|
onChange={e => setScanValue(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addScanned()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Prefix"
|
||||||
|
value={genPrefix}
|
||||||
|
onChange={e => setGenPrefix(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24"
|
||||||
|
value={genStart}
|
||||||
|
onChange={e => setGenStart(Math.max(1, Number(e.target.value)))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={accepted - progress.done <= 0}
|
||||||
|
onClick={generateSerials}
|
||||||
|
>
|
||||||
|
Auto-generate {Math.max(0, accepted - progress.done)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="min-h-40 w-full rounded-lg border bg-transparent px-3 py-2 font-mono text-xs"
|
||||||
|
placeholder="Serials appear here, one per line — type, paste or edit any of them"
|
||||||
|
value={serialText}
|
||||||
|
onChange={e => onSerialText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className={progress.done === accepted ? 'text-muted-foreground' : 'text-destructive'}>
|
||||||
|
{progress.done} of {accepted} entered
|
||||||
|
</span>
|
||||||
|
<button type="button" className="text-muted-foreground underline" onClick={() => onSerialText('')}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{progress.duplicates.length > 0 && (
|
||||||
|
<p className="text-xs text-destructive">Duplicate: {progress.duplicates.join(', ')}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{batches.map((b, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{info.tracksBatches && (
|
||||||
|
<Input
|
||||||
|
placeholder="Batch no."
|
||||||
|
value={b.batchNumber}
|
||||||
|
onChange={e => update(i, { batchNumber: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{info.tracksBatches && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => update(i, { batchNumber: `B${today.replaceAll('-', '')}-${i + 1}` })}
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{info.tracksExpiryDate && (
|
||||||
|
<Input type="date" value={b.expiryDate} onChange={e => update(i, { expiryDate: e.target.value })} />
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-24"
|
||||||
|
placeholder="Qty"
|
||||||
|
value={b.quantity}
|
||||||
|
onChange={e => update(i, { quantity: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onBatches(batches.filter((_, j) => j !== i))}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{info.tracksExpiryDate && b.expiryDate && b.expiryDate < today && (
|
||||||
|
<span className="text-xs text-destructive">Already expired.</span>
|
||||||
|
)}
|
||||||
|
{info.tracksExpiryDate && b.expiryDate && b.expiryDate >= today && b.expiryDate <= soon && (
|
||||||
|
<span className="text-xs text-amber-700">Expires within 30 days.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBatches([
|
||||||
|
...batches,
|
||||||
|
{ batchNumber: '', expiryDate: '', quantity: Math.max(0, accepted - progress.done) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add {info.tracksBatches ? 'batch' : 'expiry lot'}
|
||||||
|
</Button>
|
||||||
|
<span className={`text-xs ${progress.done === accepted ? 'text-muted-foreground' : 'text-destructive'}`}>
|
||||||
|
{progress.done} of {accepted} allocated
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" onClick={() => setPanelOpen(false)}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackedLineValues {
|
||||||
|
acceptedQuantity: number
|
||||||
|
serialText: string
|
||||||
|
batches: BatchRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the server-side rules in GrnService.ValidateLineShape so users get an inline message
|
||||||
|
* instead of a round trip. Serial tracking takes precedence over batch/expiry, as on the server.
|
||||||
|
* Returns the serial/batch payload for the line, or an error message.
|
||||||
|
*/
|
||||||
|
export function trackingPayload(
|
||||||
|
info: ItemSummaryResponse | undefined,
|
||||||
|
v: TrackedLineValues,
|
||||||
|
): { serialNumbers?: string[]; batches?: GrnBatchLineBody[] } | { error: string } {
|
||||||
|
if (info?.tracksSerialNumbers) {
|
||||||
|
const serials = parseSerials(v.serialText)
|
||||||
|
if (v.acceptedQuantity === 0) return {}
|
||||||
|
if (serials.length !== v.acceptedQuantity)
|
||||||
|
return { error: `enter exactly ${v.acceptedQuantity} serial number(s) (have ${serials.length}).` }
|
||||||
|
if (new Set(serials).size !== serials.length) return { error: 'duplicate serial numbers.' }
|
||||||
|
return { serialNumbers: serials }
|
||||||
|
}
|
||||||
|
if (info?.tracksBatches || info?.tracksExpiryDate) {
|
||||||
|
if (v.acceptedQuantity === 0) return {}
|
||||||
|
if (v.batches.length === 0) return { error: 'add at least one batch/expiry row.' }
|
||||||
|
if (v.batches.reduce((sum, b) => sum + b.quantity, 0) !== v.acceptedQuantity)
|
||||||
|
return { error: 'batch quantities must add up to the accepted quantity.' }
|
||||||
|
for (const b of v.batches) {
|
||||||
|
if (b.quantity <= 0) return { error: 'batch quantity must be greater than 0.' }
|
||||||
|
if (info.tracksBatches && !b.batchNumber.trim()) return { error: 'batch number is required.' }
|
||||||
|
if (info.tracksExpiryDate && !b.expiryDate) return { error: 'expiry date is required.' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
batches: v.batches.map(b => ({
|
||||||
|
batchNumber: info.tracksBatches ? b.batchNumber.trim() : null,
|
||||||
|
expiryDate: info.tracksExpiryDate && b.expiryDate ? `${b.expiryDate}T00:00:00.000Z` : null,
|
||||||
|
quantity: b.quantity,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ const fmtDateTime = (v: string) =>
|
|||||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fmtDate = (v: string) =>
|
||||||
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
interface GrnViewSheetProps {
|
interface GrnViewSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -40,7 +43,10 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Row label="Purchase Order" value={<span className="font-mono text-xs">{grn.purchaseOrderId}</span>} />
|
{grn.purchaseOrderId
|
||||||
|
? <Row label="Purchase Order" value={<span className="font-mono text-xs">{grn.purchaseOrderId}</span>} />
|
||||||
|
: <Row label="Type" value="Direct receipt (no purchase order)" />}
|
||||||
|
{grn.supplierName && <Row label="Supplier" value={grn.supplierName} />}
|
||||||
<Row label="Warehouse" value={grn.warehouseName} />
|
<Row label="Warehouse" value={grn.warehouseName} />
|
||||||
<Row label="Received At" value={fmtDateTime(grn.receivedAt)} />
|
<Row label="Received At" value={fmtDateTime(grn.receivedAt)} />
|
||||||
<Row label="Created" value={fmtDateTime(grn.createdAt)} />
|
<Row label="Created" value={fmtDateTime(grn.createdAt)} />
|
||||||
@@ -65,6 +71,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
|||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{item.itemName}
|
{item.itemName}
|
||||||
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
|
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
|
||||||
|
{item.batches.length > 0 && (
|
||||||
|
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{item.batches.map((b, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
{b.batchNumber ? `Batch ${b.batchNumber}` : 'Lot'} · qty {b.quantity}
|
||||||
|
{b.expiryDate && ` · expires ${fmtDate(b.expiryDate)}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{item.serialNumbers.length > 0 && (
|
||||||
|
<details className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer select-none">
|
||||||
|
{item.serialNumbers.length} serial number{item.serialNumbers.length === 1 ? '' : 's'}
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto font-mono">
|
||||||
|
{item.serialNumbers.map(sn => <li key={sn}>{sn}</li>)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
|
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
|
||||||
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
|
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { useFieldArray } from 'react-hook-form'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import type { GrnItemBody } from '@/lib/api/procurement'
|
||||||
|
import {
|
||||||
|
grn as grnApi,
|
||||||
|
warehouses as warehousesApi,
|
||||||
|
users as usersApi,
|
||||||
|
suppliers as suppliersApi,
|
||||||
|
items as itemsApi,
|
||||||
|
getApiError,
|
||||||
|
getPaginatedItems,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { WarehouseResponse, UserResponse, SupplierResponse, ItemSummaryResponse } from '@/types/api'
|
||||||
|
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
|
||||||
|
|
||||||
|
const NO_SUPPLIER = 'none'
|
||||||
|
|
||||||
|
const lineSchema = z.object({
|
||||||
|
itemId: z.string().min(1, 'Select an item'),
|
||||||
|
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||||
|
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||||
|
serialText: z.string(),
|
||||||
|
batches: z.array(z.object({ batchNumber: z.string(), expiryDate: z.string(), quantity: z.number() })),
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
warehouseId: z.string().min(1, 'Warehouse is required'),
|
||||||
|
supplierId: z.string(),
|
||||||
|
receivedByUserId: z.string().min(1, 'Received by is required'),
|
||||||
|
items: z.array(lineSchema).min(1, 'Add at least one item'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
const emptyLine = { itemId: '', acceptedQuantity: 1, rejectedQuantity: 0, serialText: '', batches: [] }
|
||||||
|
|
||||||
|
/** Receive goods into a warehouse with no purchase order. */
|
||||||
|
export default function NewDirectGrnPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/procurement/grn')
|
||||||
|
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
||||||
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
|
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
|
||||||
|
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { warehouseId: '', supplierId: NO_SUPPLIER, receivedByUserId: '', items: [{ ...emptyLine }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
|
usersApi.list({ pageSize: 200 }),
|
||||||
|
suppliersApi.list(),
|
||||||
|
itemsApi.list({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([ws, us, ss, its]) => {
|
||||||
|
setWarehousesList(getPaginatedItems(ws))
|
||||||
|
setUsersList(getPaginatedItems(us))
|
||||||
|
setSuppliersList(ss)
|
||||||
|
setItemsList(getPaginatedItems(its))
|
||||||
|
})
|
||||||
|
.catch(err =>
|
||||||
|
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const itemById = (id: string) => itemsList.find(i => i.id === id)
|
||||||
|
|
||||||
|
const watchedItems = form.watch('items')
|
||||||
|
const trackingIncomplete = watchedItems.some(v => {
|
||||||
|
const p = trackingProgress(itemById(v.itemId), v.acceptedQuantity, v.serialText, v.batches)
|
||||||
|
return p !== null && !p.complete
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const items: GrnItemBody[] = []
|
||||||
|
for (const v of values.items) {
|
||||||
|
const info = itemById(v.itemId)
|
||||||
|
if (v.acceptedQuantity + v.rejectedQuantity <= 0) {
|
||||||
|
toast.add({ title: `${info?.sku ?? 'Line'}: enter a quantity.`, type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = trackingPayload(info, v)
|
||||||
|
if ('error' in payload) {
|
||||||
|
toast.add({ title: `${info?.sku ?? 'Line'}: ${payload.error}`, type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
itemId: v.itemId,
|
||||||
|
acceptedQuantity: v.acceptedQuantity,
|
||||||
|
rejectedQuantity: v.rejectedQuantity,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await grnApi.createDirect({
|
||||||
|
warehouseId: values.warehouseId,
|
||||||
|
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
|
||||||
|
receivedByUserId: values.receivedByUserId,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Direct receipt recorded', type: 'success' })
|
||||||
|
router.push('/procurement/grn')
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-4xl flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={goBack}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Direct Receipt</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Receive stock into a warehouse without a purchase order.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="warehouseId"
|
||||||
|
label="Warehouse"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select warehouse..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{warehousesList.map(w => (
|
||||||
|
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="supplierId"
|
||||||
|
label="Supplier (optional)"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No supplier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_SUPPLIER}>No supplier</SelectItem>
|
||||||
|
{suppliersList.filter(s => s.isActive).map(s => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="receivedByUserId"
|
||||||
|
label="Received By"
|
||||||
|
description="Temporary — required until auth resolves the logged-in user automatically."
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select user..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{usersList.map(user => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">Items</p>
|
||||||
|
|
||||||
|
{fields.map((f, index) => {
|
||||||
|
const v = watchedItems[index]
|
||||||
|
const info = v ? itemById(v.itemId) : undefined
|
||||||
|
return (
|
||||||
|
<div key={f.id} className="grid grid-cols-[1fr_90px_90px_auto] items-start gap-3 rounded-xl border px-3 py-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`items.${index}.itemId`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value as string}
|
||||||
|
onValueChange={val => {
|
||||||
|
field.onChange(val)
|
||||||
|
// A different item has different tracking rules — drop stale entries.
|
||||||
|
form.setValue(`items.${index}.serialText`, '')
|
||||||
|
form.setValue(`items.${index}.batches`, [])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select item..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{itemsList.filter(i => i.isActive).map(i => (
|
||||||
|
<SelectItem key={i.id} value={i.id}>{i.sku} — {i.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`items.${index}.acceptedQuantity`}
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="Accepted"
|
||||||
|
value={Number(field.value as number)}
|
||||||
|
onChange={e => field.onChange(Number(e.target.value))}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`items.${index}.rejectedQuantity`}
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="Rejected"
|
||||||
|
value={Number(field.value as number)}
|
||||||
|
onChange={e => field.onChange(Number(e.target.value))}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={fields.length === 1}
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{info && v && (
|
||||||
|
<LineTracking
|
||||||
|
info={info}
|
||||||
|
name={info.name}
|
||||||
|
accepted={v.acceptedQuantity}
|
||||||
|
serialText={v.serialText}
|
||||||
|
batches={v.batches}
|
||||||
|
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
|
||||||
|
onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => append({ ...emptyLine })}>
|
||||||
|
Add item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting || trackingIncomplete}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+101
-44
@@ -1,6 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
import { useFieldArray } from 'react-hook-form'
|
import { useFieldArray } from 'react-hook-form'
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
import { FormField } from '@/components/shared/form-field'
|
import { FormField } from '@/components/shared/form-field'
|
||||||
@@ -13,31 +16,41 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import type { GrnItemBody } from '@/lib/api/procurement'
|
||||||
|
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
|
||||||
import {
|
import {
|
||||||
grn as grnApi,
|
grn as grnApi,
|
||||||
purchaseOrders as purchaseOrdersApi,
|
purchaseOrders as purchaseOrdersApi,
|
||||||
warehouses as warehousesApi,
|
warehouses as warehousesApi,
|
||||||
users as usersApi,
|
users as usersApi,
|
||||||
|
items as itemsApi,
|
||||||
getApiError,
|
getApiError,
|
||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import type { PurchaseOrderResponse, WarehouseResponse, UserResponse } from '@/types/api'
|
import type {
|
||||||
|
PurchaseOrderResponse,
|
||||||
|
WarehouseResponse,
|
||||||
|
UserResponse,
|
||||||
|
ItemSummaryResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
const batchLineSchema = z.object({
|
||||||
|
batchNumber: z.string(),
|
||||||
|
expiryDate: z.string(),
|
||||||
|
quantity: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
const grnItemSchema = z.object({
|
const grnItemSchema = z.object({
|
||||||
purchaseOrderItemId: z.string().min(1),
|
purchaseOrderItemId: z.string().min(1),
|
||||||
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
|
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||||
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
|
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
|
||||||
|
// One serial number per line (newline or comma separated) — used for serial-tracked items.
|
||||||
|
serialText: z.string(),
|
||||||
|
// Used for batch- and/or expiry-tracked items.
|
||||||
|
batches: z.array(batchLineSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -50,22 +63,20 @@ const schema = z.object({
|
|||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
interface GrnSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayIso() {
|
function todayIso() {
|
||||||
return new Date().toISOString().slice(0, 10)
|
return new Date().toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
export default function NewPoGrnPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/procurement/grn')
|
||||||
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
|
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
|
||||||
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
||||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||||
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
|
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
|
||||||
const [loadingPO, setLoadingPO] = useState(false)
|
const [loadingPO, setLoadingPO] = useState(false)
|
||||||
|
// Tracking flags per catalogue item, keyed by itemId — the PO line response doesn't carry them.
|
||||||
|
const [itemInfo, setItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -80,11 +91,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
|||||||
|
|
||||||
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
|
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
|
||||||
|
|
||||||
// Load reference data when sheet opens
|
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
|
||||||
|
|
||||||
|
// Block Save until every tracked line has its serials/batches fully entered.
|
||||||
|
const watchedItems = form.watch('items')
|
||||||
|
const trackingIncomplete = watchedItems.some((v, i) => {
|
||||||
|
const info = poItems[i] ? itemInfo[poItems[i].itemId] : undefined
|
||||||
|
const p = trackingProgress(info, v.acceptedQuantity, v.serialText, v.batches)
|
||||||
|
return p !== null && !p.complete
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load reference data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
|
||||||
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), receivedByUserId: '', items: [] })
|
|
||||||
setSelectedPO(null)
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
@@ -100,7 +118,7 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
|||||||
.catch(err =>
|
.catch(err =>
|
||||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||||
)
|
)
|
||||||
}, [open])
|
}, [])
|
||||||
|
|
||||||
// When PO changes, fetch its details and populate items
|
// When PO changes, fetch its details and populate items
|
||||||
async function handlePOChange(poId: string) {
|
async function handlePOChange(poId: string) {
|
||||||
@@ -113,14 +131,20 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
|||||||
setLoadingPO(true)
|
setLoadingPO(true)
|
||||||
try {
|
try {
|
||||||
const po = await purchaseOrdersApi.get(poId)
|
const po = await purchaseOrdersApi.get(poId)
|
||||||
|
const activeLines = (po.items ?? []).filter(item => !item.isCancelled)
|
||||||
|
|
||||||
|
const itemIds = [...new Set(activeLines.map(l => l.itemId))]
|
||||||
|
const details = await Promise.all(itemIds.map(id => itemsApi.get(id)))
|
||||||
|
setItemInfo(Object.fromEntries(details.map(d => [d.id, d])))
|
||||||
|
|
||||||
setSelectedPO(po)
|
setSelectedPO(po)
|
||||||
// Pre-fill items: remaining quantity for each non-cancelled line
|
// Pre-fill items: remaining quantity for each non-cancelled line
|
||||||
const lineItems = (po.items ?? [])
|
const lineItems = activeLines.map(item => ({
|
||||||
.filter(item => !item.isCancelled)
|
|
||||||
.map(item => ({
|
|
||||||
purchaseOrderItemId: item.id,
|
purchaseOrderItemId: item.id,
|
||||||
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
|
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
|
||||||
rejectedQuantity: 0,
|
rejectedQuantity: 0,
|
||||||
|
serialText: '',
|
||||||
|
batches: [],
|
||||||
}))
|
}))
|
||||||
replace(lineItems)
|
replace(lineItems)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,36 +154,58 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildItems(values: FormValues, lines: typeof poItems): GrnItemBody[] | null {
|
||||||
|
const result: GrnItemBody[] = []
|
||||||
|
for (let i = 0; i < values.items.length; i++) {
|
||||||
|
const v = values.items[i]
|
||||||
|
const line = lines[i]
|
||||||
|
const payload = trackingPayload(line ? itemInfo[line.itemId] : undefined, v)
|
||||||
|
if ('error' in payload) {
|
||||||
|
toast.add({ title: `${line?.sku ?? `line ${i + 1}`}: ${payload.error}`, type: 'error' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
purchaseOrderItemId: v.purchaseOrderItemId,
|
||||||
|
acceptedQuantity: v.acceptedQuantity,
|
||||||
|
rejectedQuantity: v.rejectedQuantity,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
|
const items = buildItems(values, poItems)
|
||||||
|
if (!items) return
|
||||||
try {
|
try {
|
||||||
await grnApi.create({
|
await grnApi.create({
|
||||||
purchaseOrderId: values.purchaseOrderId,
|
purchaseOrderId: values.purchaseOrderId,
|
||||||
warehouseId: values.warehouseId,
|
warehouseId: values.warehouseId,
|
||||||
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
|
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
|
||||||
items: values.items,
|
items,
|
||||||
receivedByUserId: values.receivedByUserId,
|
receivedByUserId: values.receivedByUserId,
|
||||||
})
|
})
|
||||||
toast.add({ title: 'Goods receipt recorded', type: 'success' })
|
toast.add({ title: 'Goods receipt recorded', type: 'success' })
|
||||||
onSuccess()
|
router.push('/procurement/grn')
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
|
<div className="flex max-w-4xl flex-col gap-6">
|
||||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
|
<div className="flex items-center gap-4">
|
||||||
<DialogHeader>
|
<Button type="button" variant="outline" size="sm" onClick={goBack}>
|
||||||
<DialogTitle>Record Goods Receipt</DialogTitle>
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
<DialogDescription>
|
Back
|
||||||
Record stock received into a warehouse against a purchase order.
|
</Button>
|
||||||
</DialogDescription>
|
<div>
|
||||||
</DialogHeader>
|
<h1 className="text-2xl font-semibold text-foreground">Receipt Against PO</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Record stock received into a warehouse against an issued purchase order.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6 rounded-2xl border p-6">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -304,6 +350,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{poItem && itemInfo[poItem.itemId] && (
|
||||||
|
<LineTracking
|
||||||
|
info={itemInfo[poItem.itemId]}
|
||||||
|
name={poItem.itemName}
|
||||||
|
accepted={form.watch(`items.${index}.acceptedQuantity`)}
|
||||||
|
serialText={form.watch(`items.${index}.serialText`)}
|
||||||
|
batches={form.watch(`items.${index}.batches`)}
|
||||||
|
onSerialText={v => form.setValue(`items.${index}.serialText`, v)}
|
||||||
|
onBatches={v => form.setValue(`items.${index}.batches`, v, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -313,14 +371,13 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}>
|
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0 || trackingIncomplete}>
|
||||||
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/shared/data-table'
|
||||||
|
import type { TableConfig } from '@/components/shared/data-table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import {
|
||||||
|
productReports,
|
||||||
|
type ProductReportSummary,
|
||||||
|
type ProductReturnRow,
|
||||||
|
type ProductSalesRow,
|
||||||
|
type ProductStockRow,
|
||||||
|
} from '@/lib/api/reports'
|
||||||
|
|
||||||
|
type Tab = 'stock' | 'sales' | 'returns'
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'stock', label: 'Stock' },
|
||||||
|
{ key: 'sales', label: 'Sales' },
|
||||||
|
{ key: 'returns', label: 'Returns' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const num = (v: number | null | undefined) =>
|
||||||
|
v == null ? '—' : v.toLocaleString('en-IN', { maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
const pct = (v: number | null | undefined) => (v == null ? '—' : `${v.toFixed(2)}%`)
|
||||||
|
|
||||||
|
const stockColumns: TableConfig<ProductStockRow>['columns'] = [
|
||||||
|
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
|
||||||
|
{ key: 'brand', title: 'Brand', fields: [{ key: 'brand', transform: v => (v as string | null) ?? '—' }], width: 140 },
|
||||||
|
{ key: 'category', title: 'Category', fields: ['category'], width: 150 },
|
||||||
|
{ key: 'uom', title: 'UOM', fields: ['uom'], width: 80 },
|
||||||
|
{ key: 'warehouseQty', title: 'Warehouse', fields: [{ key: 'warehouseQty', transform: v => num(v as number) }], width: 110 },
|
||||||
|
{ key: 'vehicleQty', title: 'Vehicles', fields: [{ key: 'vehicleQty', transform: v => num(v as number) }], width: 100 },
|
||||||
|
{ key: 'totalQty', title: 'Total', fields: [{ key: 'totalQty', transform: v => num(v as number) }], width: 100 },
|
||||||
|
{ key: 'stockValue', title: 'Stock Value', fields: [{ key: 'stockValue', transform: v => num(v as number) }], width: 120 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const salesColumns: TableConfig<ProductSalesRow>['columns'] = [
|
||||||
|
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
|
||||||
|
{ key: 'orders', title: 'Orders', fields: ['orders'], width: 90 },
|
||||||
|
{ key: 'stores', title: 'Stores', fields: ['stores'], width: 90 },
|
||||||
|
{ key: 'qtyOrdered', title: 'Qty Ordered', fields: [{ key: 'qtyOrdered', transform: v => num(v as number) }], width: 120 },
|
||||||
|
{ key: 'qtyFulfilled', title: 'Qty Fulfilled', fields: [{ key: 'qtyFulfilled', transform: v => num(v as number) }], width: 120 },
|
||||||
|
{ key: 'salesValue', title: 'Sales', fields: [{ key: 'salesValue', transform: v => num(v as number) }], width: 120 },
|
||||||
|
{ key: 'margin', title: 'Margin', fields: [{ key: 'margin', transform: v => num(v as number | null) }], width: 110 },
|
||||||
|
{ key: 'marginPercent', title: 'Margin %', fields: [{ key: 'marginPercent', transform: v => pct(v as number | null) }], width: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const returnsColumns: TableConfig<ProductReturnRow>['columns'] = [
|
||||||
|
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
|
||||||
|
{ key: 'returnedQty', title: 'Returned Qty', fields: [{ key: 'returnedQty', transform: v => num(v as number) }], width: 120 },
|
||||||
|
{ key: 'returnedValue', title: 'Returned Value', fields: [{ key: 'returnedValue', transform: v => num(v as number) }], width: 130 },
|
||||||
|
{ key: 'soldQty', title: 'Sold Qty', fields: [{ key: 'soldQty', transform: v => num(v as number) }], width: 100 },
|
||||||
|
{ key: 'returnPercent', title: 'Return %', fields: [{ key: 'returnPercent', transform: v => pct(v as number | null) }], width: 100 },
|
||||||
|
{
|
||||||
|
key: 'qtyByReason',
|
||||||
|
title: 'By Reason',
|
||||||
|
fields: ['qtyByReason'],
|
||||||
|
minWidth: 220,
|
||||||
|
cellRender: row => (
|
||||||
|
<span className="text-sm">
|
||||||
|
{Object.entries(row.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', ')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ProductReportClient() {
|
||||||
|
const [tab, setTab] = useState<Tab>('stock')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [lowStockOnly, setLowStockOnly] = useState(false)
|
||||||
|
|
||||||
|
const [stockData, setStockData] = useState<ProductStockRow[]>([])
|
||||||
|
const [salesData, setSalesData] = useState<ProductSalesRow[]>([])
|
||||||
|
const [returnsData, setReturnsData] = useState<ProductReturnRow[]>([])
|
||||||
|
const [summary, setSummary] = useState<ProductReportSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const base = { page, pageSize, search: search || undefined, from: from || undefined, to: to || undefined }
|
||||||
|
try {
|
||||||
|
if (tab === 'stock') {
|
||||||
|
const res = await productReports.stock({ ...base, lowStockOnly })
|
||||||
|
setStockData(getPaginatedItems(res))
|
||||||
|
setTotal(res.total)
|
||||||
|
} else if (tab === 'sales') {
|
||||||
|
const res = await productReports.sales(base)
|
||||||
|
setSalesData(getPaginatedItems(res))
|
||||||
|
setTotal(res.total)
|
||||||
|
} else {
|
||||||
|
const res = await productReports.returns(base)
|
||||||
|
setReturnsData(getPaginatedItems(res))
|
||||||
|
setTotal(res.total)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to load product report', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [tab, page, pageSize, search, from, to, lowStockOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productReports
|
||||||
|
.summary({ from: from || undefined, to: to || undefined })
|
||||||
|
.then(setSummary)
|
||||||
|
.catch(err => toast.add({ title: 'Failed to load summary', description: getApiError(err), type: 'error' }))
|
||||||
|
}, [from, to])
|
||||||
|
|
||||||
|
function changeTab(next: Tab) {
|
||||||
|
setTab(next)
|
||||||
|
setPage(1)
|
||||||
|
setTotal(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
|
||||||
|
|
||||||
|
const stockConfig = useMemo<TableConfig<ProductStockRow>>(
|
||||||
|
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: stockColumns }),
|
||||||
|
[pageSize],
|
||||||
|
)
|
||||||
|
const salesConfig = useMemo<TableConfig<ProductSalesRow>>(
|
||||||
|
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No finalized sales in this period.', columns: salesColumns }),
|
||||||
|
[pageSize],
|
||||||
|
)
|
||||||
|
const returnsConfig = useMemo<TableConfig<ProductReturnRow>>(
|
||||||
|
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No returns in this period.', columns: returnsColumns }),
|
||||||
|
[pageSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onServerSideChange = ({ page: p }: { page: { page: number; pageSize: number } }) => {
|
||||||
|
setPage(p.page)
|
||||||
|
setPageSize(p.pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Product Report</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Stock, sales and returns per product. Sales count fulfilled quantity on finalized invoices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
['Products', num(summary.itemCount)],
|
||||||
|
['Total stock qty', num(summary.totalQty)],
|
||||||
|
['Stock value', num(summary.totalValue)],
|
||||||
|
['Sales (period)', num(summary.totalSales)],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<Card key={label} className="rounded-3xl border-border/60">
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">{value}</CardTitle>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<Button key={t.key} size="sm" variant={tab === t.key ? 'default' : 'outline'} onClick={() => changeTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
className="w-56"
|
||||||
|
placeholder="Search name or SKU"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
/>
|
||||||
|
{tab !== 'stock' ? (
|
||||||
|
<>
|
||||||
|
<Input className="w-40" type="date" value={from} onChange={e => { setFrom(e.target.value); setPage(1) }} />
|
||||||
|
<Input className="w-40" type="date" value={to} onChange={e => { setTo(e.target.value); setPage(1) }} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant={lowStockOnly ? 'default' : 'outline'} onClick={() => { setLowStockOnly(v => !v); setPage(1) }}>
|
||||||
|
Low stock only
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'stock' && (
|
||||||
|
<DataTable config={stockConfig} data={stockData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
|
||||||
|
)}
|
||||||
|
{tab === 'sales' && (
|
||||||
|
<DataTable config={salesConfig} data={salesData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
|
||||||
|
)}
|
||||||
|
{tab === 'returns' && (
|
||||||
|
<DataTable config={returnsConfig} data={returnsData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProductReportClient } from './_components/product-report-client'
|
||||||
|
|
||||||
|
export default function ProductReportPage() {
|
||||||
|
return <ProductReportClient />
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { useAuth, type Role } from '@/contexts/auth-context'
|
import { useAuth, type Role } from '@/contexts/auth-context'
|
||||||
import { DASHBOARD_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav'
|
import { DASHBOARD_ITEM, CONFIGURATION_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav'
|
||||||
|
|
||||||
function canSee(item: NavItem, permissions: string[]): boolean {
|
function canSee(item: NavItem, permissions: string[]): boolean {
|
||||||
if (!item.permission) return true
|
if (!item.permission) return true
|
||||||
@@ -54,6 +54,8 @@ export function AppSidebar() {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const dashboardActive = pathname === DASHBOARD_ITEM.href
|
const dashboardActive = pathname === DASHBOARD_ITEM.href
|
||||||
|
const configurationActive =
|
||||||
|
pathname === CONFIGURATION_ITEM.href || pathname.startsWith(CONFIGURATION_ITEM.href + '/')
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,6 +184,22 @@ export function AppSidebar() {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Configuration — standalone, no sub-nav */}
|
||||||
|
<SidebarGroup className="py-1">
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
render={<Link href={CONFIGURATION_ITEM.href} />}
|
||||||
|
isActive={configurationActive}
|
||||||
|
tooltip={CONFIGURATION_ITEM.title}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={CONFIGURATION_ITEM.icon} size={18} />
|
||||||
|
<span>{CONFIGURATION_ITEM.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm ring-1 ring-foreground/5 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("font-heading text-base font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-2xl border-2 transition-all outline-none group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-8 data-[size=sm]:h-4 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary group-has-[:focus-visible]/field-label:data-checked:border-primary data-unchecked:border-transparent data-unchecked:bg-input/90 group-has-[:focus-visible]/field-label:data-unchecked:border-transparent data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-2xl bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-checked:translate-x-[calc(100%-4px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ClipboardListIcon,
|
ClipboardListIcon,
|
||||||
BankIcon,
|
BankIcon,
|
||||||
PackageMoving01Icon,
|
PackageMoving01Icon,
|
||||||
|
Settings01Icon,
|
||||||
} from '@hugeicons/core-free-icons'
|
} from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -49,6 +50,13 @@ export const DASHBOARD_ITEM: NavItem = {
|
|||||||
icon: DashboardSquare01Icon,
|
icon: DashboardSquare01Icon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone item rendered at the bottom — links straight to the page, no sub-nav
|
||||||
|
export const CONFIGURATION_ITEM: NavItem = {
|
||||||
|
title: 'Configuration',
|
||||||
|
href: '/configuration',
|
||||||
|
icon: Settings01Icon,
|
||||||
|
}
|
||||||
|
|
||||||
export const NAV_GROUPS: NavGroup[] = [
|
export const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
title: 'Shop POs & Invoices',
|
title: 'Shop POs & Invoices',
|
||||||
@@ -80,7 +88,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
title: 'Catalogue',
|
title: 'Catalogue',
|
||||||
icon: CatalogueIcon,
|
icon: CatalogueIcon,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Items', href: '/catalogue/items', icon: Package01Icon, description: 'Products available for sale and distribution', permission: 'items.view' },
|
{ title: 'Products', href: '/catalogue/items', icon: Package01Icon, description: 'Products available for sale and distribution', permission: 'items.view' },
|
||||||
{ title: 'Categories', href: '/catalogue/categories', icon: CatalogueIcon, description: 'Product groupings and sub-categories', permission: 'categories.view' },
|
{ title: 'Categories', href: '/catalogue/categories', icon: CatalogueIcon, description: 'Product groupings and sub-categories', permission: 'categories.view' },
|
||||||
{ title: 'Brands', href: '/catalogue/brands', icon: BarCode01Icon, description: 'Brands associated with catalogue items', permission: 'categories.view' },
|
{ title: 'Brands', href: '/catalogue/brands', icon: BarCode01Icon, description: 'Brands associated with catalogue items', permission: 'categories.view' },
|
||||||
// { title: 'UOMs', href: '/catalogue/uoms', icon: Layers01Icon, description: 'Units of measure and conversion factors between units', permission: 'categories.view' },
|
// { title: 'UOMs', href: '/catalogue/uoms', icon: Layers01Icon, description: 'Units of measure and conversion factors between units', permission: 'categories.view' },
|
||||||
@@ -94,12 +102,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ title: 'Stock Movements', href: '/stock/movements', icon: PackageMoving01Icon, description: 'Full history of stock in and out across all locations', permission: 'warehouses.view' },
|
{ title: 'Stock Movements', href: '/stock/movements', icon: PackageMoving01Icon, description: 'Full history of stock in and out across all locations', permission: 'warehouses.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Procurement',
|
title: 'Procurement',
|
||||||
icon: ShoppingCart01Icon,
|
icon: ShoppingCart01Icon,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Purchase Orders', href: '/procurement/orders', icon: ShoppingCart01Icon, description: 'Orders placed with suppliers to restock inventory', permission: 'purchase_orders.view' },
|
{ title: 'Purchase Orders', href: '/procurement/orders', icon: ShoppingCart01Icon, description: 'Orders placed with suppliers to restock inventory', permission: 'purchase_orders.view' },
|
||||||
{ title: 'Goods Receipt', href: '/procurement/grn', icon: PackageReceiveIcon, description: 'Record stock received into a warehouse against a purchase order', permission: 'purchase_orders.view' },
|
{ title: 'Goods Receipt', href: '/procurement/grn', icon: PackageReceiveIcon, description: 'Record stock received into a warehouse, against a purchase order or directly', permission: 'purchase_orders.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,6 +162,14 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ title: 'Warehouses', href: '/network/warehouses', icon: Building02Icon, description: 'Storage locations where stock is held and loaded from', permission: 'warehouses.view' },
|
{ title: 'Warehouses', href: '/network/warehouses', icon: Building02Icon, description: 'Storage locations where stock is held and loaded from', permission: 'warehouses.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Reports',
|
||||||
|
icon: ClipboardListIcon,
|
||||||
|
items: [
|
||||||
|
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Ordered candidates for mobile bottom nav — first 5 visible are shown
|
// Ordered candidates for mobile bottom nav — first 5 visible are shown
|
||||||
@@ -162,5 +179,5 @@ export const BOTTOM_NAV_CANDIDATES: NavItem[] = [
|
|||||||
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' },
|
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' },
|
||||||
{ title: 'Loading', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.view' },
|
{ title: 'Loading', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.view' },
|
||||||
{ title: 'Shop POs', href: '/sales', icon: ClipboardListIcon, permission: 'orders.view' },
|
{ title: 'Shop POs', href: '/sales', icon: ClipboardListIcon, permission: 'orders.view' },
|
||||||
{ title: 'Items', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' },
|
{ title: 'Products', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export interface ItemBody {
|
|||||||
brandId?: string | null
|
brandId?: string | null
|
||||||
baseUomId: string
|
baseUomId: string
|
||||||
salePrice: number
|
salePrice: number
|
||||||
|
reorderLevel?: number | null
|
||||||
|
tracksSerialNumbers?: boolean
|
||||||
|
tracksBatches?: boolean
|
||||||
|
tracksExpiryDate?: boolean
|
||||||
|
hasWarranty?: boolean
|
||||||
|
warrantyPeriodDays?: number | null
|
||||||
|
allowCustomWarrantyPeriod?: boolean
|
||||||
syncSource?: SyncSource
|
syncSource?: SyncSource
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export { drivers, compliance } from './drivers'
|
|||||||
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
||||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||||
export { stock } from './stock'
|
export { stock } from './stock'
|
||||||
|
export { productReports } from './reports'
|
||||||
export { shopPos, invoices, payments } from './sales'
|
export { shopPos, invoices, payments } from './sales'
|
||||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||||
|
export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config'
|
||||||
export { getPaginatedItems } from './paginated'
|
export { getPaginatedItems } from './paginated'
|
||||||
|
|
||||||
export type { PageParams } from './master'
|
export type { PageParams } from './master'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api } from '@/lib/api'
|
|||||||
import type {
|
import type {
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
RegionResponse,
|
RegionResponse,
|
||||||
|
RegionManagerResponse,
|
||||||
ManageRegionResponse,
|
ManageRegionResponse,
|
||||||
RegionHierarchyResponse,
|
RegionHierarchyResponse,
|
||||||
RegionDashboardResponse,
|
RegionDashboardResponse,
|
||||||
@@ -84,6 +85,7 @@ export interface PageParams {
|
|||||||
|
|
||||||
export interface RegionBody {
|
export interface RegionBody {
|
||||||
name: string
|
name: string
|
||||||
|
code?: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +93,18 @@ export const regions = {
|
|||||||
list: (params?: PageParams) =>
|
list: (params?: PageParams) =>
|
||||||
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
getManager: (id: string) =>
|
||||||
|
api.get<RegionManagerResponse>(`/regions/${id}/manager`).then(r => r.data),
|
||||||
|
|
||||||
|
setManager: (id: string, userId: string) =>
|
||||||
|
api.put<RegionManagerResponse>(`/regions/${id}/manager`, { userId }).then(r => r.data),
|
||||||
|
|
||||||
|
removeManager: (id: string) =>
|
||||||
|
api.delete(`/regions/${id}/manager`),
|
||||||
|
|
||||||
|
nextCode: () =>
|
||||||
|
api.get<{ code: string }>('/regions/next-code').then(r => r.data),
|
||||||
|
|
||||||
get: (id: string) =>
|
get: (id: string) =>
|
||||||
api.get<RegionResponse>(`/regions/${id}`).then(r => r.data),
|
api.get<RegionResponse>(`/regions/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,24 @@ export const purchaseOrders = {
|
|||||||
|
|
||||||
// ─── Goods Receipts (GRN) ─────────────────────────────────────────────────────
|
// ─── Goods Receipts (GRN) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GrnBatchLineBody {
|
||||||
|
/** Null for an expiry-only lot (item tracks expiry but not batch numbers). */
|
||||||
|
batchNumber: string | null
|
||||||
|
expiryDate: string | null
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GrnItemBody {
|
export interface GrnItemBody {
|
||||||
purchaseOrderItemId: string
|
/** Set for a PO receipt; omitted for a direct receipt (itemId is used instead). */
|
||||||
|
purchaseOrderItemId?: string
|
||||||
|
/** Set for a direct receipt line. */
|
||||||
|
itemId?: string
|
||||||
acceptedQuantity: number
|
acceptedQuantity: number
|
||||||
rejectedQuantity: number
|
rejectedQuantity: number
|
||||||
|
/** Only for items with TracksBatches and/or TracksExpiryDate. */
|
||||||
|
batches?: GrnBatchLineBody[]
|
||||||
|
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
|
||||||
|
serialNumbers?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrnBody {
|
export interface GrnBody {
|
||||||
@@ -74,6 +88,14 @@ export interface GrnBody {
|
|||||||
receivedByUserId: string
|
receivedByUserId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectGrnBody {
|
||||||
|
warehouseId: string
|
||||||
|
supplierId?: string
|
||||||
|
items: GrnItemBody[]
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
receivedByUserId: string
|
||||||
|
}
|
||||||
|
|
||||||
export const grn = {
|
export const grn = {
|
||||||
list: (params?: PageParams & { purchaseOrderId?: string }) =>
|
list: (params?: PageParams & { purchaseOrderId?: string }) =>
|
||||||
api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data),
|
api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data),
|
||||||
@@ -83,6 +105,10 @@ export const grn = {
|
|||||||
|
|
||||||
create: (body: GrnBody) =>
|
create: (body: GrnBody) =>
|
||||||
api.post<GrnResponse>('/goods-receipts', body).then(r => r.data),
|
api.post<GrnResponse>('/goods-receipts', body).then(r => r.data),
|
||||||
|
|
||||||
|
/** Receive goods with no purchase order. */
|
||||||
|
createDirect: (body: DirectGrnBody) =>
|
||||||
|
api.post<GrnResponse>('/goods-receipts/direct', body).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Purchase Returns ─────────────────────────────────────────────────────────
|
// ─── Purchase Returns ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type { PaginatedResponse } from '@/types/api'
|
||||||
|
|
||||||
|
import type { PageParams } from './master'
|
||||||
|
|
||||||
|
export interface ProductStockRow {
|
||||||
|
itemId: string
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
brand: string | null
|
||||||
|
category: string
|
||||||
|
subCategory: string | null
|
||||||
|
uom: string
|
||||||
|
salePrice: number | null
|
||||||
|
costPrice: number | null
|
||||||
|
reorderLevel: number | null
|
||||||
|
warehouseQty: number
|
||||||
|
vehicleQty: number
|
||||||
|
totalQty: number
|
||||||
|
stockValue: number
|
||||||
|
isLowStock: boolean
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSalesRow {
|
||||||
|
itemId: string
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
orders: number
|
||||||
|
stores: number
|
||||||
|
qtyOrdered: number
|
||||||
|
qtyFulfilled: number
|
||||||
|
salesValue: number
|
||||||
|
costValue: number | null
|
||||||
|
margin: number | null
|
||||||
|
marginPercent: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductReturnRow {
|
||||||
|
itemId: string
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
returnedQty: number
|
||||||
|
returnedValue: number
|
||||||
|
soldQty: number
|
||||||
|
returnPercent: number | null
|
||||||
|
qtyByReason: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductReportSummary {
|
||||||
|
itemCount: number
|
||||||
|
totalQty: number
|
||||||
|
totalValue: number
|
||||||
|
totalSales: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductReportParams extends PageParams {
|
||||||
|
brandId?: string
|
||||||
|
categoryId?: string
|
||||||
|
subCategoryId?: string
|
||||||
|
warehouseId?: string
|
||||||
|
regionId?: string
|
||||||
|
routeId?: string
|
||||||
|
storeId?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductStockParams extends ProductReportParams {
|
||||||
|
activeOnly?: boolean
|
||||||
|
lowStockOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productReports = {
|
||||||
|
stock: (params?: ProductStockParams) =>
|
||||||
|
api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
sales: (params?: ProductReportParams) =>
|
||||||
|
api.get<PaginatedResponse<ProductSalesRow>>('/reports/products/sales', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
returns: (params?: ProductReportParams) =>
|
||||||
|
api.get<PaginatedResponse<ProductReturnRow>>('/reports/products/returns', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
|
||||||
|
api.get<ProductReportSummary>('/reports/products/summary', { params }).then(r => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type { SysConfigResponse } from '@/types/api'
|
||||||
|
|
||||||
|
// ─── Item tracking feature keys (must match Distribution.Features.SysConfig.SysConfigService) ──
|
||||||
|
|
||||||
|
export const ITEM_TRACKING_KEYS = {
|
||||||
|
serialNumberTracking: 'Feature.SerialNumberTracking',
|
||||||
|
batchTracking: 'Feature.BatchTracking',
|
||||||
|
expiryDateTracking: 'Feature.ExpiryDateTracking',
|
||||||
|
warrantyTracking: 'Feature.WarrantyTracking',
|
||||||
|
customWarrantyOverride: 'Feature.CustomWarrantyOverride',
|
||||||
|
defaultWarrantyPeriodDays: 'Feature.DefaultWarrantyPeriodDays',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const sysConfig = {
|
||||||
|
getAll: () =>
|
||||||
|
api.get<SysConfigResponse[]>('/sys-config').then(r => r.data),
|
||||||
|
|
||||||
|
get: (key: string) =>
|
||||||
|
api.get<SysConfigResponse>(`/sys-config/${key}`).then(r => r.data),
|
||||||
|
|
||||||
|
set: (key: string, value: string, userId?: string | null) =>
|
||||||
|
api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data),
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ export interface PermissionResponse {
|
|||||||
|
|
||||||
export interface RegionResponse {
|
export interface RegionResponse {
|
||||||
id: string
|
id: string
|
||||||
|
code: string
|
||||||
name: string
|
name: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -282,6 +283,13 @@ export interface ItemSummaryResponse {
|
|||||||
baseUomId: string
|
baseUomId: string
|
||||||
baseUomName: string
|
baseUomName: string
|
||||||
salePrice: number
|
salePrice: number
|
||||||
|
reorderLevel: number | null
|
||||||
|
tracksSerialNumbers: boolean
|
||||||
|
tracksBatches: boolean
|
||||||
|
tracksExpiryDate: boolean
|
||||||
|
hasWarranty: boolean
|
||||||
|
warrantyPeriodDays: number | null
|
||||||
|
allowCustomWarrantyPeriod: boolean
|
||||||
syncSource: SyncSource
|
syncSource: SyncSource
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -393,22 +401,34 @@ export interface PurchaseOrderResponse {
|
|||||||
items: PurchaseOrderItemResponse[]
|
items: PurchaseOrderItemResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GrnBatchLineResponse {
|
||||||
|
batchNumber: string | null
|
||||||
|
expiryDate: string | null
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GrnItemResponse {
|
export interface GrnItemResponse {
|
||||||
id: string
|
id: string
|
||||||
purchaseOrderItemId: string
|
/** Null on a direct receipt line (no purchase order). */
|
||||||
|
purchaseOrderItemId: string | null
|
||||||
itemId: string
|
itemId: string
|
||||||
sku: string
|
sku: string
|
||||||
itemName: string
|
itemName: string
|
||||||
acceptedQuantity: number
|
acceptedQuantity: number
|
||||||
rejectedQuantity: number
|
rejectedQuantity: number
|
||||||
|
batches: GrnBatchLineResponse[]
|
||||||
|
serialNumbers: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GrnResponse {
|
export interface GrnResponse {
|
||||||
id: string
|
id: string
|
||||||
purchaseOrderId: string
|
/** Null for a direct receipt (goods received without a purchase order). */
|
||||||
|
purchaseOrderId: string | null
|
||||||
receivedByUserId: string
|
receivedByUserId: string
|
||||||
warehouseId: string
|
warehouseId: string
|
||||||
warehouseName: string
|
warehouseName: string
|
||||||
|
supplierId: string | null
|
||||||
|
supplierName: string | null
|
||||||
receivedAt: string
|
receivedAt: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
items: GrnItemResponse[]
|
items: GrnItemResponse[]
|
||||||
@@ -709,3 +729,10 @@ export interface SysConfigResponse {
|
|||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegionManagerResponse {
|
||||||
|
regionId: string
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user