Refactor/main flow #54

Merged
DeepnaPooja merged 16 commits from refactor/main_flow into dev 2026-10-07 10:46:59 +05:30
73 changed files with 14348 additions and 490 deletions
@@ -18,6 +18,7 @@ public class RegionServiceTests
public RegionServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
@@ -148,6 +149,123 @@ public class RegionServiceTests
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
// Region code
[Fact]
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0001", result.Code);
}
[Fact]
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0010", result.Code);
}
[Fact]
public async Task CreateAsync_StoresProvidedCode_AsEntered()
{
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
Assert.Equal("nth-1", result.Code);
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
}
[Fact]
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
{
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task GetNextCodeAsync_ReturnsSuggestion()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
var result = await _sut.GetNextCodeAsync();
Assert.Equal("REG-0004", result.Code);
}
[Fact]
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
Assert.Equal("north-1", result!.Code);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
Assert.Equal("REG-0001", result!.Code);
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
}
[Fact]
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task UpdateAsync_AllowsResubmittingOwnCode()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
Assert.Equal("REG-0001", result!.Code);
}
[Fact]
public async Task GetByIdAsync_ReturnsCode()
{
var region = ActiveRegion();
region.Code = "REG-0007";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.GetByIdAsync(region.Id);
Assert.Equal("REG-0007", result!.Code);
}
// DeleteAsync
[Fact]
@@ -195,4 +313,93 @@ public class RegionServiceTests
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
}
// Region manager
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
{
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
Role = new Role { Name = role, Level = 50 }
};
[Fact]
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
}
[Fact]
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
{
var region = ActiveRegion();
var user = ManagerUser("SalesRep");
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
{
var region = ActiveRegion();
var user = ManagerUser();
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
Assert.Equal(user.Id, result!.UserId);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
{
var region = ActiveRegion();
var user = ManagerUser();
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
}
[Fact]
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
{
var region = ActiveRegion();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
Assert.False(await _sut.RemoveManagerAsync(region.Id));
}
[Fact]
public async Task GetManagerAsync_ReturnsManager()
{
var region = ActiveRegion();
var user = ManagerUser();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
var result = await _sut.GetManagerAsync(region.Id);
Assert.Equal("mgr", result!.Username);
}
}
+115 -2
View File
@@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Uom> Uoms { get; set; }
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<ItemBatch> ItemBatches { get; set; }
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
@@ -47,6 +49,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<GoodsReceiptItemBatch> GoodsReceiptItemBatches { get; set; }
public DbSet<GoodsReceiptItemSerial> GoodsReceiptItemSerials { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
@@ -83,8 +87,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Unique indexes ---
// NOTE: MySQL treats each NULL as distinct in a unique index, so this does not by itself
// prevent duplicate rows for untracked items (both FK columns null) — StockRepository's
// get-then-update pattern is what keeps those to one row per (Item, Location), same as before.
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
.IsUnique();
modelBuilder.Entity<ItemSerialNumber>()
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
@@ -292,6 +303,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Supplier)
.WithMany()
.HasForeignKey(gr => gr.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse)
.WithMany()
@@ -310,12 +328,92 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- ItemBatch / ItemSerialNumber ---
modelBuilder.Entity<ItemBatch>()
.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ItemBatch>()
.HasIndex(b => new { b.FK_ItemId, b.BatchNumber });
modelBuilder.Entity<ItemSerialNumber>()
.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- StockBalance / StockMovement batch & serial links ---
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemBatch)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemSerialNumber)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemBatch)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemSerialNumber)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- GoodsReceiptItemBatch / GoodsReceiptItemSerial ---
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.GoodsReceiptItem)
.WithMany(i => i.Batches)
.HasForeignKey(b => b.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.ItemBatch)
.WithMany()
.HasForeignKey(b => b.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.GoodsReceiptItem)
.WithMany(i => i.Serials)
.HasForeignKey(s => s.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.ItemSerialNumber)
.WithMany()
.HasForeignKey(s => s.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>()
@@ -446,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -478,7 +585,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
+2 -2
View File
@@ -84,8 +84,8 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Models.Route>().HasData(
@@ -39,6 +39,20 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
/// <summary>Goods received without a purchase order.</summary>
[HttpPost("api/goods-receipts/direct")]
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateDirectAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
+31 -4
View File
@@ -1,6 +1,19 @@
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(
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.
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(
Guid Id,
Guid PurchaseOrderItemId,
Guid? PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
int RejectedQuantity);
int RejectedQuantity,
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers);
public record GrnResponse(
Guid Id,
Guid PurchaseOrderId,
Guid? PurchaseOrderId,
Guid ReceivedByUserId,
Guid WarehouseId,
string WarehouseName,
Guid? SupplierId,
string? SupplierName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items);
+17 -5
View File
@@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
+295 -36
View File
@@ -9,14 +9,23 @@ public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item;
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) =>
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());
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) =>
(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>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
@@ -64,8 +123,13 @@ public class GrnService(
ReceivedAt = DateTime.Now
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
foreach (var line in request.Items)
{
if (line.PurchaseOrderItemId is null)
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
@@ -79,14 +143,27 @@ public class GrnService(
throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
ValidateLineShape(poItem.Item, line);
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
}
}
poItem.ReceivedQuantity += line.AcceptedQuantity;
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
var entity = new Models.GoodsReceiptItem
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
});
};
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
}
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
@@ -94,48 +171,230 @@ public class GrnService(
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived;
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
}
/// <summary>
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
/// </summary>
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found.");
var 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);
try
{
await repo.AddAsync(goodsReceipt);
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
foreach (var (entity, line, item) in grLines.Where(x => x.Line.AcceptedQuantity > 0))
{
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
if (balance is null)
if (item.TracksSerialNumbers)
{
await stockRepo.AddBalanceAsync(new StockBalance
foreach (var serialNumber in line.SerialNumbers!)
{
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
var serial = new ItemSerialNumber
{
FK_ItemId = item.Id,
SerialNumber = serialNumber,
Status = SerialStatus.InStock,
CurrentLocationId = warehouseId,
CurrentLocationType = LocationType.Warehouse,
ReceivedAt = goodsReceipt.ReceivedAt
};
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
// stays null until the sale/delivery flow sets it.
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
await stockRepo.AddItemSerialNumberAsync(serial);
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
foreach (var b in line.Batches!)
{
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
: null;
if (batch is null)
{
batch = new ItemBatch
{
FK_ItemId = item.Id,
BatchNumber = b.BatchNumber,
ExpiryDate = b.ExpiryDate
};
await stockRepo.AddItemBatchAsync(batch);
}
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity
});
}
else
{
balance.Quantity += b.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = poItem.FK_ItemId,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
poRepo.Update(po);
if (po is not null) poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
+26 -2
View File
@@ -12,7 +12,13 @@ public record CreateItemRequest(
Guid BaseUomId,
decimal? SalePrice,
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(
string Name,
@@ -23,7 +29,13 @@ public record UpdateItemRequest(
Guid BaseUomId,
decimal? SalePrice,
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);
@@ -43,6 +55,12 @@ public record ItemSummaryResponse(
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt);
@@ -62,6 +80,12 @@ public record ItemDetailResponse(
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt,
+59 -3
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Item;
public class ItemService(IItemRepository repo, IUnitOfWork uow)
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
{
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
@@ -13,7 +13,10 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.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) =>
new(i.Id, i.Sku, i.Name, i.Description,
@@ -21,9 +24,44 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.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());
/// <summary>
/// Rejects per-item tracking flags that are off system-wide, and warranty-period values that
/// don't make sense given HasWarranty/AllowCustomWarrantyPeriod.
/// </summary>
private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches,
bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod)
{
if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync())
throw new InvalidOperationException("Serial number tracking is disabled system-wide.");
if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync())
throw new InvalidOperationException("Batch tracking is disabled system-wide.");
if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync())
throw new InvalidOperationException("Expiry date tracking is disabled system-wide.");
if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync())
throw new InvalidOperationException("Warranty tracking is disabled system-wide.");
if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync())
throw new InvalidOperationException("Custom warranty period override is disabled system-wide.");
if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod))
throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true.");
if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod)
throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true.");
if (warrantyPeriodDays is < 0)
throw new InvalidOperationException("WarrantyPeriodDays cannot be negative.");
}
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
.Select(MapSummary)
@@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
if (await repo.SkuExistsAsync(request.Sku))
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
var item = new Models.Item
{
Sku = request.Sku,
@@ -52,6 +93,12 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice,
ReorderLevel = request.ReorderLevel,
TracksSerialNumbers = request.TracksSerialNumbers,
TracksBatches = request.TracksBatches,
TracksExpiryDate = request.TracksExpiryDate,
HasWarranty = request.HasWarranty,
WarrantyPeriodDays = request.WarrantyPeriodDays,
AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod,
SyncSource = Models.SyncSource.Manual
};
@@ -78,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
if (item is null) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
item.Name = request.Name;
item.Description = request.Description;
item.FK_CategoryId = request.CategoryId;
@@ -86,6 +136,12 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice;
item.ReorderLevel = request.ReorderLevel;
item.TracksSerialNumbers = request.TracksSerialNumbers;
item.TracksBatches = request.TracksBatches;
item.TracksExpiryDate = request.TracksExpiryDate;
item.HasWarranty = request.HasWarranty;
item.WarrantyPeriodDays = request.WarrantyPeriodDays;
item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod;
item.IsActive = request.IsActive;
await uow.BeginAsync(ct);
@@ -0,0 +1,18 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record SalesAggregate(Guid ItemId, string Sku, string Name, decimal? CostPrice,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled, decimal SalesValue);
public record ReturnAggregate(Guid ItemId, string Sku, string Name, ReturnReasonCode Reason,
decimal Qty, decimal Value);
public interface IProductReportRepository
{
Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize);
Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize);
Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f);
Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds);
Task<decimal> GetTotalSalesAsync(ProductReportFilter f);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ProductReport;
[ApiController]
[Route("api/reports/products")]
public class ProductReportController(ProductReportService service) : ControllerBase
{
private IActionResult? Check(ProductReportFilter f, int page, int pageSize)
{
var error = ProductReportService.Validate(f, page, pageSize);
return error is null ? null : BadRequest(new { message = error });
}
[HttpGet("stock")]
public async Task<IActionResult> Stock(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? warehouseId, [FromQuery] bool activeOnly = true, [FromQuery] bool lowStockOnly = false,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, warehouseId, null, null, null,
null, null, activeOnly, lowStockOnly, search);
return Check(f, page, pageSize) ?? Ok(await service.GetStockAsync(f, page, pageSize));
}
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetSalesAsync(f, page, pageSize));
}
[HttpGet("returns")]
public async Task<IActionResult> Returns(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetReturnsAsync(f, page, pageSize));
}
[HttpGet("summary")]
public async Task<IActionResult> Summary(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var f = new ProductReportFilter(brandId, categoryId, null, warehouseId, null, null, null,
from, to, true, false, null);
return Check(f, 1, 1) ?? Ok(await service.GetSummaryAsync(f));
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record ProductStockRow(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string? SubCategory, string Uom,
decimal? SalePrice, decimal? CostPrice, decimal? ReorderLevel,
decimal WarehouseQty, decimal VehicleQty, decimal TotalQty, decimal StockValue,
bool IsLowStock, bool IsActive);
public record ProductSalesRow(
Guid ItemId, string Sku, string Name,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled,
decimal SalesValue, decimal? CostValue, decimal? Margin, decimal? MarginPercent);
public record ProductReturnRow(
Guid ItemId, string Sku, string Name,
decimal ReturnedQty, decimal ReturnedValue, decimal SoldQty, decimal? ReturnPercent,
Dictionary<ReturnReasonCode, decimal> QtyByReason);
public record ProductReportSummary(int ItemCount, decimal TotalQty, decimal TotalValue, decimal TotalSales);
public record ProductReportFilter(
Guid? BrandId, Guid? CategoryId, Guid? SubCategoryId, Guid? WarehouseId,
Guid? RegionId, Guid? RouteId, Guid? StoreId,
DateTime? From, DateTime? To, bool ActiveOnly, bool LowStockOnly, string? Search);
@@ -0,0 +1,156 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ProductReport;
public class ProductReportRepository(AppDBContext db) : IProductReportRepository
{
public async Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var balances = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
balances = balances.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
var items = db.Items.AsNoTracking().AsQueryable();
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) items = items.Where(i => i.FK_SubCategoryId == f.SubCategoryId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
}
var withQty = items.Select(i => new
{
Item = i,
Wh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Warehouse)
.Sum(b => b.Quantity),
Veh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Vehicle)
.Sum(b => b.Quantity),
});
if (f.LowStockOnly)
withQty = withQty.Where(x => x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel);
var total = await withQty.CountAsync();
var page_ = await withQty
.OrderBy(x => x.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.Item.Id, x.Item.Sku, x.Item.Name,
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
Category = x.Item.Category.Name,
SubCategory = x.Item.SubCategory != null ? x.Item.SubCategory.Name : null,
Uom = x.Item.BaseUom.Name,
x.Item.SalePrice, x.Item.CostPrice, x.Item.ReorderLevel, x.Item.IsActive,
x.Wh, x.Veh,
})
.ToListAsync();
var rows = page_.Select(r => new ProductStockRow(
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.SubCategory, r.Uom,
r.SalePrice, r.CostPrice, r.ReorderLevel,
r.Wh, r.Veh, r.Wh + r.Veh, (r.Wh + r.Veh) * (r.CostPrice ?? 0m),
r.ReorderLevel != null && r.Wh <= r.ReorderLevel,
r.IsActive)).ToList();
return (rows, total);
}
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
{
var lines =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
select li;
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
lines = lines.Where(l => l.Item.Name.Contains(s) || l.Item.Sku.Contains(s));
}
return lines;
}
public async Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var query = SoldLines(f)
.GroupBy(l => new { l.FK_ItemId, l.Item.Sku, l.Item.Name, l.Item.CostPrice })
.Select(g => new
{
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
});
var total = await query.CountAsync();
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
// Distinct stores per item is counted separately: MySQL can't run the correlated
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
var ids = page_.Select(r => r.FK_ItemId).ToList();
var storePairs = await SoldLines(f)
.Where(l => ids.Contains(l.FK_ItemId))
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
.Distinct()
.ToListAsync();
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
return (rows, total);
}
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
{
var ids = itemIds.ToList();
return await SoldLines(f with { Search = null })
.Where(l => ids.Contains(l.FK_ItemId))
.GroupBy(l => l.FK_ItemId)
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
.ToDictionaryAsync(x => x.Key, x => x.Qty);
}
public Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f)
{
var q = db.SaleReturnItems.AsNoTracking().AsQueryable();
if (f.From.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt >= f.From.Value);
if (f.To.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt < f.To.Value.Date.AddDays(1));
if (f.BrandId.HasValue) q = q.Where(r => r.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) q = q.Where(r => r.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
q = q.Where(r => r.Item.Name.Contains(s) || r.Item.Sku.Contains(s));
}
return q.GroupBy(r => new { r.FK_ItemId, r.Item.Sku, r.Item.Name, r.ReasonCode })
.Select(g => new ReturnAggregate(g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.ReasonCode,
g.Sum(x => x.Quantity), g.Sum(x => x.Quantity * x.UnitPrice)))
.ToListAsync();
}
}
@@ -0,0 +1,72 @@
using Distribution.Features.Stock;
namespace Distribution.Features.ProductReport;
public class ProductReportService(IProductReportRepository repo)
{
public const int MaxRangeDays = 366;
public static string? Validate(ProductReportFilter f, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (f.From.HasValue && f.To.HasValue)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To.Value - f.From.Value).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
}
return null;
}
public async Task<PagedResponse<ProductStockRow>> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetStockAsync(f, page, pageSize);
return new PagedResponse<ProductStockRow>(page, pageSize, total, rows);
}
public async Task<PagedResponse<ProductSalesRow>> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetSalesAsync(f, page, pageSize);
var mapped = rows.Select(r =>
{
decimal? cost = r.CostPrice.HasValue ? r.CostPrice.Value * r.QtyFulfilled : null;
decimal? margin = cost.HasValue ? r.SalesValue - cost.Value : null;
decimal? pct = margin.HasValue && r.SalesValue != 0 ? Math.Round(margin.Value / r.SalesValue * 100, 2) : null;
return new ProductSalesRow(r.ItemId, r.Sku, r.Name, r.Orders, r.Stores, r.QtyOrdered, r.QtyFulfilled,
r.SalesValue, cost, margin, pct);
}).ToList();
return new PagedResponse<ProductSalesRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<ProductReturnRow>> GetReturnsAsync(ProductReportFilter f, int page, int pageSize)
{
var agg = await repo.GetReturnsAsync(f);
var grouped = agg.GroupBy(a => new { a.ItemId, a.Sku, a.Name })
.Select(g => new
{
g.Key,
Qty = g.Sum(x => x.Qty),
Value = g.Sum(x => x.Value),
ByReason = g.ToDictionary(x => x.Reason, x => x.Qty),
})
.OrderByDescending(x => x.Value).ThenBy(x => x.Key.Name)
.ToList();
var pageRows = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var sold = await repo.GetSoldQtyAsync(f, pageRows.Select(r => r.Key.ItemId));
var rows = pageRows.Select(r =>
{
var soldQty = sold.GetValueOrDefault(r.Key.ItemId);
decimal? pct = soldQty > 0 ? Math.Round(r.Qty / soldQty * 100, 2) : null;
return new ProductReturnRow(r.Key.ItemId, r.Key.Sku, r.Key.Name, r.Qty, r.Value, soldQty, pct, r.ByReason);
}).ToList();
return new PagedResponse<ProductReturnRow>(page, pageSize, grouped.Count, rows);
}
public async Task<ProductReportSummary> GetSummaryAsync(ProductReportFilter f)
{
var (rows, total) = await repo.GetStockAsync(f, 1, int.MaxValue);
var totalSales = await repo.GetTotalSalesAsync(f);
return new ProductReportSummary(total, rows.Sum(r => r.TotalQty), rows.Sum(r => r.StockValue), totalSales);
}
}
@@ -4,6 +4,14 @@ public interface IRegionRepository
{
Task<Models.Region?> GetByIdAsync(Guid id);
Task<List<Models.Region>> GetAllAsync();
Task<List<string>> GetAllCodesAsync();
Task<bool> CodeExistsAsync(string code, Guid? excludeId = null);
Task<Models.User?> GetUserWithRoleAsync(Guid userId);
Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId);
Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId);
Task AddRegionScopeAsync(Models.UserRegionScope scope);
void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes);
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
@@ -10,6 +10,11 @@ public class RegionController(RegionService service) : ControllerBase
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Suggested code for the Add Region form; the user may override it.
[HttpGet("next-code")]
public async Task<IActionResult> GetNextCode() =>
Ok(await service.GetNextCodeAsync());
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -38,6 +43,34 @@ public class RegionController(RegionService service) : ControllerBase
return deleted ? NoContent() : NotFound();
}
[HttpGet("{id:guid}/manager")]
public async Task<IActionResult> GetManager(Guid id)
{
var result = await service.GetManagerAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/manager")]
public async Task<IActionResult> SetManager(Guid id, SetRegionManagerRequest request, CancellationToken ct)
{
try
{
var result = await service.SetManagerAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/manager")]
public async Task<IActionResult> RemoveManager(Guid id, CancellationToken ct)
{
var removed = await service.RemoveManagerAsync(id, ct);
return removed ? NoContent() : NotFound();
}
//targets/budgets/agencies/tax-rule.
[HttpPut("{id:guid}/manage")]
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
+13 -3
View File
@@ -1,10 +1,14 @@
namespace Distribution.Features.Region;
public record CreateRegionRequest(string Name);
// Code is optional: blank/null auto-generates the next REG-#### code; otherwise stored as entered (trimmed).
public record CreateRegionRequest(string Name, string? Code = null);
public record UpdateRegionRequest(string Name);
public record NextRegionCodeResponse(string Code);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
// Code is auto-generated on create; on update it is optional (null/blank keeps the current code).
public record UpdateRegionRequest(string Name, string? Code = null);
public record RegionResponse(Guid Id, string Code, string Name, bool IsActive, DateTime CreatedAt);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
@@ -61,3 +65,9 @@ public record RegionManageEntryResponse(
string? RuleConfig,
Guid? AgencyUserId,
DateTime CreatedAt);
// Region manager = the single active user with the RegionalManager role scoped to the region
// (UserRegionScope). Users themselves are owned by the auth service; this only links one to a region.
public record SetRegionManagerRequest(Guid UserId);
public record RegionManagerResponse(Guid RegionId, Guid UserId, string Username, string FullName);
@@ -11,6 +11,32 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Region>> GetAllAsync() =>
await context.Regions.ToListAsync();
// Includes soft-deleted regions: the unique index on Code doesn't know about IsActive.
public async Task<List<string>> GetAllCodesAsync() =>
await context.Regions.Select(r => r.Code).ToListAsync();
public async Task<bool> CodeExistsAsync(string code, Guid? excludeId = null) =>
await context.Regions.AnyAsync(r => r.Code == code && (excludeId == null || r.Id != excludeId));
public async Task<Models.User?> GetUserWithRoleAsync(Guid userId) =>
await context.Users.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == userId);
public async Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId) =>
await context.UserRegionScopes
.Include(s => s.User).ThenInclude(u => u.Role)
.Where(s => s.FK_RegionId == regionId && s.User.IsActive && s.User.Role.Name == "RegionalManager")
.ToListAsync();
public async Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId) =>
await context.UserRegionScopes.FirstOrDefaultAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId);
public async Task AddRegionScopeAsync(Models.UserRegionScope scope) =>
await context.UserRegionScopes.AddAsync(scope);
// Scope rows are hard-deleted (composite key user+region), same as UserRepository.ReplaceRegionScopesAsync.
public void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes) =>
context.UserRegionScopes.RemoveRange(scopes);
public async Task AddAsync(Models.Region entity) =>
await context.Regions.AddAsync(entity);
+97 -5
View File
@@ -6,6 +6,7 @@ namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RegionalAgencyRoleLevel = 60;
private const string CodePrefix = "REG-";
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
@@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RegionResponse(r.Id, r.Name, r.IsActive, r.CreatedAt))
.Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
.ToList();
public async Task<RegionResponse?> GetByIdAsync(Guid id)
{
var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null;
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
// Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused).
public async Task<NextRegionCodeResponse> GetNextCodeAsync() => new(await GenerateCodeAsync());
private async Task<string> GenerateCodeAsync()
{
var max = (await repo.GetAllCodesAsync())
.Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(c[CodePrefix.Length..], out _))
.Select(c => int.Parse(c[CodePrefix.Length..]))
.DefaultIfEmpty(0)
.Max();
return $"{CodePrefix}{max + 1:D4}";
}
public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
{
var region = new Models.Region { Name = request.Name };
string code;
if (string.IsNullOrWhiteSpace(request.Code))
code = await GenerateCodeAsync();
else
{
code = request.Code.Trim();
if (await repo.CodeExistsAsync(code))
throw new InvalidOperationException($"Region code '{code}' is already in use.");
}
var region = new Models.Region { Name = request.Name, Code = code };
await uow.BeginAsync(ct);
try
{
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
@@ -46,6 +70,13 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null;
region.Name = request.Name;
if (!string.IsNullOrWhiteSpace(request.Code))
{
var code = request.Code.Trim();
if (code != region.Code && await repo.CodeExistsAsync(code, id))
throw new InvalidOperationException($"Region code '{code}' is already in use.");
region.Code = code;
}
await uow.BeginAsync(ct);
try
{
@@ -57,7 +88,68 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
private const string RegionalManagerRole = "RegionalManager";
public async Task<RegionManagerResponse?> GetManagerAsync(Guid regionId)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault();
return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName);
}
// A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it.
// Returns null when the region does not exist; throws InvalidOperationException for an invalid user.
public async Task<RegionManagerResponse?> SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var user = await repo.GetUserWithRoleAsync(request.UserId);
if (user is null || !user.IsActive)
throw new InvalidOperationException("User not found.");
if (user.Role?.Name != RegionalManagerRole)
throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region.");
await uow.BeginAsync(ct);
try
{
var existing = await repo.GetManagerScopesAsync(regionId);
repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id));
if (await repo.GetRegionScopeAsync(user.Id, regionId) is null)
await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId });
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName);
}
public async Task<bool> RemoveManagerAsync(Guid regionId, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return false;
var existing = await repo.GetManagerScopesAsync(regionId);
if (existing.Count == 0) return false;
await uow.BeginAsync(ct);
try
{
repo.RemoveRegionScopes(existing);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -6,6 +6,7 @@ public interface IStockRepository
{
// Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
@@ -20,4 +21,12 @@ public interface IStockRepository
void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
Task AddItemBatchAsync(ItemBatch batch);
// Serial numbers
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
Task AddItemSerialNumberAsync(ItemSerialNumber serial);
}
+20 -1
View File
@@ -9,7 +9,14 @@ public class StockRepository(AppDBContext db) : IStockRepository
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId);
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) =>
db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
db.StockBalances
@@ -90,4 +97,16 @@ public class StockRepository(AppDBContext db) : IStockRepository
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.CountAsync();
}
public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
public async Task AddItemBatchAsync(ItemBatch batch) =>
await db.ItemBatches.AddAsync(batch);
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) =>
await db.ItemSerialNumbers.AddAsync(serial);
}
@@ -42,10 +42,11 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
[HttpPut("{key}")]
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var userId = currentUser.UserId ?? request.UserId;
if (userId is null) return BadRequest(new { error = "UserId is required while auth is unavailable" });
try
{
var result = await service.SetAsync(key, request.Value, userId, ct);
var result = await service.SetAsync(key, request.Value, userId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
@@ -4,6 +4,6 @@ namespace Distribution.Features.SysConfig;
public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy);
public record SetSysConfigRequest(string Value);
public record SetSysConfigRequest(string Value, Guid? UserId);
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
@@ -7,6 +7,22 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
{
public const string KeyInvoiceCreationMode = "InvoiceCreationMode";
public const string KeySerialNumberTrackingEnabled = "Feature.SerialNumberTracking";
public const string KeyBatchTrackingEnabled = "Feature.BatchTracking";
public const string KeyExpiryDateTrackingEnabled = "Feature.ExpiryDateTracking";
public const string KeyWarrantyTrackingEnabled = "Feature.WarrantyTracking";
public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride";
public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays";
private static readonly string[] BooleanFeatureKeys =
[
KeySerialNumberTrackingEnabled,
KeyBatchTrackingEnabled,
KeyExpiryDateTrackingEnabled,
KeyWarrantyTrackingEnabled,
KeyCustomWarrantyOverrideEnabled,
];
public async Task<List<SysConfigResponse>> 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;
}
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)
{
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
throw new InvalidOperationException(
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<InvoiceCreationMode>())}.");
if (BooleanFeatureKeys.Contains(key) && !bool.TryParse(value, out _))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Allowed values: true, false.");
if (key == KeyDefaultWarrantyPeriodDays && (!int.TryParse(value, out var days) || days < 0))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a non-negative integer.");
}
private static SysConfigResponse ToResponse(Models.SysConfig c) =>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,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")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_PurchaseOrderId")
b.Property<Guid?>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ReceivedByUserId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_SupplierId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_WarehouseId")
.HasColumnType("char(36)");
@@ -446,6 +449,8 @@ namespace Distribution.Migrations
b.HasIndex("FK_PurchaseOrderId");
b.HasIndex("FK_SupplierId");
b.HasIndex("FK_WarehouseId");
b.ToTable("GoodsReceipts");
@@ -478,7 +483,10 @@ namespace Distribution.Migrations
b.Property<Guid>("FK_GoodsReceiptId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_PurchaseOrderItemId")
b.Property<Guid?>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_PurchaseOrderItemId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
@@ -491,6 +499,8 @@ namespace Distribution.Migrations
b.HasIndex("FK_GoodsReceiptId");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_PurchaseOrderItemId");
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 =>
{
b.Property<Guid>("Id")
@@ -600,6 +667,9 @@ namespace Distribution.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<bool>("AllowCustomWarrantyPeriod")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("CostPrice")
.HasColumnType("decimal(65,30)");
@@ -624,6 +694,9 @@ namespace Distribution.Migrations
b.Property<Guid?>("FK_SubCategoryId")
.HasColumnType("char(36)");
b.Property<bool>("HasWarranty")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
@@ -634,7 +707,6 @@ namespace Distribution.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<decimal?>("ReorderLevel")
.HasColumnType("decimal(65,30)");
@@ -648,6 +720,18 @@ namespace Distribution.Migrations
b.Property<int>("SyncSource")
.HasColumnType("int");
b.Property<bool>("TracksBatches")
.HasColumnType("tinyint(1)");
b.Property<bool>("TracksExpiryDate")
.HasColumnType("tinyint(1)");
b.Property<bool>("TracksSerialNumbers")
.HasColumnType("tinyint(1)");
b.Property<int?>("WarrantyPeriodDays")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_BaseUomId");
@@ -664,61 +748,157 @@ namespace Distribution.Migrations
new
{
Id = new Guid("23000000-0000-0000-0000-000000000001"),
AllowCustomWarrantyPeriod = false,
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
Description = "Coca-Cola 500ml bottle",
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
HasWarranty = false,
IsActive = true,
Name = "Coca-Cola 500ml",
SalePrice = 60m,
Sku = "BEV-COKE-500",
SyncSource = 0
SyncSource = 0,
TracksBatches = false,
TracksExpiryDate = false,
TracksSerialNumbers = false
},
new
{
Id = new Guid("23000000-0000-0000-0000-000000000002"),
AllowCustomWarrantyPeriod = false,
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
Description = "Coca-Cola 1.5L bottle",
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000001"),
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000001"),
HasWarranty = false,
IsActive = true,
Name = "Coca-Cola 1.5L",
SalePrice = 120m,
Sku = "BEV-COKE-1500",
SyncSource = 0
SyncSource = 0,
TracksBatches = false,
TracksExpiryDate = false,
TracksSerialNumbers = false
},
new
{
Id = new Guid("23000000-0000-0000-0000-000000000003"),
AllowCustomWarrantyPeriod = false,
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
Description = "Lays Classic Salted 52g pack",
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
HasWarranty = false,
IsActive = true,
Name = "Lays Classic 52g",
SalePrice = 50m,
Sku = "SNK-LAYS-52",
SyncSource = 0
SyncSource = 0,
TracksBatches = false,
TracksExpiryDate = false,
TracksSerialNumbers = false
},
new
{
Id = new Guid("23000000-0000-0000-0000-000000000004"),
AllowCustomWarrantyPeriod = false,
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
Description = "Lays Family Pack 150g",
FK_BaseUomId = new Guid("22000000-0000-0000-0000-000000000001"),
FK_BrandId = new Guid("21000000-0000-0000-0000-000000000002"),
FK_CategoryId = new Guid("20000000-0000-0000-0000-000000000002"),
HasWarranty = false,
IsActive = true,
Name = "Lays Family Pack 150g",
SalePrice = 150m,
Sku = "SNK-LAYS-FAM",
SyncSource = 0
SyncSource = 0,
TracksBatches = false,
TracksExpiryDate = false,
TracksSerialNumbers = false
});
});
modelBuilder.Entity("Distribution.Models.ItemBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("BatchNumber")
.HasColumnType("varchar(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("ExpiryDate")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("ManufactureDate")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("FK_ItemId", "BatchNumber");
b.ToTable("ItemBatches");
});
modelBuilder.Entity("Distribution.Models.ItemSerialNumber", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("CurrentLocationId")
.HasColumnType("char(36)");
b.Property<int?>("CurrentLocationType")
.HasColumnType("int");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("datetime(6)");
b.Property<string>("SerialNumber")
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("WarrantyExpiresAt")
.HasColumnType("datetime(6)");
b.Property<int?>("WarrantyPeriodDaysOverride")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_ItemId", "SerialNumber")
.IsUnique();
b.ToTable("ItemSerialNumbers");
});
modelBuilder.Entity("Distribution.Models.LoadingSheet", b =>
{
b.Property<Guid>("Id")
@@ -1294,6 +1474,11 @@ namespace Distribution.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -1306,6 +1491,9 @@ namespace Distribution.Migrations
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Regions");
b.HasData(
@@ -1314,6 +1502,7 @@ namespace Distribution.Migrations
Id = new Guid("24000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true,
Code = "REG-0001",
Name = "Western Province"
},
new
@@ -1321,6 +1510,7 @@ namespace Distribution.Migrations
Id = new Guid("24000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified),
IsActive = true,
Code = "REG-0002",
Name = "Central Province"
});
});
@@ -2239,9 +2429,15 @@ namespace Distribution.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ItemBatchId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ItemSerialNumberId")
.HasColumnType("char(36)");
b.Property<Guid>("LocationId")
.HasColumnType("char(36)");
@@ -2256,7 +2452,11 @@ namespace Distribution.Migrations
b.HasKey("Id");
b.HasIndex("FK_ItemId", "LocationId")
b.HasIndex("FK_ItemBatchId");
b.HasIndex("FK_ItemSerialNumberId");
b.HasIndex("FK_ItemId", "LocationId", "FK_ItemBatchId", "FK_ItemSerialNumberId")
.IsUnique();
b.ToTable("StockBalances");
@@ -2303,9 +2503,15 @@ namespace Distribution.Migrations
b.Property<Guid>("CreatedBy")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ItemBatchId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_ItemSerialNumberId")
.HasColumnType("char(36)");
b.Property<Guid?>("FromLocationId")
.HasColumnType("char(36)");
@@ -2332,8 +2538,12 @@ namespace Distribution.Migrations
b.HasKey("Id");
b.HasIndex("FK_ItemBatchId");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_ItemSerialNumberId");
b.ToTable("StockMovements");
b.HasData(
@@ -2763,6 +2973,42 @@ namespace Distribution.Migrations
Key = "InvoiceCreationMode",
UpdatedAt = new DateTime(2026, 9, 17, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "ModeB"
},
new
{
Key = "Feature.SerialNumberTracking",
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "false"
},
new
{
Key = "Feature.BatchTracking",
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "false"
},
new
{
Key = "Feature.ExpiryDateTracking",
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "false"
},
new
{
Key = "Feature.WarrantyTracking",
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "false"
},
new
{
Key = "Feature.CustomWarrantyOverride",
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "false"
},
new
{
Key = "Feature.DefaultWarrantyPeriodDays",
UpdatedAt = new DateTime(2026, 10, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
Value = "0"
});
});
@@ -3256,8 +3502,12 @@ namespace Distribution.Migrations
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
.WithMany()
.HasForeignKey("FK_PurchaseOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Supplier", "Supplier")
.WithMany()
.HasForeignKey("FK_SupplierId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Warehouse", "Warehouse")
.WithMany()
@@ -3267,6 +3517,8 @@ namespace Distribution.Migrations
b.Navigation("PurchaseOrder");
b.Navigation("Supplier");
b.Navigation("Warehouse");
});
@@ -3278,17 +3530,61 @@ namespace Distribution.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem")
.WithMany()
.HasForeignKey("FK_PurchaseOrderItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("GoodsReceipt");
b.Navigation("Item");
b.Navigation("PurchaseOrderItem");
});
modelBuilder.Entity("Distribution.Models.GoodsReceiptItemBatch", b =>
{
b.HasOne("Distribution.Models.GoodsReceiptItem", "GoodsReceiptItem")
.WithMany("Batches")
.HasForeignKey("FK_GoodsReceiptItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.ItemBatch", "ItemBatch")
.WithMany()
.HasForeignKey("FK_ItemBatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("GoodsReceiptItem");
b.Navigation("ItemBatch");
});
modelBuilder.Entity("Distribution.Models.GoodsReceiptItemSerial", b =>
{
b.HasOne("Distribution.Models.GoodsReceiptItem", "GoodsReceiptItem")
.WithMany("Serials")
.HasForeignKey("FK_GoodsReceiptItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
.WithMany()
.HasForeignKey("FK_ItemSerialNumberId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("GoodsReceiptItem");
b.Navigation("ItemSerialNumber");
});
modelBuilder.Entity("Distribution.Models.Invoice", b =>
{
b.HasOne("Distribution.Models.ShopPO", "ShopPO")
@@ -3331,6 +3627,28 @@ namespace Distribution.Migrations
b.Navigation("SubCategory");
});
modelBuilder.Entity("Distribution.Models.ItemBatch", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("Distribution.Models.ItemSerialNumber", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
});
modelBuilder.Entity("Distribution.Models.LoadingSheet", b =>
{
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
@@ -3746,24 +4064,52 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.StockBalance", b =>
{
b.HasOne("Distribution.Models.ItemBatch", "ItemBatch")
.WithMany()
.HasForeignKey("FK_ItemBatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
.WithMany()
.HasForeignKey("FK_ItemSerialNumberId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
b.Navigation("ItemBatch");
b.Navigation("ItemSerialNumber");
});
modelBuilder.Entity("Distribution.Models.StockMovement", b =>
{
b.HasOne("Distribution.Models.ItemBatch", "ItemBatch")
.WithMany()
.HasForeignKey("FK_ItemBatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.ItemSerialNumber", "ItemSerialNumber")
.WithMany()
.HasForeignKey("FK_ItemSerialNumberId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Item");
b.Navigation("ItemBatch");
b.Navigation("ItemSerialNumber");
});
modelBuilder.Entity("Distribution.Models.Store", b =>
@@ -3964,6 +4310,13 @@ namespace Distribution.Migrations
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b =>
{
b.Navigation("Batches");
b.Navigation("Serials");
});
modelBuilder.Entity("Distribution.Models.Invoice", b =>
{
b.Navigation("Payments");
+2
View File
@@ -44,4 +44,6 @@ namespace Distribution.Models
/// ModeB: Invoice created as a separate explicit step.
/// </summary>
public enum InvoiceCreationMode { ModeA, ModeB }
public enum SerialStatus { InStock, Sold, Returned, Damaged }
}
+8 -2
View File
@@ -4,9 +4,15 @@ namespace Distribution.Models
{
public class GoodsReceipt : Template
{
public Guid FK_PurchaseOrderId { get; set; }
/// <summary>Null for a direct receipt (goods received without a purchase order).</summary>
public Guid? FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
/// <summary>Optional supplier for a direct receipt; for PO receipts the supplier comes from the PO.</summary>
public Guid? FK_SupplierId { get; set; }
[ForeignKey("FK_SupplierId")]
public Supplier? Supplier { get; set; }
public Guid FK_ReceivedByUserId { get; set; }
+14 -2
View File
@@ -8,14 +8,26 @@ namespace Distribution.Models
[ForeignKey("FK_GoodsReceiptId")]
public GoodsReceipt GoodsReceipt { get; set; }
public Guid FK_PurchaseOrderItemId { get; set; }
/// <summary>Null for a direct receipt line (no purchase order); FK_ItemId is set instead.</summary>
public Guid? FK_PurchaseOrderItemId { get; set; }
[ForeignKey("FK_PurchaseOrderItemId")]
public PurchaseOrderItem PurchaseOrderItem { get; set; }
public PurchaseOrderItem? PurchaseOrderItem { get; set; }
/// <summary>Set for direct receipt lines. PO-based lines resolve the item through PurchaseOrderItem.</summary>
public Guid? FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item? Item { get; set; }
/// <summary>Added to warehouse stock.</summary>
public int AcceptedQuantity { get; set; }
/// <summary>Damaged/short at receipt, not added to stock.</summary>
public int RejectedQuantity { get; set; } = 0;
/// <summary>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; }
}
}
+7
View File
@@ -32,6 +32,13 @@ namespace Distribution.Models
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
public decimal? CostPrice { get; set; }
public bool TracksSerialNumbers { get; set; } = false;
public bool TracksBatches { get; set; } = false;
public bool TracksExpiryDate { get; set; } = false;
public bool HasWarranty { get; set; } = false;
public int? WarrantyPeriodDays { get; set; }
public bool AllowCustomWarrantyPeriod { get; set; } = false;
public SyncSource SyncSource { get; set; } = SyncSource.Manual;
public int? ExternalErpItemId { get; set; }
public DateTime? LastSyncedAt { get; set; }
+20
View File
@@ -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; }
}
}
+26
View File
@@ -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; }
}
}
+1
View File
@@ -2,6 +2,7 @@
{
public class Region : Template
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; }
}
}
+10
View File
@@ -13,6 +13,16 @@ namespace Distribution.Models
public Guid LocationId { get; set; }
public LocationType LocationType { get; set; }
/// <summary>Null for items that don't track batches/expiry — no behavior change for existing items.</summary>
public Guid? FK_ItemBatchId { get; set; }
[ForeignKey("FK_ItemBatchId")]
public ItemBatch? ItemBatch { get; set; }
/// <summary>Null for items that don't track serial numbers.</summary>
public Guid? FK_ItemSerialNumberId { get; set; }
[ForeignKey("FK_ItemSerialNumberId")]
public ItemSerialNumber? ItemSerialNumber { get; set; }
public decimal Quantity { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.Now;
}
+10
View File
@@ -16,6 +16,16 @@ namespace Distribution.Models
public Guid? ToLocationId { get; set; }
public LocationType? ToLocationType { get; set; }
/// <summary>Null for items that don't track batches/expiry — no behavior change for existing items.</summary>
public Guid? FK_ItemBatchId { get; set; }
[ForeignKey("FK_ItemBatchId")]
public ItemBatch? ItemBatch { get; set; }
/// <summary>Null for items that don't track serial numbers. Quantity is always 1 when set.</summary>
public Guid? FK_ItemSerialNumberId { get; set; }
[ForeignKey("FK_ItemSerialNumberId")]
public ItemSerialNumber? ItemSerialNumber { get; set; }
public decimal Quantity { get; set; }
public MovementType Type { get; set; }
+5
View File
@@ -30,6 +30,7 @@ using Distribution.Features.Sync;
using Distribution.Features.Uom;
using Distribution.Features.User;
using Distribution.Features.Vehicle;
using Distribution.Features.ProductReport;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Features.Warehouse;
@@ -62,6 +63,8 @@ builder.Services.AddScoped<IBrandRepository, BrandRepository>();
builder.Services.AddScoped<IUomRepository, UomRepository>();
builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
builder.Services.AddScoped<ProductReportService>();
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>();
@@ -75,6 +78,7 @@ builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>(
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
builder.Services.AddScoped<ISysConfigRepository, SysConfigRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
@@ -109,6 +113,7 @@ builder.Services.AddScoped<ClaimService>();
builder.Services.AddScoped<SupplierClaimService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddScoped<SysConfigService>();
builder.Services.AddHttpContextAccessor();
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'
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
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, getApiError } from '@/lib/api/index'
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
import type { ItemSummaryResponse } 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'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
import { ItemForm } from './item-form'
interface ItemSheetProps {
open: boolean
onClose: () => void
item: ItemSummaryResponse | null
item: ItemSummaryResponse
onSuccess: () => void
}
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 (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Item' : 'Add Item'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'}
</DialogDescription>
<DialogTitle>Edit Product</DialogTitle>
<DialogDescription>Update the product details below.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<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="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>
<ItemForm
item={item}
onSuccess={() => {
onSuccess()
onClose()
}}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
)
@@ -34,7 +34,7 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Item Details</DialogTitle>
<DialogTitle>Product Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
@@ -44,6 +44,27 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
<Row label="Brand" value={item.brandName ?? '—'} />
<Row label="Base UOM" value={item.baseUomName} />
<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="Status" value={<Badge variant={item.isActive ? 'default' : 'secondary'}>{item.isActive ? 'Active' : 'Inactive'}</Badge>} />
<Row label="Created At" value={fmtDate(item.createdAt)} />
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -16,6 +17,7 @@ import { ItemSheet } from './item-sheet'
import { ItemViewSheet } from './item-view-sheet'
export function ItemsClient() {
const router = useRouter()
const [data, setData] = useState<ItemSummaryResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -34,7 +36,7 @@ export function ItemsClient() {
setData(getPaginatedItems(res))
setTotal(res.total)
} 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 {
setLoading(false)
}
@@ -44,11 +46,6 @@ export function ItemsClient() {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(item: ItemSummaryResponse) {
setEditing(item)
setSheetOpen(true)
@@ -62,10 +59,10 @@ export function ItemsClient() {
async function handleDelete(item: ItemSummaryResponse) {
try {
await itemsApi.remove(item.id)
toast.add({ title: 'Item deactivated', type: 'success' })
toast.add({ title: 'Product deactivated', type: 'success' })
fetchData(page, pageSize)
} 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',
serverSide: 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] },
columns: [
{
@@ -137,17 +134,17 @@ export function ItemsClient() {
actions: () => [
{
action: 'view',
label: 'View item',
label: 'View product',
onClick: openView,
},
{
action: 'edit',
label: 'Edit item',
label: 'Edit product',
onClick: openEdit,
},
{
action: 'cancel',
label: 'Delete item',
label: 'Delete product',
onClick: handleDelete,
visible: (target) => target.isActive,
},
@@ -159,14 +156,14 @@ export function ItemsClient() {
<>
<div className="flex items-center justify-between">
<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">
Products available for sale and distribution
</p>
</div>
<Button onClick={openCreate}>
<Button onClick={() => router.push('/catalogue/items/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Item
Add Product
</Button>
</div>
@@ -181,12 +178,14 @@ export function ItemsClient() {
}}
/>
<ItemSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
item={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
{editing && (
<ItemSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
item={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
)}
<ItemViewSheet
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>
)
}
@@ -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&apos;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&apos;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>
)
}
@@ -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 { 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 {
Dialog,
@@ -20,8 +19,8 @@ import { regions as regionsApi, getApiError } from '@/lib/api/index'
import type { RegionResponse } from '@/types/api'
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'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
@@ -30,30 +29,39 @@ interface RegionSheetProps {
open: boolean
onClose: () => void
region: RegionResponse | null
readOnly?: boolean
onSuccess: () => void
}
export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetProps) {
export function RegionSheet({ open, onClose, region, readOnly = false, onSuccess }: RegionSheetProps) {
const isEdit = !!region
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
defaultValues: { code: '', name: '' },
})
useEffect(() => {
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])
async function onSubmit(values: FormValues) {
try {
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' })
} else {
await regionsApi.create(values)
await regionsApi.create({ name: values.name, code: values.code?.trim() || undefined })
toast.add({ title: 'Region created', type: 'success' })
}
onSuccess()
@@ -67,14 +75,37 @@ export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetPro
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
<DialogTitle>{readOnly ? 'View Region' : isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
<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>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<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
control={form.control}
name="name"
@@ -89,36 +120,20 @@ export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetPro
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Western Province"
disabled={readOnly}
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
{readOnly ? 'Close' : 'Cancel'}
</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'}
</Button>
</Button>)}
</DialogFooter>
</form>
</DialogContent>
@@ -8,11 +8,13 @@ import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RegionResponse } from '@/types/api'
import { RegionSheet } from './region-sheet'
import { RegionManagerDialog } from './region-manager-dialog'
export function RegionsClient() {
const [data, setData] = useState<RegionResponse[]>([])
@@ -23,6 +25,10 @@ export function RegionsClient() {
const [sheetOpen, setSheetOpen] = useState(false)
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) => {
setLoading(true)
@@ -43,24 +49,34 @@ export function RegionsClient() {
function openCreate() {
setEditing(null)
setViewOnly(false)
setSheetOpen(true)
}
function openEdit(region: RegionResponse) {
setEditing(region)
setViewOnly(false)
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 {
await regionsApi.update(region.id, { isActive: !region.isActive })
toast.add({
title: region.isActive ? 'Region deactivated' : 'Region activated',
type: 'success',
})
await regionsApi.remove(deleting.id)
toast.add({ title: 'Region deleted', type: 'success' })
setDeleting(null)
fetchData(page, pageSize)
} 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.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [
{
key: 'code',
title: 'Code',
fields: [{ key: 'code', primary: true }],
sortable: true,
width: 120,
},
{
key: 'name',
title: 'Region',
@@ -103,6 +126,16 @@ export function RegionsClient() {
],
rowActions: {
actions: () => [
{
action: 'confirm',
label: 'Manage region manager',
onClick: (region: RegionResponse) => setManagerRegion(region),
},
{
action: 'view',
label: 'View region',
onClick: openView,
},
{
action: 'edit',
label: 'Edit region',
@@ -110,19 +143,12 @@ export function RegionsClient() {
},
{
action: 'deactivate',
label: 'Deactivate region',
onClick: toggleActive,
visible: (target) => target.isActive,
},
{
action: 'activate',
label: 'Activate region',
onClick: toggleActive,
visible: (target) => !target.isActive,
label: 'Delete region',
onClick: (region: RegionResponse) => setDeleting(region),
},
],
},
}), [pageSize, openEdit, toggleActive])
}), [pageSize, openEdit, openView])
return (
<>
@@ -154,8 +180,34 @@ export function RegionsClient() {
open={sheetOpen}
onClose={() => setSheetOpen(false)}
region={editing}
readOnly={viewOnly}
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,
label: 'Items',
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,
@@ -321,10 +321,16 @@ function LoadingSheetForm() {
let changed = false
const next: Record<string, PickedLine> = {}
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 quantity = Math.min(l.quantity, avail)
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
next[key] = { ...l, extras, quantity, checked }
}
@@ -452,7 +458,12 @@ function LoadingSheetForm() {
const avail = availIn(warehouseId, line.itemId)
if (!stockLoaded || avail <= 0) return
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
}
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
@@ -912,10 +923,18 @@ function LoadingSheetForm() {
<tr>
<td className="px-3 pb-2"></td>
<td className="px-3 pb-2" colSpan={7}>
<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)}>
Take remaining from another warehouse
</Button>
{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>
<Button type="button" variant="outline" size="sm" onClick={() => addExtra(line)}>
Take remaining from another warehouse
</Button>
</>
)}
</td>
</tr>
)}
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
@@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api'
import { GrnSheet } from './grn-sheet'
import { GrnViewSheet } from './grn-view-sheet'
const fmtDate = (v: string) =>
@@ -18,12 +18,12 @@ const fmtDate = (v: string) =>
const truncId = (id: string) => id.substring(0, 8)
export function GrnClient() {
const router = useRouter()
const [data, setData] = useState<GrnResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [viewing, setViewing] = useState<GrnResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
@@ -71,7 +71,9 @@ export function GrnClient() {
fields: ['purchaseOrderId'],
width: 140,
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>
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
<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>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Record Receipt
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Direct Receipt
</Button>
<Button onClick={() => router.push('/procurement/grn/po/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Receipt Against PO
</Button>
</div>
</div>
<DataTable
@@ -139,12 +147,6 @@ export function GrnClient() {
}}
/>
<GrnSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<GrnViewSheet
open={viewOpen}
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',
})
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface GrnViewSheetProps {
open: boolean
onClose: () => void
@@ -40,7 +43,10 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
</DialogHeader>
<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="Received At" value={fmtDateTime(grn.receivedAt)} />
<Row label="Created" value={fmtDateTime(grn.createdAt)} />
@@ -65,6 +71,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<td className="px-3 py-2">
{item.itemName}
<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 className="px-3 py-2 text-right">{item.acceptedQuantity}</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>
)
}
@@ -1,6 +1,9 @@
'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'
@@ -13,31 +16,41 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
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 { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi,
users as usersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} 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({
purchaseOrderItemId: z.string().min(1),
acceptedQuantity: 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({
@@ -50,22 +63,20 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>
interface GrnSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function todayIso() {
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 [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
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>({
resolver: zodResolver(schema),
@@ -80,11 +91,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
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(() => {
if (!open) return
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), receivedByUserId: '', items: [] })
setSelectedPO(null)
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
@@ -100,7 +118,7 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
}, [])
// When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) {
@@ -113,15 +131,21 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
setLoadingPO(true)
try {
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)
// Pre-fill items: remaining quantity for each non-cancelled line
const lineItems = (po.items ?? [])
.filter(item => !item.isCancelled)
.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
}))
const lineItems = activeLines.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
serialText: '',
batches: [],
}))
replace(lineItems)
} catch (err) {
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
@@ -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) {
const items = buildItems(values, poItems)
if (!items) return
try {
await grnApi.create({
purchaseOrderId: values.purchaseOrderId,
warehouseId: values.warehouseId,
receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items: values.items,
items,
receivedByUserId: values.receivedByUserId,
})
toast.add({ title: 'Goods receipt recorded', type: 'success' })
onSuccess()
onClose()
router.push('/procurement/grn')
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Record Goods Receipt</DialogTitle>
<DialogDescription>
Record stock received into a warehouse against a purchase order.
</DialogDescription>
</DialogHeader>
<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">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>
<FormField
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>
)
})}
@@ -313,14 +371,13 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0 || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</DialogFooter>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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,
} from '@/components/ui/tooltip'
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 {
if (!item.permission) return true
@@ -54,6 +54,8 @@ export function AppSidebar() {
const pathname = usePathname()
const dashboardActive = pathname === DASHBOARD_ITEM.href
const configurationActive =
pathname === CONFIGURATION_ITEM.href || pathname.startsWith(CONFIGURATION_ITEM.href + '/')
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
useEffect(() => {
@@ -182,6 +184,22 @@ export function AppSidebar() {
</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>
<SidebarFooter>
+99
View File
@@ -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,
}
+31
View File
@@ -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 }
+20 -3
View File
@@ -26,6 +26,7 @@ import {
ClipboardListIcon,
BankIcon,
PackageMoving01Icon,
Settings01Icon,
} from '@hugeicons/core-free-icons'
export interface NavItem {
@@ -49,6 +50,13 @@ export const DASHBOARD_ITEM: NavItem = {
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[] = [
{
title: 'Shop POs & Invoices',
@@ -80,7 +88,7 @@ export const NAV_GROUPS: NavGroup[] = [
title: 'Catalogue',
icon: CatalogueIcon,
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: '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' },
@@ -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: 'Procurement',
icon: ShoppingCart01Icon,
items: [
{ 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: '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
@@ -162,5 +179,5 @@ export const BOTTOM_NAV_CANDIDATES: NavItem[] = [
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' },
{ title: 'Loading', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.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' },
]
+7
View File
@@ -28,6 +28,13 @@ export interface ItemBody {
brandId?: string | null
baseUomId: string
salePrice: number
reorderLevel?: number | null
tracksSerialNumbers?: boolean
tracksBatches?: boolean
tracksExpiryDate?: boolean
hasWarranty?: boolean
warrantyPeriodDays?: number | null
allowCustomWarrantyPeriod?: boolean
syncSource?: SyncSource
isActive?: boolean
}
+2
View File
@@ -8,8 +8,10 @@ export { drivers, compliance } from './drivers'
export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock'
export { productReports } from './reports'
export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config'
export { getPaginatedItems } from './paginated'
export type { PageParams } from './master'
+14
View File
@@ -2,6 +2,7 @@ import { api } from '@/lib/api'
import type {
PaginatedResponse,
RegionResponse,
RegionManagerResponse,
ManageRegionResponse,
RegionHierarchyResponse,
RegionDashboardResponse,
@@ -84,6 +85,7 @@ export interface PageParams {
export interface RegionBody {
name: string
code?: string
isActive?: boolean
}
@@ -91,6 +93,18 @@ export const regions = {
list: (params?: PageParams) =>
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) =>
api.get<RegionResponse>(`/regions/${id}`).then(r => r.data),
+27 -1
View File
@@ -59,10 +59,24 @@ export const purchaseOrders = {
// ─── 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 {
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
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 {
@@ -74,6 +88,14 @@ export interface GrnBody {
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 = {
list: (params?: PageParams & { purchaseOrderId?: string }) =>
api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data),
@@ -83,6 +105,10 @@ export const grn = {
create: (body: GrnBody) =>
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 ─────────────────────────────────────────────────────────
+87
View File
@@ -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),
}
+24
View File
@@ -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),
}
+29 -2
View File
@@ -71,6 +71,7 @@ export interface PermissionResponse {
export interface RegionResponse {
id: string
code: string
name: string
isActive: boolean
createdAt: string
@@ -282,6 +283,13 @@ export interface ItemSummaryResponse {
baseUomId: string
baseUomName: string
salePrice: number
reorderLevel: number | null
tracksSerialNumbers: boolean
tracksBatches: boolean
tracksExpiryDate: boolean
hasWarranty: boolean
warrantyPeriodDays: number | null
allowCustomWarrantyPeriod: boolean
syncSource: SyncSource
isActive: boolean
createdAt: string
@@ -393,22 +401,34 @@ export interface PurchaseOrderResponse {
items: PurchaseOrderItemResponse[]
}
export interface GrnBatchLineResponse {
batchNumber: string | null
expiryDate: string | null
quantity: number
}
export interface GrnItemResponse {
id: string
purchaseOrderItemId: string
/** Null on a direct receipt line (no purchase order). */
purchaseOrderItemId: string | null
itemId: string
sku: string
itemName: string
acceptedQuantity: number
rejectedQuantity: number
batches: GrnBatchLineResponse[]
serialNumbers: string[]
}
export interface GrnResponse {
id: string
purchaseOrderId: string
/** Null for a direct receipt (goods received without a purchase order). */
purchaseOrderId: string | null
receivedByUserId: string
warehouseId: string
warehouseName: string
supplierId: string | null
supplierName: string | null
receivedAt: string
createdAt: string
items: GrnItemResponse[]
@@ -709,3 +729,10 @@ export interface SysConfigResponse {
export interface ApiError {
error: string
}
export interface RegionManagerResponse {
regionId: string
userId: string
username: string
fullName: string
}