diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index c65d2e3..44b39fb 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet Uoms { get; set; } public DbSet UomConversions { get; set; } public DbSet Items { get; set; } + public DbSet ItemBatches { get; set; } + public DbSet ItemSerialNumbers { get; set; } public DbSet SaleRoutes { get; set; } public DbSet ShopPOs { get; set; } public DbSet ShopPOItems { get; set; } @@ -47,6 +49,8 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet PurchaseOrderItems { get; set; } public DbSet GoodsReceipts { get; set; } public DbSet GoodsReceiptItems { get; set; } + public DbSet GoodsReceiptItemBatches { get; set; } + public DbSet GoodsReceiptItemSerials { get; set; } public DbSet PurchaseReturns { get; set; } public DbSet PurchaseReturnItems { get; set; } public DbSet SaleReturns { get; set; } @@ -83,8 +87,15 @@ public class AppDBContext(DbContextOptions 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() - .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() + .HasIndex(s => new { s.FK_ItemId, s.SerialNumber }) .IsUnique(); modelBuilder.Entity() @@ -316,6 +327,79 @@ public class AppDBContext(DbContextOptions options) : DbContext(op modelBuilder.Entity() .HasIndex(i => i.FK_PurchaseOrderItemId); + // --- ItemBatch / ItemSerialNumber --- + + modelBuilder.Entity() + .HasOne(b => b.Item) + .WithMany() + .HasForeignKey(b => b.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(b => new { b.FK_ItemId, b.BatchNumber }); + + modelBuilder.Entity() + .HasOne(s => s.Item) + .WithMany() + .HasForeignKey(s => s.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + // --- StockBalance / StockMovement batch & serial links --- + + modelBuilder.Entity() + .HasOne(sb => sb.ItemBatch) + .WithMany() + .HasForeignKey(sb => sb.FK_ItemBatchId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(sb => sb.ItemSerialNumber) + .WithMany() + .HasForeignKey(sb => sb.FK_ItemSerialNumberId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(sm => sm.ItemBatch) + .WithMany() + .HasForeignKey(sm => sm.FK_ItemBatchId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(sm => sm.ItemSerialNumber) + .WithMany() + .HasForeignKey(sm => sm.FK_ItemSerialNumberId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // --- GoodsReceiptItemBatch / GoodsReceiptItemSerial --- + + modelBuilder.Entity() + .HasOne(b => b.GoodsReceiptItem) + .WithMany(i => i.Batches) + .HasForeignKey(b => b.FK_GoodsReceiptItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(b => b.ItemBatch) + .WithMany() + .HasForeignKey(b => b.FK_ItemBatchId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.GoodsReceiptItem) + .WithMany(i => i.Serials) + .HasForeignKey(s => s.FK_GoodsReceiptItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.ItemSerialNumber) + .WithMany() + .HasForeignKey(s => s.FK_ItemSerialNumberId) + .OnDelete(DeleteBehavior.Restrict); + // --- PurchaseReturn --- modelBuilder.Entity() @@ -478,7 +562,13 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasMaxLength(100); modelBuilder.Entity().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); diff --git a/Distribution/Features/Item/ItemDTOs.cs b/Distribution/Features/Item/ItemDTOs.cs index 977980c..d973780 100644 --- a/Distribution/Features/Item/ItemDTOs.cs +++ b/Distribution/Features/Item/ItemDTOs.cs @@ -12,7 +12,13 @@ public record CreateItemRequest( Guid BaseUomId, decimal? SalePrice, List? 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 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, diff --git a/Distribution/Features/Item/ItemService.cs b/Distribution/Features/Item/ItemService.cs index 82b76a0..a796fda 100644 --- a/Distribution/Features/Item/ItemService.cs +++ b/Distribution/Features/Item/ItemService.cs @@ -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()); + /// + /// Rejects per-item tracking flags that are off system-wide, and warranty-period values that + /// don't make sense given HasWarranty/AllowCustomWarrantyPeriod. + /// + 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> 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); diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 2fbc6bf..f23dfe2 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -75,6 +75,7 @@ builder.Services.AddScoped( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -109,6 +110,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped();