Fix changes #70
@@ -18,6 +18,7 @@ public class RegionServiceTests
|
||||
public RegionServiceTests()
|
||||
{
|
||||
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||
}
|
||||
|
||||
@@ -148,6 +149,123 @@ public class RegionServiceTests
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
// Region code
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||
|
||||
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
|
||||
|
||||
Assert.Equal("REG-0001", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
|
||||
|
||||
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
|
||||
|
||||
Assert.Equal("REG-0010", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_StoresProvidedCode_AsEntered()
|
||||
{
|
||||
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
|
||||
|
||||
Assert.Equal("nth-1", result.Code);
|
||||
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
|
||||
{
|
||||
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
|
||||
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextCodeAsync_ReturnsSuggestion()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
|
||||
|
||||
var result = await _sut.GetNextCodeAsync();
|
||||
|
||||
Assert.Equal("REG-0004", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
|
||||
|
||||
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
|
||||
|
||||
Assert.Equal("north-1", result!.Code);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
|
||||
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
|
||||
|
||||
Assert.Equal("REG-0001", result!.Code);
|
||||
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
|
||||
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_AllowsResubmittingOwnCode()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0001";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
|
||||
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
|
||||
|
||||
Assert.Equal("REG-0001", result!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsCode()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
region.Code = "REG-0007";
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
|
||||
var result = await _sut.GetByIdAsync(region.Id);
|
||||
|
||||
Assert.Equal("REG-0007", result!.Code);
|
||||
}
|
||||
|
||||
// DeleteAsync
|
||||
|
||||
[Fact]
|
||||
@@ -195,4 +313,93 @@ public class RegionServiceTests
|
||||
|
||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
// Region manager
|
||||
|
||||
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
|
||||
Role = new Role { Name = role, Level = 50 }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
|
||||
{
|
||||
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
|
||||
|
||||
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser("SalesRep");
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser();
|
||||
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
|
||||
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
|
||||
|
||||
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||
|
||||
Assert.Equal(user.Id, result!.UserId);
|
||||
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
|
||||
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
|
||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser();
|
||||
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
|
||||
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
|
||||
|
||||
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||
|
||||
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
|
||||
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
|
||||
|
||||
Assert.False(await _sut.RemoveManagerAsync(region.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetManagerAsync_ReturnsManager()
|
||||
{
|
||||
var region = ActiveRegion();
|
||||
var user = ManagerUser();
|
||||
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
|
||||
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
|
||||
|
||||
var result = await _sut.GetManagerAsync(region.Id);
|
||||
|
||||
Assert.Equal("mgr", result!.Username);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<Uom> Uoms { get; set; }
|
||||
public DbSet<UomConversion> UomConversions { get; set; }
|
||||
public DbSet<Item> Items { get; set; }
|
||||
public DbSet<ItemBatch> ItemBatches { get; set; }
|
||||
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
|
||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
namespace Distribution.Features.Region;
|
||||
|
||||
public record CreateRegionRequest(string Name);
|
||||
// Code is optional: blank/null auto-generates the next REG-#### code; otherwise stored as entered (trimmed).
|
||||
public record CreateRegionRequest(string Name, string? Code = null);
|
||||
|
||||
public record UpdateRegionRequest(string Name);
|
||||
public record NextRegionCodeResponse(string Code);
|
||||
|
||||
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||
// Code is auto-generated on create; on update it is optional (null/blank keeps the current code).
|
||||
public record UpdateRegionRequest(string Name, string? Code = null);
|
||||
|
||||
public record RegionResponse(Guid Id, string Code, string Name, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||
|
||||
@@ -61,3 +65,9 @@ public record RegionManageEntryResponse(
|
||||
string? RuleConfig,
|
||||
Guid? AgencyUserId,
|
||||
DateTime CreatedAt);
|
||||
|
||||
// Region manager = the single active user with the RegionalManager role scoped to the region
|
||||
// (UserRegionScope). Users themselves are owned by the auth service; this only links one to a region.
|
||||
public record SetRegionManagerRequest(Guid UserId);
|
||||
|
||||
public record RegionManagerResponse(Guid RegionId, Guid UserId, string Username, string FullName);
|
||||
|
||||
@@ -11,6 +11,32 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
||||
public async Task<List<Models.Region>> GetAllAsync() =>
|
||||
await context.Regions.ToListAsync();
|
||||
|
||||
// Includes soft-deleted regions: the unique index on Code doesn't know about IsActive.
|
||||
public async Task<List<string>> GetAllCodesAsync() =>
|
||||
await context.Regions.Select(r => r.Code).ToListAsync();
|
||||
|
||||
public async Task<bool> CodeExistsAsync(string code, Guid? excludeId = null) =>
|
||||
await context.Regions.AnyAsync(r => r.Code == code && (excludeId == null || r.Id != excludeId));
|
||||
|
||||
public async Task<Models.User?> GetUserWithRoleAsync(Guid userId) =>
|
||||
await context.Users.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
public async Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId) =>
|
||||
await context.UserRegionScopes
|
||||
.Include(s => s.User).ThenInclude(u => u.Role)
|
||||
.Where(s => s.FK_RegionId == regionId && s.User.IsActive && s.User.Role.Name == "RegionalManager")
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId) =>
|
||||
await context.UserRegionScopes.FirstOrDefaultAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId);
|
||||
|
||||
public async Task AddRegionScopeAsync(Models.UserRegionScope scope) =>
|
||||
await context.UserRegionScopes.AddAsync(scope);
|
||||
|
||||
// Scope rows are hard-deleted (composite key user+region), same as UserRepository.ReplaceRegionScopesAsync.
|
||||
public void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes) =>
|
||||
context.UserRegionScopes.RemoveRange(scopes);
|
||||
|
||||
public async Task AddAsync(Models.Region entity) =>
|
||||
await context.Regions.AddAsync(entity);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Distribution.Features.Region;
|
||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
{
|
||||
private const int RegionalAgencyRoleLevel = 60;
|
||||
private const string CodePrefix = "REG-";
|
||||
|
||||
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
||||
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
|
||||
@@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||
(await repo.GetAllAsync())
|
||||
.Where(r => r.IsActive)
|
||||
.Select(r => new RegionResponse(r.Id, r.Name, r.IsActive, r.CreatedAt))
|
||||
.Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
public async Task<RegionResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(id);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
|
||||
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
||||
}
|
||||
|
||||
// Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused).
|
||||
public async Task<NextRegionCodeResponse> GetNextCodeAsync() => new(await GenerateCodeAsync());
|
||||
|
||||
private async Task<string> GenerateCodeAsync()
|
||||
{
|
||||
var max = (await repo.GetAllCodesAsync())
|
||||
.Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(c[CodePrefix.Length..], out _))
|
||||
.Select(c => int.Parse(c[CodePrefix.Length..]))
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return $"{CodePrefix}{max + 1:D4}";
|
||||
}
|
||||
|
||||
public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = new Models.Region { Name = request.Name };
|
||||
string code;
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
code = await GenerateCodeAsync();
|
||||
else
|
||||
{
|
||||
code = request.Code.Trim();
|
||||
if (await repo.CodeExistsAsync(code))
|
||||
throw new InvalidOperationException($"Region code '{code}' is already in use.");
|
||||
}
|
||||
var region = new Models.Region { Name = request.Name, Code = code };
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
|
||||
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
|
||||
@@ -46,6 +70,13 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
var region = await repo.GetByIdAsync(id);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
region.Name = request.Name;
|
||||
if (!string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
var code = request.Code.Trim();
|
||||
if (code != region.Code && await repo.CodeExistsAsync(code, id))
|
||||
throw new InvalidOperationException($"Region code '{code}' is already in use.");
|
||||
region.Code = code;
|
||||
}
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
@@ -57,7 +88,68 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
|
||||
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
|
||||
}
|
||||
|
||||
private const string RegionalManagerRole = "RegionalManager";
|
||||
|
||||
public async Task<RegionManagerResponse?> GetManagerAsync(Guid regionId)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault();
|
||||
return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName);
|
||||
}
|
||||
|
||||
// A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it.
|
||||
// Returns null when the region does not exist; throws InvalidOperationException for an invalid user.
|
||||
public async Task<RegionManagerResponse?> SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return null;
|
||||
|
||||
var user = await repo.GetUserWithRoleAsync(request.UserId);
|
||||
if (user is null || !user.IsActive)
|
||||
throw new InvalidOperationException("User not found.");
|
||||
if (user.Role?.Name != RegionalManagerRole)
|
||||
throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region.");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
var existing = await repo.GetManagerScopesAsync(regionId);
|
||||
repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id));
|
||||
if (await repo.GetRegionScopeAsync(user.Id, regionId) is null)
|
||||
await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId });
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName);
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveManagerAsync(Guid regionId, CancellationToken ct = default)
|
||||
{
|
||||
var region = await repo.GetByIdAsync(regionId);
|
||||
if (region is null || !region.IsActive) return false;
|
||||
var existing = await repo.GetManagerScopesAsync(regionId);
|
||||
if (existing.Count == 0) return false;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.RemoveRegionScopes(existing);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Backs both batch/lot tracking and expiry-only tracking for an item. BatchNumber is null
|
||||
/// when the item tracks an expiry date but not a batch number (TracksExpiryDate without
|
||||
/// TracksBatches).
|
||||
/// </summary>
|
||||
public class ItemBatch : Template
|
||||
{
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public string? BatchNumber { get; set; }
|
||||
public DateTime? ManufactureDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>One row per physical serial-tracked unit. Quantity is always 1.</summary>
|
||||
public class ItemSerialNumber : Template
|
||||
{
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public string SerialNumber { get; set; }
|
||||
public SerialStatus Status { get; set; } = SerialStatus.InStock;
|
||||
|
||||
public Guid? CurrentLocationId { get; set; }
|
||||
public LocationType? CurrentLocationType { get; set; }
|
||||
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>Snapshotted at receipt when Item.AllowCustomWarrantyPeriod is true; otherwise null and the system default applies when the warranty starts.</summary>
|
||||
public int? WarrantyPeriodDaysOverride { get; set; }
|
||||
|
||||
/// <summary>Set when the serial is sold/delivered: sale date + (override ?? system default). Null while in stock or if Item.HasWarranty is false.</summary>
|
||||
public DateTime? WarrantyExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public class Region : Template
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ namespace Distribution.Models
|
||||
public Guid LocationId { get; set; }
|
||||
public LocationType LocationType { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track batches/expiry — no behavior change for existing items.</summary>
|
||||
public Guid? FK_ItemBatchId { get; set; }
|
||||
[ForeignKey("FK_ItemBatchId")]
|
||||
public ItemBatch? ItemBatch { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track serial numbers.</summary>
|
||||
public Guid? FK_ItemSerialNumberId { get; set; }
|
||||
[ForeignKey("FK_ItemSerialNumberId")]
|
||||
public ItemSerialNumber? ItemSerialNumber { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,16 @@ namespace Distribution.Models
|
||||
public Guid? ToLocationId { get; set; }
|
||||
public LocationType? ToLocationType { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track batches/expiry — no behavior change for existing items.</summary>
|
||||
public Guid? FK_ItemBatchId { get; set; }
|
||||
[ForeignKey("FK_ItemBatchId")]
|
||||
public ItemBatch? ItemBatch { get; set; }
|
||||
|
||||
/// <summary>Null for items that don't track serial numbers. Quantity is always 1 when set.</summary>
|
||||
public Guid? FK_ItemSerialNumberId { get; set; }
|
||||
[ForeignKey("FK_ItemSerialNumberId")]
|
||||
public ItemSerialNumber? ItemSerialNumber { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public MovementType Type { get; set; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Package01Icon } from '@hugeicons/core-free-icons'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { FieldLabel, FieldDescription } from '@/components/ui/field'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
|
||||
interface FormState {
|
||||
serialNumberTracking: boolean
|
||||
batchTracking: boolean
|
||||
expiryDateTracking: boolean
|
||||
warrantyTracking: boolean
|
||||
customWarrantyOverride: boolean
|
||||
defaultWarrantyPeriodDays: number
|
||||
}
|
||||
|
||||
const DEFAULTS: FormState = {
|
||||
serialNumberTracking: false,
|
||||
batchTracking: false,
|
||||
expiryDateTracking: false,
|
||||
warrantyTracking: false,
|
||||
customWarrantyOverride: false,
|
||||
defaultWarrantyPeriodDays: 0,
|
||||
}
|
||||
|
||||
interface CapabilityToggleProps {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs leading-relaxed">{description}</FieldDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pt-0.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{checked ? 'ON' : 'OFF'}</span>
|
||||
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={(v) => onCheckedChange(!!v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The Products configuration card: tracking capability switches and warranty defaults, saved immediately. */
|
||||
export function ProductCapabilitiesCard() {
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [form, setForm] = useState<FormState>(DEFAULTS)
|
||||
const [pendingKeys, setPendingKeys] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
sysConfig.getAll()
|
||||
.then((configs) => {
|
||||
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
|
||||
setForm({
|
||||
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
|
||||
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
|
||||
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
|
||||
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
|
||||
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
|
||||
defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0),
|
||||
})
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' }))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) {
|
||||
const previous = form[formKey]
|
||||
setForm(f => ({ ...f, [formKey]: value }))
|
||||
setPendingKeys(p => ({ ...p, [configKey]: true }))
|
||||
try {
|
||||
await sysConfig.set(configKey, String(value), user.id)
|
||||
} catch (err) {
|
||||
setForm(f => ({ ...f, [formKey]: previous }))
|
||||
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setPendingKeys(p => ({ ...p, [configKey]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDefaultWarrantyDays(value: number) {
|
||||
const previous = form.defaultWarrantyPeriodDays
|
||||
setForm(f => ({ ...f, defaultWarrantyPeriodDays: value }))
|
||||
if (value === previous) return
|
||||
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true }))
|
||||
try {
|
||||
await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id)
|
||||
} catch (err) {
|
||||
setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous }))
|
||||
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<Card className="max-w-4xl rounded-3xl border-border/60">
|
||||
<CardHeader className="border-b border-border/60 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<HugeiconsIcon icon={Package01Icon} size={18} />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Products</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="mt-3">
|
||||
Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately;
|
||||
items then opt into the enabled capabilities from the item's edit form.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CapabilityToggle
|
||||
id="serialNumberTracking"
|
||||
label="Serial number tracking"
|
||||
description="Track individual units of an item by a unique serial number."
|
||||
checked={form.serialNumberTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
|
||||
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
|
||||
/>
|
||||
|
||||
<CapabilityToggle
|
||||
id="batchTracking"
|
||||
label="Batch / lot tracking"
|
||||
description="Group stock into batches or lots for traceability."
|
||||
checked={form.batchTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
|
||||
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
|
||||
/>
|
||||
|
||||
<CapabilityToggle
|
||||
id="expiryDateTracking"
|
||||
label="Expiry date tracking"
|
||||
description="Record and monitor expiry dates on perishable stock."
|
||||
checked={form.expiryDateTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
|
||||
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
|
||||
/>
|
||||
|
||||
<CapabilityToggle
|
||||
id="warrantyTracking"
|
||||
label="Warranty periods"
|
||||
description="Attach a warranty period to items at the time of sale."
|
||||
checked={form.warrantyTracking}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
|
||||
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.warrantyTracking && (
|
||||
<>
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Warranty defaults
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CapabilityToggle
|
||||
id="customWarrantyOverride"
|
||||
label="Per-item custom warranty"
|
||||
description="Allow individual items to override the default warranty period."
|
||||
checked={form.customWarrantyOverride}
|
||||
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
|
||||
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4">
|
||||
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
|
||||
Default warranty period
|
||||
</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="defaultWarrantyPeriodDays"
|
||||
type="number"
|
||||
min={0}
|
||||
value={String(form.defaultWarrantyPeriodDays)}
|
||||
onChange={(e) => setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))}
|
||||
onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)}
|
||||
className="max-w-24"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
<FieldDescription className="text-xs">
|
||||
Applied to items that don't set their own warranty period.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Configuration</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
|
||||
</div>
|
||||
|
||||
<ProductCapabilitiesCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { RegionResponse, RegionManagerResponse, UserResponse } from '@/types/api'
|
||||
|
||||
const MANAGER_ROLE = 'RegionalManager'
|
||||
|
||||
interface RegionManagerDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
region: RegionResponse | null
|
||||
}
|
||||
|
||||
export function RegionManagerDialog({ open, onClose, region }: RegionManagerDialogProps) {
|
||||
const [current, setCurrent] = useState<RegionManagerResponse | null>(null)
|
||||
const [candidates, setCandidates] = useState<UserResponse[]>([])
|
||||
const [selected, setSelected] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !region) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSelected('')
|
||||
setCurrent(null)
|
||||
Promise.all([
|
||||
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
|
||||
usersApi.list({ pageSize: 500 }),
|
||||
])
|
||||
.then(([manager, userRes]) => {
|
||||
if (cancelled) return
|
||||
setCurrent(manager)
|
||||
setSelected(manager?.userId ?? '')
|
||||
setCandidates(getPaginatedItems(userRes).filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
||||
})
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => { cancelled = true }
|
||||
}, [open, region])
|
||||
|
||||
async function save() {
|
||||
if (!region || !selected) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await regionsApi.setManager(region.id, selected)
|
||||
toast.add({ title: 'Region manager assigned', type: 'success' })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to assign manager', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!region) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await regionsApi.removeManager(region.id)
|
||||
toast.add({ title: 'Region manager removed', type: 'success' })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to remove manager', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && !saving && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Region Manager</DialogTitle>
|
||||
<DialogDescription>
|
||||
{region ? `${region.code} – ${region.name}. ` : ''}
|
||||
Each region has one manager, chosen from users with the RegionalManager role.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current manager:{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{loading ? 'Loading…' : current ? `${current.fullName} (${current.username})` : 'None'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<Select value={selected} onValueChange={(v) => setSelected(v ?? '')} disabled={loading || saving}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={candidates.length ? 'Select a manager' : 'No RegionalManager users available'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.fullName} ({u.username})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{current && (
|
||||
<Button type="button" variant="destructive" onClick={remove} disabled={saving}>
|
||||
Remove manager
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={save} disabled={saving || loading || !selected || selected === current?.userId}>
|
||||
{saving ? 'Saving…' : 'Assign manager'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useEffect } from 'react'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { 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>
|
||||
)
|
||||
}
|
||||
+105
-48
@@ -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>
|
||||
)
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
import type { TableConfig } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import {
|
||||
productReports,
|
||||
type ProductReportSummary,
|
||||
type ProductReturnRow,
|
||||
type ProductSalesRow,
|
||||
type ProductStockRow,
|
||||
} from '@/lib/api/reports'
|
||||
|
||||
type Tab = 'stock' | 'sales' | 'returns'
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'stock', label: 'Stock' },
|
||||
{ key: 'sales', label: 'Sales' },
|
||||
{ key: 'returns', label: 'Returns' },
|
||||
]
|
||||
|
||||
const num = (v: number | null | undefined) =>
|
||||
v == null ? '—' : v.toLocaleString('en-IN', { maximumFractionDigits: 2 })
|
||||
|
||||
const pct = (v: number | null | undefined) => (v == null ? '—' : `${v.toFixed(2)}%`)
|
||||
|
||||
const stockColumns: TableConfig<ProductStockRow>['columns'] = [
|
||||
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
|
||||
{ key: 'brand', title: 'Brand', fields: [{ key: 'brand', transform: v => (v as string | null) ?? '—' }], width: 140 },
|
||||
{ key: 'category', title: 'Category', fields: ['category'], width: 150 },
|
||||
{ key: 'uom', title: 'UOM', fields: ['uom'], width: 80 },
|
||||
{ key: 'warehouseQty', title: 'Warehouse', fields: [{ key: 'warehouseQty', transform: v => num(v as number) }], width: 110 },
|
||||
{ key: 'vehicleQty', title: 'Vehicles', fields: [{ key: 'vehicleQty', transform: v => num(v as number) }], width: 100 },
|
||||
{ key: 'totalQty', title: 'Total', fields: [{ key: 'totalQty', transform: v => num(v as number) }], width: 100 },
|
||||
{ key: 'stockValue', title: 'Stock Value', fields: [{ key: 'stockValue', transform: v => num(v as number) }], width: 120 },
|
||||
]
|
||||
|
||||
const salesColumns: TableConfig<ProductSalesRow>['columns'] = [
|
||||
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
|
||||
{ key: 'orders', title: 'Orders', fields: ['orders'], width: 90 },
|
||||
{ key: 'stores', title: 'Stores', fields: ['stores'], width: 90 },
|
||||
{ key: 'qtyOrdered', title: 'Qty Ordered', fields: [{ key: 'qtyOrdered', transform: v => num(v as number) }], width: 120 },
|
||||
{ key: 'qtyFulfilled', title: 'Qty Fulfilled', fields: [{ key: 'qtyFulfilled', transform: v => num(v as number) }], width: 120 },
|
||||
{ key: 'salesValue', title: 'Sales', fields: [{ key: 'salesValue', transform: v => num(v as number) }], width: 120 },
|
||||
{ key: 'margin', title: 'Margin', fields: [{ key: 'margin', transform: v => num(v as number | null) }], width: 110 },
|
||||
{ key: 'marginPercent', title: 'Margin %', fields: [{ key: 'marginPercent', transform: v => pct(v as number | null) }], width: 100 },
|
||||
]
|
||||
|
||||
const returnsColumns: TableConfig<ProductReturnRow>['columns'] = [
|
||||
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
|
||||
{ key: 'returnedQty', title: 'Returned Qty', fields: [{ key: 'returnedQty', transform: v => num(v as number) }], width: 120 },
|
||||
{ key: 'returnedValue', title: 'Returned Value', fields: [{ key: 'returnedValue', transform: v => num(v as number) }], width: 130 },
|
||||
{ key: 'soldQty', title: 'Sold Qty', fields: [{ key: 'soldQty', transform: v => num(v as number) }], width: 100 },
|
||||
{ key: 'returnPercent', title: 'Return %', fields: [{ key: 'returnPercent', transform: v => pct(v as number | null) }], width: 100 },
|
||||
{
|
||||
key: 'qtyByReason',
|
||||
title: 'By Reason',
|
||||
fields: ['qtyByReason'],
|
||||
minWidth: 220,
|
||||
cellRender: row => (
|
||||
<span className="text-sm">
|
||||
{Object.entries(row.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', ')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function ProductReportClient() {
|
||||
const [tab, setTab] = useState<Tab>('stock')
|
||||
const [search, setSearch] = useState('')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [lowStockOnly, setLowStockOnly] = useState(false)
|
||||
|
||||
const [stockData, setStockData] = useState<ProductStockRow[]>([])
|
||||
const [salesData, setSalesData] = useState<ProductSalesRow[]>([])
|
||||
const [returnsData, setReturnsData] = useState<ProductReturnRow[]>([])
|
||||
const [summary, setSummary] = useState<ProductReportSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const base = { page, pageSize, search: search || undefined, from: from || undefined, to: to || undefined }
|
||||
try {
|
||||
if (tab === 'stock') {
|
||||
const res = await productReports.stock({ ...base, lowStockOnly })
|
||||
setStockData(getPaginatedItems(res))
|
||||
setTotal(res.total)
|
||||
} else if (tab === 'sales') {
|
||||
const res = await productReports.sales(base)
|
||||
setSalesData(getPaginatedItems(res))
|
||||
setTotal(res.total)
|
||||
} else {
|
||||
const res = await productReports.returns(base)
|
||||
setReturnsData(getPaginatedItems(res))
|
||||
setTotal(res.total)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load product report', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tab, page, pageSize, search, from, to, lowStockOnly])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
productReports
|
||||
.summary({ from: from || undefined, to: to || undefined })
|
||||
.then(setSummary)
|
||||
.catch(err => toast.add({ title: 'Failed to load summary', description: getApiError(err), type: 'error' }))
|
||||
}, [from, to])
|
||||
|
||||
function changeTab(next: Tab) {
|
||||
setTab(next)
|
||||
setPage(1)
|
||||
setTotal(0)
|
||||
}
|
||||
|
||||
const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
|
||||
|
||||
const stockConfig = useMemo<TableConfig<ProductStockRow>>(
|
||||
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: stockColumns }),
|
||||
[pageSize],
|
||||
)
|
||||
const salesConfig = useMemo<TableConfig<ProductSalesRow>>(
|
||||
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No finalized sales in this period.', columns: salesColumns }),
|
||||
[pageSize],
|
||||
)
|
||||
const returnsConfig = useMemo<TableConfig<ProductReturnRow>>(
|
||||
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No returns in this period.', columns: returnsColumns }),
|
||||
[pageSize],
|
||||
)
|
||||
|
||||
const onServerSideChange = ({ page: p }: { page: { page: number; pageSize: number } }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Product Report</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Stock, sales and returns per product. Sales count fulfilled quantity on finalized invoices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
['Products', num(summary.itemCount)],
|
||||
['Total stock qty', num(summary.totalQty)],
|
||||
['Stock value', num(summary.totalValue)],
|
||||
['Sales (period)', num(summary.totalSales)],
|
||||
].map(([label, value]) => (
|
||||
<Card key={label} className="rounded-3xl border-border/60">
|
||||
<CardContent className="space-y-1">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-2xl">{value}</CardTitle>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{TABS.map(t => (
|
||||
<Button key={t.key} size="sm" variant={tab === t.key ? 'default' : 'outline'} onClick={() => changeTab(t.key)}>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
<Input
|
||||
className="w-56"
|
||||
placeholder="Search name or SKU"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
/>
|
||||
{tab !== 'stock' ? (
|
||||
<>
|
||||
<Input className="w-40" type="date" value={from} onChange={e => { setFrom(e.target.value); setPage(1) }} />
|
||||
<Input className="w-40" type="date" value={to} onChange={e => { setTo(e.target.value); setPage(1) }} />
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant={lowStockOnly ? 'default' : 'outline'} onClick={() => { setLowStockOnly(v => !v); setPage(1) }}>
|
||||
Low stock only
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'stock' && (
|
||||
<DataTable config={stockConfig} data={stockData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
|
||||
)}
|
||||
{tab === 'sales' && (
|
||||
<DataTable config={salesConfig} data={salesData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
|
||||
)}
|
||||
{tab === 'returns' && (
|
||||
<DataTable config={returnsConfig} data={returnsData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProductReportClient } from './_components/product-report-client'
|
||||
|
||||
export default function ProductReportPage() {
|
||||
return <ProductReportClient />
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} 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>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm ring-1 ring-foreground/5 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("font-heading text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-2xl border-2 transition-all outline-none group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-8 data-[size=sm]:h-4 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary group-has-[:focus-visible]/field-label:data-checked:border-primary data-unchecked:border-transparent data-unchecked:bg-input/90 group-has-[:focus-visible]/field-label:data-unchecked:border-transparent data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-2xl bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-checked:translate-x-[calc(100%-4px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ClipboardListIcon,
|
||||
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' },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { PaginatedResponse } from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
|
||||
export interface ProductStockRow {
|
||||
itemId: string
|
||||
sku: string
|
||||
name: string
|
||||
brand: string | null
|
||||
category: string
|
||||
subCategory: string | null
|
||||
uom: string
|
||||
salePrice: number | null
|
||||
costPrice: number | null
|
||||
reorderLevel: number | null
|
||||
warehouseQty: number
|
||||
vehicleQty: number
|
||||
totalQty: number
|
||||
stockValue: number
|
||||
isLowStock: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ProductSalesRow {
|
||||
itemId: string
|
||||
sku: string
|
||||
name: string
|
||||
orders: number
|
||||
stores: number
|
||||
qtyOrdered: number
|
||||
qtyFulfilled: number
|
||||
salesValue: number
|
||||
costValue: number | null
|
||||
margin: number | null
|
||||
marginPercent: number | null
|
||||
}
|
||||
|
||||
export interface ProductReturnRow {
|
||||
itemId: string
|
||||
sku: string
|
||||
name: string
|
||||
returnedQty: number
|
||||
returnedValue: number
|
||||
soldQty: number
|
||||
returnPercent: number | null
|
||||
qtyByReason: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ProductReportSummary {
|
||||
itemCount: number
|
||||
totalQty: number
|
||||
totalValue: number
|
||||
totalSales: number
|
||||
}
|
||||
|
||||
export interface ProductReportParams extends PageParams {
|
||||
brandId?: string
|
||||
categoryId?: string
|
||||
subCategoryId?: string
|
||||
warehouseId?: string
|
||||
regionId?: string
|
||||
routeId?: string
|
||||
storeId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface ProductStockParams extends ProductReportParams {
|
||||
activeOnly?: boolean
|
||||
lowStockOnly?: boolean
|
||||
}
|
||||
|
||||
export const productReports = {
|
||||
stock: (params?: ProductStockParams) =>
|
||||
api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data),
|
||||
|
||||
sales: (params?: ProductReportParams) =>
|
||||
api.get<PaginatedResponse<ProductSalesRow>>('/reports/products/sales', { params }).then(r => r.data),
|
||||
|
||||
returns: (params?: ProductReportParams) =>
|
||||
api.get<PaginatedResponse<ProductReturnRow>>('/reports/products/returns', { params }).then(r => r.data),
|
||||
|
||||
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
|
||||
api.get<ProductReportSummary>('/reports/products/summary', { params }).then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { SysConfigResponse } from '@/types/api'
|
||||
|
||||
// ─── Item tracking feature keys (must match Distribution.Features.SysConfig.SysConfigService) ──
|
||||
|
||||
export const ITEM_TRACKING_KEYS = {
|
||||
serialNumberTracking: 'Feature.SerialNumberTracking',
|
||||
batchTracking: 'Feature.BatchTracking',
|
||||
expiryDateTracking: 'Feature.ExpiryDateTracking',
|
||||
warrantyTracking: 'Feature.WarrantyTracking',
|
||||
customWarrantyOverride: 'Feature.CustomWarrantyOverride',
|
||||
defaultWarrantyPeriodDays: 'Feature.DefaultWarrantyPeriodDays',
|
||||
} as const
|
||||
|
||||
export const sysConfig = {
|
||||
getAll: () =>
|
||||
api.get<SysConfigResponse[]>('/sys-config').then(r => r.data),
|
||||
|
||||
get: (key: string) =>
|
||||
api.get<SysConfigResponse>(`/sys-config/${key}`).then(r => r.data),
|
||||
|
||||
set: (key: string, value: string, userId?: string | null) =>
|
||||
api.put<SysConfigResponse>(`/sys-config/${key}`, { value, userId }).then(r => r.data),
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export interface PermissionResponse {
|
||||
|
||||
export interface RegionResponse {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user