diff --git a/Backend/ERPCore/Controllers/BrandsController.cs b/Backend/ERPCore/Controllers/BrandsController.cs new file mode 100644 index 0000000..7d4c5b3 --- /dev/null +++ b/Backend/ERPCore/Controllers/BrandsController.cs @@ -0,0 +1,67 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6). +[Route("api/v1/brands")] +public sealed class BrandsController : ApiControllerBase +{ + private readonly IBrandService _brands; + + public BrandsController(IBrandService brands) => _brands = brands; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _brands.ListAsync(query, status, ct)); + + [HttpGet("{brandId:int}")] + [ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int brandId, CancellationToken ct) + { + var result = await _brands.GetAsync(brandId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateBrandRequest request, CancellationToken ct) + { + var result = await _brands.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value); + } + + [HttpPut("{brandId:int}")] + [ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _brands.UpdateAsync(brandId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{brandId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct) + { + await _brands.SetStatusAsync(brandId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/CategoriesController.cs b/Backend/ERPCore/Controllers/CategoriesController.cs index dab7c60..3fcd0e2 100644 --- a/Backend/ERPCore/Controllers/CategoriesController.cs +++ b/Backend/ERPCore/Controllers/CategoriesController.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; using ERPCore.Services.Interfaces; @@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc; namespace ERPCore.Controllers; -/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3). +/// +/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories +/// nested beneath each category. The hierarchy is exactly two levels deep — the old +/// ?tree=true parameter is gone along with the self-nesting model. +/// [Route("api/v1/categories")] public sealed class CategoriesController : ApiControllerBase { @@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase public CategoriesController(ICategoryService categories) => _categories = categories; - /// Flat paged list, or a nested tree when tree=true. [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct) - => tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct)); + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _categories.ListAsync(query, status, ct)); + + [HttpGet("{categoryId:int}")] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int categoryId, CancellationToken ct) + { + var result = await _categories.GetAsync(categoryId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } [HttpPost] [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task> Create([FromBody] CreateCategoryRequest request, CancellationToken ct) { - var dto = await _categories.CreateAsync(request, ct); - return Created($"/api/v1/categories/{dto.CategoryId}", dto); + var result = await _categories.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value); + } + + [HttpPut("{categoryId:int}")] + [ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update( + int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _categories.UpdateAsync(categoryId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{categoryId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus( + int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct) + { + await _categories.SetStatusAsync(categoryId, request.Status, ct); + return NoContent(); + } + + // Subcategories — nested under their parent category (docs/11 §2.3). + // Updates live on SubCategoriesController at /api/v1/subcategories/{id}. + + [HttpGet("{categoryId:int}/subcategories")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> ListSubCategories( + int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct)); + + [HttpPost("{categoryId:int}/subcategories")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> CreateSubCategory( + int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct) + { + var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value); } } diff --git a/Backend/ERPCore/Controllers/ItemTypesController.cs b/Backend/ERPCore/Controllers/ItemTypesController.cs new file mode 100644 index 0000000..efbc4f7 --- /dev/null +++ b/Backend/ERPCore/Controllers/ItemTypesController.cs @@ -0,0 +1,73 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material +/// dimension names. GET is the reason this master exists: it populates the item +/// builder's dropdown. Items never reference an item type; the chosen values are encoded +/// into the client-generated SKU (docs/10 Part C.9). +/// +[Route("api/v1/item-types")] +public sealed class ItemTypesController : ApiControllerBase +{ + private readonly IItemTypeService _itemTypes; + + public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes; + + /// Feeds the frontend item-builder dropdown; filter status=Active for selectable rows. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _itemTypes.ListAsync(query, status, ct)); + + [HttpGet("{itemTypeId:int}")] + [ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int itemTypeId, CancellationToken ct) + { + var result = await _itemTypes.GetAsync(itemTypeId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct) + { + var result = await _itemTypes.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value); + } + + [HttpPut("{itemTypeId:int}")] + [ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{itemTypeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct) + { + await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs index c98493a..fe4ed11 100644 --- a/Backend/ERPCore/Controllers/ItemsController.cs +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -21,9 +21,11 @@ public sealed class ItemsController : ApiControllerBase [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] int? categoryId, + [FromQuery] int? subCategoryId, + [FromQuery] int? brandId, [FromQuery] TrackingMode? trackingMode, CancellationToken ct) - => Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct)); + => Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct)); /// Get a single item; returns an ETag for optimistic concurrency. [HttpGet("{itemId:int}")] diff --git a/Backend/ERPCore/Controllers/ProductConfigController.cs b/Backend/ERPCore/Controllers/ProductConfigController.cs new file mode 100644 index 0000000..300f446 --- /dev/null +++ b/Backend/ERPCore/Controllers/ProductConfigController.cs @@ -0,0 +1,46 @@ +using ERPCore.Dtos.Config; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton +/// feature gate for subcategories/brands/item-types. +/// +/// Authorization: writes are admitted by the inherited ERP door policy only. +/// A dedicated CONFIG_MANAGE permission is reserved for when per-endpoint RBAC +/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute +/// with no other change. Until then any ERP-admitted user can flip these flags; that is +/// the accepted Phase-1 posture, consistent with every other endpoint. +/// +/// +[Route("api/v1/product-config")] +public sealed class ProductConfigController : ApiControllerBase +{ + private readonly IProductConfigService _config; + + public ProductConfigController(IProductConfigService config) => _config = config; + + [HttpGet] + [ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)] + public async Task> Get(CancellationToken ct) + { + var result = await _config.GetAsync(ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPut] + [ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update( + [FromBody] UpdateProductConfigRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _config.UpdateAsync(request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } +} diff --git a/Backend/ERPCore/Controllers/SubCategoriesController.cs b/Backend/ERPCore/Controllers/SubCategoriesController.cs new file mode 100644 index 0000000..162a89e --- /dev/null +++ b/Backend/ERPCore/Controllers/SubCategoriesController.cs @@ -0,0 +1,56 @@ +using ERPCore.Dtos.Categories; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3). +/// Listing and creation live under the parent category on , +/// since a subcategory only exists in the context of one. +/// +[Route("api/v1/subcategories")] +public sealed class SubCategoriesController : ApiControllerBase +{ + private readonly ICategoryService _categories; + + public SubCategoriesController(ICategoryService categories) => _categories = categories; + + [HttpGet("{subCategoryId:int}")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int subCategoryId, CancellationToken ct) + { + var result = await _categories.GetSubCategoryAsync(subCategoryId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Renames a subcategory. It cannot be moved to another category — see the request DTO. + [HttpPut("{subCategoryId:int}")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update( + int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{subCategoryId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus( + int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct) + { + await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Domain/Entities/Brand.cs b/Backend/ERPCore/Domain/Entities/Brand.cs new file mode 100644 index 0000000..1544349 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Brand.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Brand master (FR-MD-09). Referenced optionally by . +/// Mutable aggregate with a ETag token. Deactivated, not +/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class Brand +{ + public int BrandId { get; set; } + public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs index 6c34b87..7d04a6d 100644 --- a/Backend/ERPCore/Domain/Entities/Category.cs +++ b/Backend/ERPCore/Domain/Entities/Category.cs @@ -1,15 +1,25 @@ +using ERPCore.Domain.Enums; + namespace ERPCore.Domain.Entities; /// -/// Hierarchical item category (FR-MD-04). A null denotes a -/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level +/// below is ; categories no longer self-nest (the former +/// parent_id tree was replaced in migration #2). +/// Mutable aggregate with a ETag token. Deactivated, not +/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1. /// public class Category { public int CategoryId { get; set; } public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; - public int? ParentId { get; set; } - public Category? Parent { get; set; } - public ICollection Children { get; set; } = new List(); + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } + + public ICollection SubCategories { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index f308dd3..ba9ed6c 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -17,13 +17,20 @@ public class Item public int CategoryId { get; set; } public Category? Category { get; set; } + /// Optional second level below ; must belong to it. + public int? SubCategoryId { get; set; } + public SubCategory? SubCategory { get; set; } + + public int? BrandId { get; set; } + public Brand? Brand { get; set; } + public int BaseUomId { get; set; } public Uom? BaseUom { get; set; } public int? DefaultVendorId { get; set; } public Vendor? DefaultVendor { get; set; } - public ItemType ItemType { get; set; } + public StockNature StockNature { get; set; } public TrackingMode TrackingMode { get; set; } public string? TaxClass { get; set; } public EntityStatus Status { get; set; } = EntityStatus.Active; diff --git a/Backend/ERPCore/Domain/Entities/ItemType.cs b/Backend/ERPCore/Domain/Entities/ItemType.cs new file mode 100644 index 0000000..668b1b7 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ItemType.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or +/// Material. +/// +/// Deliberately unlinked. Nothing references this entity and it references +/// nothing: there is no value table and no join to . Its only job is +/// to feed the frontend's item-builder dropdown via GET /item-types. The chosen +/// values (Red, S, M) are encoded by the client into the generated SKU +/// (e.g. BL-100-0003) and are never stored or parsed server-side — the item list +/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9. +/// +/// Not to be confused with (Stocked/NonStocked/Service), +/// which is what the old ItemType enum became. +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class ItemType +{ + public int ItemTypeId { get; set; } + public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs new file mode 100644 index 0000000..4120cdc --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Product configuration (FR-MD-11) — a singleton row (single-tenant, docs/00-CORE §1) +/// gating optional product master-data features. +/// +/// and are enforced +/// server-side: an Item write carrying a subcategory/brand while the flag is off is +/// rejected with CONFIG_DISABLED. is +/// advisory only — items carry no item-type reference (see ), +/// so there is nothing on a write to reject; the frontend honours it by hiding the +/// builder's type section. Reads are never gated, so existing data stays visible after a +/// flag is switched off. +/// +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class ProductConfig +{ + /// Always 1 — the singleton row's id. + public const int SingletonId = 1; + + public int ConfigId { get; set; } + + public bool SubcategoriesEnabled { get; set; } = true; + public bool BrandsEnabled { get; set; } = true; + public bool ItemTypesEnabled { get; set; } = true; + + public DateTime? UpdatedAt { get; set; } + + public int? UpdatedBy { get; set; } + public User? UpdatedByUser { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SubCategory.cs b/Backend/ERPCore/Domain/Entities/SubCategory.cs new file mode 100644 index 0000000..a270f41 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SubCategory.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Subcategory — the single optional level below (FR-MD-04). +/// Replaces the former self-referencing CATEGORY.parent_id tree: the hierarchy is +/// exactly two levels deep and cannot nest further. Referenced optionally by +/// . Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class SubCategory +{ + public int SubCategoryId { get; set; } + public string Name { get; set; } = string.Empty; + + public int CategoryId { get; set; } + public Category? Category { get; set; } + + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/ItemType.cs b/Backend/ERPCore/Domain/Enums/ItemType.cs deleted file mode 100644 index c81a1d6..0000000 --- a/Backend/ERPCore/Domain/Enums/ItemType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ERPCore.Domain.Enums; - -/// -/// Item classification (FR-MD-01). Values match the itemType enum in -/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. -/// -public enum ItemType -{ - Stocked, - NonStocked, - Service -} diff --git a/Backend/ERPCore/Domain/Enums/StockNature.cs b/Backend/ERPCore/Domain/Enums/StockNature.cs new file mode 100644 index 0000000..7e75517 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/StockNature.cs @@ -0,0 +1,14 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Whether an item holds stock (FR-MD-01). Values match the stockNature enum in +/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. +/// Renamed from ItemType so that name could be taken by the ItemType master +/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9). +/// +public enum StockNature +{ + Stocked, + NonStocked, + Service +} diff --git a/Backend/ERPCore/Dtos/Brands/BrandDtos.cs b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs new file mode 100644 index 0000000..b5f6c80 --- /dev/null +++ b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Brands; + +/// Brand resource (docs/11-BACKEND-PHASE1.md §2.6). +public sealed record BrandDto( + int BrandId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +// Request DTOs — narrow: server-controlled fields (status, ids, timestamps) +// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- + +public sealed class CreateBrandRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateBrandRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateBrandStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs index 744026e..ed27120 100644 --- a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs +++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs @@ -1,15 +1,56 @@ using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Categories; -/// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3). -public sealed record CategoryDto(int CategoryId, string Name, int? ParentId); +// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------ +// The hierarchy is exactly two levels: Category → SubCategory. The former +// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in +// migration #2 — see docs/10 Part C.1. -/// Nested category node for GET /categories?tree=true. -public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList Children); +/// Category resource — the top level. +public sealed record CategoryDto( + int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +/// Subcategory resource — the single optional level below a category. +public sealed record SubCategoryDto( + int SubCategoryId, int CategoryId, string Name, EntityStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt); + +// Request DTOs — narrow: server-controlled fields (status, ids, timestamps) +// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- public sealed class CreateCategoryRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; - public int? ParentId { get; set; } +} + +public sealed class UpdateCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateCategoryStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} + +/// Body for POST /categories/{categoryId}/subcategories; the parent comes from the route. +public sealed class CreateSubCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +/// +/// Body for PUT /subcategories/{id}. Name only — a subcategory cannot be reparented, +/// since moving one would silently invalidate the category of every item referencing it. +/// +public sealed class UpdateSubCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateSubCategoryStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } } diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs new file mode 100644 index 0000000..f4e2e71 --- /dev/null +++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Config; + +/// +/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton. +/// is advisory (frontend-honoured) — see the entity docs. +/// +public sealed record ProductConfigDto( + bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled, + DateTime? UpdatedAt, int? UpdatedBy); + +/// +/// Full replacement of the flags. UpdatedBy is derived from the token, never posted. +/// +/// The flags are ? deliberately: [Required] on a non-nullable bool +/// is a no-op (it always has a value), so a body of {} would bind every flag to +/// false and silently switch all three features off. Nullable makes the requirement +/// actually bind — an omitted flag is a 400, not an accidental disable. +/// +/// +public sealed class UpdateProductConfigRequest +{ + [Required] public bool? SubcategoriesEnabled { get; set; } + [Required] public bool? BrandsEnabled { get; set; } + [Required] public bool? ItemTypesEnabled { get; set; } +} diff --git a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs new file mode 100644 index 0000000..35d2e92 --- /dev/null +++ b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.ItemTypes; + +/// +/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color +/// or Size. Carries no values and no item linkage: GET /item-types exists to +/// populate the frontend builder's dropdown, and the chosen values are encoded into the +/// client-generated SKU rather than stored (docs/10 Part C.9). +/// +public sealed record ItemTypeDto( + int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +// Request DTOs — narrow: server-controlled fields (status, ids, timestamps) +// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- + +public sealed class CreateItemTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateItemTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateItemTypeStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index 7bb11e7..7720597 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -7,8 +7,8 @@ namespace ERPCore.Dtos.Items; /// Row shape for GET /items. public sealed record ItemListItemDto( - int ItemId, string Sku, string Name, int CategoryId, int BaseUomId, - int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId, + int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status); /// A single per-warehouse reorder policy row. @@ -17,7 +17,8 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim /// Full item resource for GET /items/{id} and create/update responses. public sealed record ItemDetailDto( int ItemId, string Sku, string Name, string? Description, int CategoryId, - int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, + StockNature StockNature, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status, IReadOnlyList Reorder, DateTime CreatedAt, DateTime? UpdatedAt); @@ -33,15 +34,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList Settin // Request DTOs — narrow: server-controlled fields (status, ids, timestamps) // are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ---- +// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g. +// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here +// by design — items carry no item-type reference (docs/10 Part C.9). + public sealed class CreateItemRequest { [Required, StringLength(50)] public string Sku { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(1000)] public string? Description { get; set; } [Required] public int CategoryId { get; set; } + /// Optional; must belong to . Rejected when subcategories are disabled. + public int? SubCategoryId { get; set; } + /// Optional. Rejected when brands are disabled. + public int? BrandId { get; set; } [Required] public int BaseUomId { get; set; } public int? DefaultVendorId { get; set; } - [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } + [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } } @@ -52,9 +61,13 @@ public sealed class UpdateItemRequest [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(1000)] public string? Description { get; set; } [Required] public int CategoryId { get; set; } + /// Optional; must belong to . Rejected when subcategories are disabled. + public int? SubCategoryId { get; set; } + /// Optional. Rejected when brands are disabled. + public int? BrandId { get; set; } [Required] public int BaseUomId { get; set; } public int? DefaultVendorId { get; set; } - [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } + [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs new file mode 100644 index 0000000..52724a8 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BrandConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("brands"); + builder.HasKey(b => b.BrandId); + + builder.Property(b => b.Name).IsRequired().HasMaxLength(200); + builder.HasIndex(b => b.Name).IsUnique(); + + builder.Property(b => b.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(b => b.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => b.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs index a0565cb..8cc8732 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs @@ -1,4 +1,5 @@ using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration builder.HasKey(c => c.CategoryId); builder.Property(c => c.Name).IsRequired().HasMaxLength(200); + builder.HasIndex(c => c.Name).IsUnique(); - builder.HasOne(c => c.Parent) - .WithMany(c => c.Children) - .HasForeignKey(c => c.ParentId) - .OnDelete(DeleteBehavior.Restrict); + builder.Property(c => c.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); - builder.HasIndex(c => c.ParentId); + builder.Property(c => c.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasIndex(c => c.Status); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index 4a74f03..dab0f1f 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration builder.Property(i => i.Description).HasMaxLength(1000); builder.Property(i => i.TaxClass).HasMaxLength(20); - builder.Property(i => i.ItemType) + builder.Property(i => i.StockNature) .HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) .HasConversion().HasMaxLength(20).IsRequired(); @@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration .HasForeignKey(i => i.CategoryId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.SubCategory) + .WithMany() + .HasForeignKey(i => i.SubCategoryId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(i => i.Brand) + .WithMany() + .HasForeignKey(i => i.BrandId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.BaseUom) .WithMany() .HasForeignKey(i => i.BaseUomId) @@ -49,5 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration builder.HasIndex(i => i.Status); builder.HasIndex(i => i.CategoryId); + builder.HasIndex(i => i.BrandId); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs new file mode 100644 index 0000000..c26f97c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs @@ -0,0 +1,33 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no +/// relationships here — nothing references this table (docs/10 Part C.9). +/// +public sealed class ItemTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("item_types"); + builder.HasKey(t => t.ItemTypeId); + + builder.Property(t => t.Name).IsRequired().HasMaxLength(200); + builder.HasIndex(t => t.Name).IsUnique(); + + builder.Property(t => t.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(t => t.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasIndex(t => t.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs new file mode 100644 index 0000000..d916d09 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs @@ -0,0 +1,37 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// Configures the singleton product-configuration row (FR-MD-11). The check constraint +/// is what makes "singleton" a database guarantee rather than a convention. +/// +public sealed class ProductConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // The column is created as quoted PascalCase ("ConfigId"), so the constraint must + // quote it too — an unquoted config_id would fold to a column that does not exist. + builder.ToTable("product_config", t => + t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}")); + + builder.HasKey(c => c.ConfigId); + + // The id is fixed, never generated — there is exactly one row, seeded by DataSeeder. + builder.Property(c => c.ConfigId).ValueGeneratedNever(); + + builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true); + builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true); + builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasOne(c => c.UpdatedByUser) + .WithMany() + .HasForeignKey(c => c.UpdatedBy) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs new file mode 100644 index 0000000..9fadd72 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class SubCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subcategories"); + builder.HasKey(s => s.SubCategoryId); + + builder.Property(s => s.Name).IsRequired().HasMaxLength(200); + + builder.Property(s => s.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(s => s.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasOne(s => s.Category) + .WithMany(c => c.SubCategories) + .HasForeignKey(s => s.CategoryId) + .OnDelete(DeleteBehavior.Restrict); + + // Names need only be unique within their parent category. + builder.HasIndex(s => new { s.CategoryId, s.Name }).IsUnique(); + builder.HasIndex(s => s.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 59a8803..79a2352 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence; /// public static class DataSeeder { + /// + /// Item type names the frontend builder has always assumed exist (they were hardcoded + /// while it ran on mock data). Seeded so the dropdown is not empty on a fresh database; + /// users add their own (e.g. Material) from the admin screen. + /// + private static readonly string[] StandardItemTypes = ["Color", "Size"]; + private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes = [ ("DMG", "Damage", ReasonContext.Adjustment), @@ -26,6 +33,15 @@ public static class DataSeeder ]; public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) + { + var dirty = await SeedReasonCodesAsync(db, ct); + dirty |= await SeedItemTypesAsync(db, ct); + dirty |= await SeedProductConfigAsync(db, ct); + + if (dirty) await db.SaveChangesAsync(ct); + } + + private static async Task SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct) { var existing = await db.ReasonCodes .Select(r => new { r.Context, r.Code }) @@ -37,9 +53,44 @@ public static class DataSeeder .Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context }) .ToList(); - if (toAdd.Count == 0) return; + if (toAdd.Count == 0) return false; db.ReasonCodes.AddRange(toAdd); - await db.SaveChangesAsync(ct); + return true; + } + + private static async Task SeedItemTypesAsync(ErpDbContext db, CancellationToken ct) + { + var have = await db.ItemTypes.Select(t => t.Name).ToListAsync(ct); + + var toAdd = StandardItemTypes + .Where(name => !have.Contains(name, StringComparer.OrdinalIgnoreCase)) + .Select(name => new ItemType { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }) + .ToList(); + + if (toAdd.Count == 0) return false; + + db.ItemTypes.AddRange(toAdd); + return true; + } + + /// + /// Ensures the singleton product-config row exists (FR-MD-11). Migration #2 inserts it, + /// so this only fires for a database built some other way — but without it every Item + /// write would 404 on the missing config, so it is worth the one query at startup. + /// New deployments start with all features on. + /// + private static async Task SeedProductConfigAsync(ErpDbContext db, CancellationToken ct) + { + if (await db.ProductConfig.AnyAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)) return false; + + db.ProductConfig.Add(new ProductConfig + { + ConfigId = ProductConfig.SingletonId, + SubcategoriesEnabled = true, + BrandsEnabled = true, + ItemTypesEnabled = true + }); + return true; } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 6a7d7e6..7305983 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace ERPCore.Infra.Persistence; /// -/// EF Core context for the ERP database. The 38 Phase 1 entities and their +/// EF Core context for the ERP database. The 42 Phase 1 entities and their /// configurations are added under /// Domain/Entities and Infra/Persistence/Configurations as they are implemented. /// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here. @@ -23,6 +23,10 @@ public class ErpDbContext : DbContext // --- Master Data (docs/10 Part C.1) --- public DbSet Categories => Set(); + public DbSet SubCategories => Set(); + public DbSet Brands => Set(); + /// Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9). + public DbSet ItemTypes => Set(); public DbSet Uoms => Set(); public DbSet UomConversions => Set(); public DbSet Items => Set(); @@ -30,6 +34,8 @@ public class ErpDbContext : DbContext public DbSet Vendors => Set(); public DbSet Warehouses => Set(); public DbSet Bins => Set(); + /// Singleton row (FR-MD-11). + public DbSet ProductConfig => Set(); // --- Cross-cutting (docs/10 Part C.7) --- public DbSet Users => Set(); diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs new file mode 100644 index 0000000..829393d --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs @@ -0,0 +1,2454 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig")] + partial class AddBrandsSubcategoriesItemTypesAndProductConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BrandId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CategoryId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("integer") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("integer"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs new file mode 100644 index 0000000..a7e0ac5 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs @@ -0,0 +1,442 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + /// Adds the Brand / SubCategory / ItemType masters and the singleton product config, + /// and converts CATEGORY from a self-nesting tree into a fixed two-level + /// Category → SubCategory hierarchy (docs/10 Part C.1). + /// + /// This migration carries data, not just DDL. The scaffolded version dropped + /// categories.ParentId outright, which would have silently flattened every + /// child category into a root and left items pointing at what is now a top-level + /// category — losing the parent entirely. The hand-written steps below (marked + /// "data migration") move child categories into subcategories and repoint items + /// onto the correct (category, subcategory) pair before the column goes away. + /// + /// + public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // NOTE: the ParentId drop is deliberately deferred to the bottom of this method — + // the data migration reads it. Order here is load-bearing. + migrationBuilder.RenameColumn( + name: "ItemType", + table: "items", + newName: "StockNature"); + + migrationBuilder.AddColumn( + name: "BrandId", + table: "items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SubCategoryId", + table: "items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "categories", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "Status", + table: "categories", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Active"); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "categories", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "xmin", + table: "categories", + type: "xid", + rowVersion: true, + nullable: false, + defaultValue: 0u); + + migrationBuilder.CreateTable( + name: "brands", + columns: table => new + { + BrandId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_brands", x => x.BrandId); + }); + + migrationBuilder.CreateTable( + name: "item_types", + columns: table => new + { + ItemTypeId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_item_types", x => x.ItemTypeId); + }); + + migrationBuilder.CreateTable( + name: "product_config", + columns: table => new + { + ConfigId = table.Column(type: "integer", nullable: false), + SubcategoriesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + BrandsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + ItemTypesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "integer", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_product_config", x => x.ConfigId); + table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); + table.ForeignKey( + name: "FK_product_config_users_UpdatedBy", + column: x => x.UpdatedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "subcategories", + columns: table => new + { + SubCategoryId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CategoryId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_subcategories", x => x.SubCategoryId); + table.ForeignKey( + name: "FK_subcategories_categories_CategoryId", + column: x => x.CategoryId, + principalTable: "categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + }); + + // --------------------------------------------------------------------------- + // DATA MIGRATION — must run before ParentId is dropped. + // --------------------------------------------------------------------------- + + // Existing categories predate CreatedAt; the added column defaulted them to + // 0001-01-01. Stamp them with the migration time instead of a sentinel date. + migrationBuilder.Sql(@" + UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc'; + "); + + // Carry the old category id alongside each new subcategory so items can be + // repointed by join below. Dropped again once the repoint is done. + migrationBuilder.Sql(@" + ALTER TABLE subcategories ADD COLUMN legacy_category_id integer; + "); + + // Walk the old tree to its roots. The previous model allowed unlimited nesting, + // but the new one is exactly two levels — so a category at any depth below the + // root collapses into a subcategory of its ROOT ancestor (a grandchild cannot + // become a subcategory of its immediate parent, since that parent is itself + // ceasing to be a category). + migrationBuilder.Sql(@" + WITH RECURSIVE tree AS ( + SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id + FROM categories + WHERE ""ParentId"" IS NULL + UNION ALL + SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id + FROM categories c + JOIN tree t ON c.""ParentId"" = t.""CategoryId"" + ) + INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id) + SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId"" + FROM tree t + WHERE t.""ParentId"" IS NOT NULL; + "); + + // Repoint items: an item that pointed at a child category now carries the root + // category plus the subcategory it actually meant. + migrationBuilder.Sql(@" + UPDATE items i + SET ""SubCategoryId"" = s.""SubCategoryId"", + ""CategoryId"" = s.""CategoryId"" + FROM subcategories s + WHERE s.legacy_category_id = i.""CategoryId""; + "); + + // The self-FK must go before the delete, or RESTRICT rejects removing a parent + // whose own child row is still present. + migrationBuilder.DropForeignKey( + name: "FK_categories_categories_ParentId", + table: "categories"); + + // Every non-root category now lives in `subcategories`, and no item references + // one any more (repointed above), so the rows can go. + migrationBuilder.Sql(@" + DELETE FROM categories WHERE ""ParentId"" IS NOT NULL; + ALTER TABLE subcategories DROP COLUMN legacy_category_id; + "); + + migrationBuilder.DropIndex( + name: "IX_categories_ParentId", + table: "categories"); + + migrationBuilder.DropColumn( + name: "ParentId", + table: "categories"); + + // Seed the singleton config (FR-MD-11) — all features on. Item writes read this + // row, so it must exist before the app serves a single request. + migrationBuilder.Sql(@" + INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"") + VALUES (1, TRUE, TRUE, TRUE) + ON CONFLICT (""ConfigId"") DO NOTHING; + "); + + // --------------------------------------------------------------------------- + + migrationBuilder.CreateIndex( + name: "IX_items_BrandId", + table: "items", + column: "BrandId"); + + migrationBuilder.CreateIndex( + name: "IX_items_SubCategoryId", + table: "items", + column: "SubCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_categories_Name", + table: "categories", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_categories_Status", + table: "categories", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_brands_Name", + table: "brands", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_brands_Status", + table: "brands", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_item_types_Name", + table: "item_types", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_item_types_Status", + table: "item_types", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_product_config_UpdatedBy", + table: "product_config", + column: "UpdatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_subcategories_CategoryId_Name", + table: "subcategories", + columns: new[] { "CategoryId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_subcategories_Status", + table: "subcategories", + column: "Status"); + + migrationBuilder.AddForeignKey( + name: "FK_items_brands_BrandId", + table: "items", + column: "BrandId", + principalTable: "brands", + principalColumn: "BrandId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_items_subcategories_SubCategoryId", + table: "items", + column: "SubCategoryId", + principalTable: "subcategories", + principalColumn: "SubCategoryId", + onDelete: ReferentialAction.Restrict); + } + + /// + /// Reverses the schema change and puts the subcategory data back where it came from. + /// + /// The scaffolded version simply dropped subcategories, which would have + /// discarded exactly what preserved. Instead each subcategory is + /// restored as a child category and its items are repointed back onto it. This is + /// not perfectly lossless: the old tree's depth is gone (a former grandchild comes + /// back as a direct child of its root), and Brand data cannot survive a schema that + /// has nowhere to put it. + /// + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_items_brands_BrandId", + table: "items"); + + migrationBuilder.DropForeignKey( + name: "FK_items_subcategories_SubCategoryId", + table: "items"); + + // Restore the parent column + self-FK first so subcategories have somewhere to + // land, then move them back before the table is dropped. + migrationBuilder.AddColumn( + name: "ParentId", + table: "categories", + type: "integer", + nullable: true); + + // --------------------------------------------------------------------------- + // DATA MIGRATION (reverse) — must run before `subcategories` is dropped. + // --------------------------------------------------------------------------- + + migrationBuilder.Sql(@" + ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer; + "); + + // Each subcategory becomes a child category again under the same parent. + migrationBuilder.Sql(@" + INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id) + SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId"" + FROM subcategories s; + "); + + // Items that carried a subcategory point back at the restored child category. + migrationBuilder.Sql(@" + UPDATE items i + SET ""CategoryId"" = c.""CategoryId"" + FROM categories c + WHERE c.legacy_subcategory_id = i.""SubCategoryId""; + "); + + migrationBuilder.Sql(@" + ALTER TABLE categories DROP COLUMN legacy_subcategory_id; + "); + + // --------------------------------------------------------------------------- + + migrationBuilder.DropTable( + name: "brands"); + + migrationBuilder.DropTable( + name: "item_types"); + + migrationBuilder.DropTable( + name: "product_config"); + + migrationBuilder.DropTable( + name: "subcategories"); + + migrationBuilder.DropIndex( + name: "IX_items_BrandId", + table: "items"); + + migrationBuilder.DropIndex( + name: "IX_items_SubCategoryId", + table: "items"); + + migrationBuilder.DropIndex( + name: "IX_categories_Name", + table: "categories"); + + migrationBuilder.DropIndex( + name: "IX_categories_Status", + table: "categories"); + + migrationBuilder.DropColumn( + name: "BrandId", + table: "items"); + + migrationBuilder.DropColumn( + name: "SubCategoryId", + table: "items"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "categories"); + + migrationBuilder.DropColumn( + name: "Status", + table: "categories"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "categories"); + + migrationBuilder.DropColumn( + name: "xmin", + table: "categories"); + + migrationBuilder.RenameColumn( + name: "StockNature", + table: "items", + newName: "ItemType"); + + // ParentId itself was re-added at the top of this method, ahead of the reverse + // data migration that populates it. + migrationBuilder.CreateIndex( + name: "IX_categories_ParentId", + table: "categories", + column: "ParentId"); + + migrationBuilder.AddForeignKey( + name: "FK_categories_categories_ParentId", + table: "categories", + column: "ParentId", + principalTable: "categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index 92abc54..88ab528 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -119,6 +119,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("bins", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BrandId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("brands", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => { b.Property("CategoryId") @@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("ParentId") - .HasColumnType("integer"); + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); b.HasKey("CategoryId"); - b.HasIndex("ParentId"); + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); b.ToTable("categories", (string)null); }); @@ -273,6 +334,9 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("BaseUomId") .HasColumnType("integer"); + b.Property("BrandId") + .HasColumnType("integer"); + b.Property("CategoryId") .HasColumnType("integer"); @@ -286,11 +350,6 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(1000) .HasColumnType("character varying(1000)"); - b.Property("ItemType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -314,6 +373,14 @@ namespace ERPCore.Infra.Persistence.Migrations .HasColumnType("character varying(20)") .HasDefaultValue("Active"); + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + b.Property("TaxClass") .HasMaxLength(20) .HasColumnType("character varying(20)"); @@ -330,6 +397,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("BaseUomId"); + b.HasIndex("BrandId"); + b.HasIndex("CategoryId"); b.HasIndex("DefaultVendorId"); @@ -339,6 +408,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("Status"); + b.HasIndex("SubCategoryId"); + b.ToTable("items", (string)null); }); @@ -374,6 +445,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("item_reorders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => { b.Property("JournalId") @@ -490,6 +603,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("po_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("integer"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1"); + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.Property("PoId") @@ -1239,6 +1394,51 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("stock_transfer_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -1516,16 +1716,6 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Parent"); - }); - modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -1616,6 +1806,11 @@ namespace ERPCore.Infra.Persistence.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Category", "Category") .WithMany() .HasForeignKey("CategoryId") @@ -1627,11 +1822,20 @@ namespace ERPCore.Infra.Persistence.Migrations .HasForeignKey("DefaultVendorId") .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("BaseUom"); + b.Navigation("Brand"); + b.Navigation("Category"); b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); }); modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => @@ -1688,6 +1892,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -2092,6 +2306,17 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Transfer"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -2159,7 +2384,7 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => { - b.Navigation("Children"); + b.Navigation("SubCategories"); }); modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 75b3156..d06df6e 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -62,6 +62,9 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/BrandService.cs b/Backend/ERPCore/Services/BrandService.cs new file mode 100644 index 0000000..dce5fd3 --- /dev/null +++ b/Backend/ERPCore/Services/BrandService.cs @@ -0,0 +1,114 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency +/// per docs/11-BACKEND-PHASE1.md §2.6. +/// +public sealed class BrandService : IBrandService +{ + private readonly IRepository _brands; + private readonly IUnitOfWork _uow; + + public BrandService(IRepository brands, IUnitOfWork uow) + { + _brands = brands; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _brands.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(b => b.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(b => b.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int brandId, CancellationToken ct = default) + { + var brand = await _brands.Query().AsNoTracking() + .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + return brand is null ? null : new ETagged(Map(brand), brand.RowVersion); + } + + public async Task> CreateAsync(CreateBrandRequest request, CancellationToken ct = default) + { + var name = request.Name.Trim(); + if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"A brand named '{name}' already exists."); + + var brand = new Brand + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _brands.AddAsync(brand, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(brand), brand.RowVersion); + } + + public async Task> UpdateAsync( + int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var brand = await _brands.GetByIdAsync(brandId, ct) + ?? throw new NotFoundException($"Brand {brandId} was not found."); + + if (brand.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(brand.Name, name, StringComparison.Ordinal) + && await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct)) + throw new ConflictException($"A brand named '{name}' already exists."); + + brand.Name = name; + brand.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412); + } + + return new ETagged(Map(brand), brand.RowVersion); + } + + public async Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default) + { + var brand = await _brands.GetByIdAsync(brandId, ct) + ?? throw new NotFoundException($"Brand {brandId} was not found."); + + brand.Status = status; + brand.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/CategoryService.cs b/Backend/ERPCore/Services/CategoryService.cs index ca0f05d..a84e40a 100644 --- a/Backend/ERPCore/Services/CategoryService.cs +++ b/Backend/ERPCore/Services/CategoryService.cs @@ -1,4 +1,6 @@ +using ERPCore.Common.Http; using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; using ERPCore.Infra.UoW; @@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; +/// +/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels: +/// categories no longer self-nest, so there is no cycle to detect and no tree to build +/// (docs/11-BACKEND-PHASE1.md §2.3). +/// public sealed class CategoryService : ICategoryService { private readonly IRepository _categories; + private readonly IRepository _subCategories; private readonly IUnitOfWork _uow; - public CategoryService(IRepository categories, IUnitOfWork uow) + public CategoryService( + IRepository categories, + IRepository subCategories, + IUnitOfWork uow) { _categories = categories; + _subCategories = subCategories; _uow = uow; } - public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + // Categories --------------------------------------------------------------- + + public async Task> ListAsync( + PageQuery query, EntityStatus? status, CancellationToken ct = default) { var q = _categories.Query().AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService var term = query.Q.Trim(); q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")); } + if (status is not null) q = q.Where(c => c.Status == status); var total = await q.CountAsync(ct); var rows = await q.OrderBy(c => c.Name) .Skip(query.Skip).Take(query.PageSize) - .Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId)) + .Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); } - public async Task> GetTreeAsync(CancellationToken ct = default) + public async Task?> GetAsync(int categoryId, CancellationToken ct = default) { - var all = await _categories.Query().AsNoTracking() - .OrderBy(c => c.Name) - .Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId)) - .ToListAsync(ct); - - var byParent = all.ToLookup(c => c.ParentId); - - List Build(int? parentId) => - byParent[parentId] - .Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId))) - .ToList(); - - return Build(null); + var category = await _categories.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct); + return category is null ? null : new ETagged(Map(category), category.RowVersion); } - public async Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) + public async Task> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) { - if (request.ParentId is not null - && !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422); + var name = request.Name.Trim(); + if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"A category named '{name}' already exists."); + + var category = new Category + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; - var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId }; await _categories.AddAsync(category, ct); await _uow.SaveChangesAsync(ct); - return new CategoryDto(category.CategoryId, category.Name, category.ParentId); + return new ETagged(Map(category), category.RowVersion); } + + public async Task> UpdateAsync( + int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var category = await _categories.GetByIdAsync(categoryId, ct) + ?? throw new NotFoundException($"Category {categoryId} was not found."); + + if (category.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(category.Name, name, StringComparison.Ordinal) + && await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct)) + throw new ConflictException($"A category named '{name}' already exists."); + + category.Name = name; + category.UpdatedAt = DateTime.UtcNow; + + await SaveGuardingConcurrencyAsync("category", ct); + return new ETagged(Map(category), category.RowVersion); + } + + public async Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default) + { + var category = await _categories.GetByIdAsync(categoryId, ct) + ?? throw new NotFoundException($"Category {categoryId} was not found."); + + category.Status = status; + category.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + // Subcategories ------------------------------------------------------------ + + public async Task> ListSubCategoriesAsync( + int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) + throw new NotFoundException($"Category {categoryId} was not found."); + + var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(s => s.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(s => s.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(s => new SubCategoryDto( + s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default) + { + var sub = await _subCategories.Query().AsNoTracking() + .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + return sub is null ? null : new ETagged(MapSub(sub), sub.RowVersion); + } + + public async Task> CreateSubCategoryAsync( + int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default) + { + var category = await _categories.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct) + ?? throw new NotFoundException($"Category {categoryId} was not found."); + + if (category.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422); + + var name = request.Name.Trim(); + if (await _subCategories.Query().AnyAsync( + s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}."); + + var sub = new SubCategory + { + CategoryId = categoryId, + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _subCategories.AddAsync(sub, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(MapSub(sub), sub.RowVersion); + } + + public async Task> UpdateSubCategoryAsync( + int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var sub = await _subCategories.GetByIdAsync(subCategoryId, ct) + ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found."); + + if (sub.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(sub.Name, name, StringComparison.Ordinal) + && await _subCategories.Query().AnyAsync( + s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct)) + throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}."); + + // Name only — reparenting is not offered, since it would silently invalidate the + // category of every item pointing at this subcategory. + sub.Name = name; + sub.UpdatedAt = DateTime.UtcNow; + + await SaveGuardingConcurrencyAsync("subcategory", ct); + return new ETagged(MapSub(sub), sub.RowVersion); + } + + public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default) + { + var sub = await _subCategories.GetByIdAsync(subCategoryId, ct) + ?? throw new NotFoundException($"Subcategory {subCategoryId} was not found."); + + sub.Status = status; + sub.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + // --------------------------------------------------------------------------- + + private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct) + { + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, $"The {label} was modified by another request.", 412); + } + } + + private static CategoryDto Map(Category c) => new(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt); + + private static SubCategoryDto MapSub(SubCategory s) => new( + s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt); } diff --git a/Backend/ERPCore/Services/Interfaces/IBrandService.cs b/Backend/ERPCore/Services/Interfaces/IBrandService.cs new file mode 100644 index 0000000..33b4a80 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBrandService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; + +namespace ERPCore.Services.Interfaces; + +/// Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6). +public interface IBrandService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int brandId, CancellationToken ct = default); + Task> CreateAsync(CreateBrandRequest request, CancellationToken ct = default); + Task> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs index c318ef6..a96116e 100644 --- a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs @@ -1,12 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; namespace ERPCore.Services.Interfaces; -/// Category master business logic (docs/11-BACKEND-PHASE1.md §2.3). +/// +/// Category + subcategory master business logic (docs/11-BACKEND-PHASE1.md §2.3). +/// The hierarchy is exactly two levels deep; there is no tree endpoint any more. +/// public interface ICategoryService { - Task> ListAsync(PageQuery query, CancellationToken ct = default); - Task> GetTreeAsync(CancellationToken ct = default); - Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int categoryId, CancellationToken ct = default); + Task> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); + Task> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default); + + /// Subcategories of one category. 404s when the category itself does not exist. + Task> ListSubCategoriesAsync( + int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default); + + Task?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default); + Task> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default); + Task> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs index b405f27..f73ee08 100644 --- a/Backend/ERPCore/Services/Interfaces/IItemService.cs +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces; public interface IItemService { Task> ListAsync( - PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default); + PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, + TrackingMode? trackingMode, CancellationToken ct = default); Task?> GetAsync(int itemId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs b/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs new file mode 100644 index 0000000..e03969e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs @@ -0,0 +1,20 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; + +namespace ERPCore.Services.Interfaces; + +/// +/// Item type master business logic (docs/11-BACKEND-PHASE1.md §2.7). Plain CRUD over an +/// unlinked list — no item ever references an item type (docs/10 Part C.9), so there is +/// nothing here beyond maintaining the names the builder's dropdown reads. +/// +public interface IItemTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int itemTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs b/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs new file mode 100644 index 0000000..468eb29 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs @@ -0,0 +1,13 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Config; + +namespace ERPCore.Services.Interfaces; + +/// Product configuration business logic (docs/11-BACKEND-PHASE1.md §2.8). Singleton. +public interface IProductConfigService +{ + Task> GetAsync(CancellationToken ct = default); + + Task> UpdateAsync( + UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 3a54e1c..051d4e0 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -13,36 +13,52 @@ namespace ERPCore.Services; /// /// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference -/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per -/// docs/11-BACKEND-PHASE1.md §2.1–2.2 and 02-SECURITY C.1. +/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic +/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.1–2.2 +/// and 02-SECURITY C.1. +/// +/// The SKU arrives generated by the client (it encodes the chosen item-type values, +/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no +/// item-type reference at all — see docs/10 Part C.9. +/// /// public sealed class ItemService : IItemService { private readonly IRepository _items; private readonly IRepository _categories; + private readonly IRepository _subCategories; + private readonly IRepository _brands; private readonly IRepository _uoms; private readonly IRepository _vendors; private readonly IRepository _warehouses; + private readonly IProductConfigService _config; private readonly IUnitOfWork _uow; public ItemService( IRepository items, IRepository categories, + IRepository subCategories, + IRepository brands, IRepository uoms, IRepository vendors, IRepository warehouses, + IProductConfigService config, IUnitOfWork uow) { _items = items; _categories = categories; + _subCategories = subCategories; + _brands = brands; _uoms = uoms; _vendors = vendors; _warehouses = warehouses; + _config = config; _uow = uow; } public async Task> ListAsync( - PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default) + PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, + TrackingMode? trackingMode, CancellationToken ct = default) { var q = _items.Query().AsNoTracking(); @@ -53,14 +69,17 @@ public sealed class ItemService : IItemService } if (status is not null) q = q.Where(i => i.Status == status); if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId); + if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId); + if (brandId is not null) q = q.Where(i => i.BrandId == brandId); if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode); var total = await q.CountAsync(ct); var rows = await q.OrderBy(i => i.Sku) .Skip(query.Skip).Take(query.PageSize) .Select(i => new ItemListItemDto( - i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId, - i.ItemType, i.TrackingMode, i.TaxClass, i.Status)) + i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, + i.BaseUomId, i.DefaultVendorId, + i.StockNature, i.TrackingMode, i.TaxClass, i.Status)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -80,7 +99,9 @@ public sealed class ItemService : IItemService if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct)) throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); - await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct); + await ValidateReferencesAsync( + request.CategoryId, request.SubCategoryId, request.BrandId, + request.BaseUomId, request.DefaultVendorId, ct); var item = new Item { @@ -88,9 +109,11 @@ public sealed class ItemService : IItemService Name = request.Name.Trim(), Description = request.Description, CategoryId = request.CategoryId, + SubCategoryId = request.SubCategoryId, + BrandId = request.BrandId, BaseUomId = request.BaseUomId, DefaultVendorId = request.DefaultVendorId, - ItemType = request.ItemType, + StockNature = request.StockNature, TrackingMode = request.TrackingMode, TaxClass = request.TaxClass, Status = EntityStatus.Active, @@ -118,15 +141,19 @@ public sealed class ItemService : IItemService && await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct)) throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); - await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct); + await ValidateReferencesAsync( + request.CategoryId, request.SubCategoryId, request.BrandId, + request.BaseUomId, request.DefaultVendorId, ct); item.Sku = request.Sku.Trim(); item.Name = request.Name.Trim(); item.Description = request.Description; item.CategoryId = request.CategoryId; + item.SubCategoryId = request.SubCategoryId; + item.BrandId = request.BrandId; item.BaseUomId = request.BaseUomId; item.DefaultVendorId = request.DefaultVendorId; - item.ItemType = request.ItemType; + item.StockNature = request.StockNature; item.TrackingMode = request.TrackingMode; item.TaxClass = request.TaxClass; item.UpdatedAt = DateTime.UtcNow; @@ -240,11 +267,56 @@ public sealed class ItemService : IItemService return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions); } - private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct) + /// + /// Validates every FK on an item write, and gates the optional ones on the product + /// configuration (FR-MD-11). Note there is no item-type check: nothing on an item + /// references an item type, so itemTypesEnabled has nothing to reject here — + /// it is advisory and honoured by the frontend only (docs/11 §2.8). + /// + private async Task ValidateReferencesAsync( + int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct) { + var config = (await _config.GetAsync(ct)).Value; + if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422); + if (subCategoryId is not null) + { + if (!config.SubcategoriesEnabled) + throw new DomainException( + ErrorCodes.ConfigDisabled, + "Subcategories are disabled in the product configuration; subCategoryId must be null.", 422); + + var sub = await _subCategories.Query().AsNoTracking() + .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + if (sub is null) + throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422); + if (sub.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422); + // The two FKs must agree, or the item would claim a category its subcategory + // does not belong to. + if (sub.CategoryId != categoryId) + throw new DomainException( + ErrorCodes.Validation, + $"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422); + } + + if (brandId is not null) + { + if (!config.BrandsEnabled) + throw new DomainException( + ErrorCodes.ConfigDisabled, + "Brands are disabled in the product configuration; brandId must be null.", 422); + + var brand = await _brands.Query().AsNoTracking() + .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + if (brand is null) + throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422); + if (brand.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422); + } + if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct)) throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422); @@ -272,8 +344,9 @@ public sealed class ItemService : IItemService } private static ItemDetailDto ToDetail(Item i) => new( - i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId, - i.ItemType, i.TrackingMode, i.TaxClass, i.Status, + i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, + i.BaseUomId, i.DefaultVendorId, + i.StockNature, i.TrackingMode, i.TaxClass, i.Status, i.ReorderSettings .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) diff --git a/Backend/ERPCore/Services/ItemTypeService.cs b/Backend/ERPCore/Services/ItemTypeService.cs new file mode 100644 index 0000000..6297a54 --- /dev/null +++ b/Backend/ERPCore/Services/ItemTypeService.cs @@ -0,0 +1,118 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that +/// GET /item-types serves to the frontend builder's dropdown. Nothing references +/// these rows, so there is no in-use check to make and no cascade to worry about +/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9). +/// +public sealed class ItemTypeService : IItemTypeService +{ + private readonly IRepository _itemTypes; + private readonly IUnitOfWork _uow; + + public ItemTypeService(IRepository itemTypes, IUnitOfWork uow) + { + _itemTypes = itemTypes; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _itemTypes.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int itemTypeId, CancellationToken ct = default) + { + var itemType = await _itemTypes.Query().AsNoTracking() + .FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct); + return itemType is null ? null : new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default) + { + var name = request.Name.Trim(); + if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"An item type named '{name}' already exists."); + + var itemType = new ItemType + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _itemTypes.AddAsync(itemType, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task> UpdateAsync( + int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) + ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); + + if (itemType.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(itemType.Name, name, StringComparison.Ordinal) + && await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct)) + throw new ConflictException($"An item type named '{name}' already exists."); + + // Renaming does not touch existing items: their SKUs already encode the values that + // were chosen, and nothing joins back to this row (docs/10 Part C.9). + itemType.Name = name; + itemType.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); + } + + return new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default) + { + var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) + ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); + + itemType.Status = status; + itemType.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/ProductConfigService.cs b/Backend/ERPCore/Services/ProductConfigService.cs new file mode 100644 index 0000000..ef8d0c4 --- /dev/null +++ b/Backend/ERPCore/Services/ProductConfigService.cs @@ -0,0 +1,71 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Config; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder. +/// Reads are never gated — only writes consult the flags — so switching a feature off +/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8). +/// +public sealed class ProductConfigService : IProductConfigService +{ + private readonly IRepository _config; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public ProductConfigService(IRepository config, ICurrentUser currentUser, IUnitOfWork uow) + { + _config = config; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> GetAsync(CancellationToken ct = default) + { + var config = await _config.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct) + ?? throw new NotFoundException("Product configuration has not been seeded."); + + return new ETagged(Map(config), config.RowVersion); + } + + public async Task> UpdateAsync( + UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var config = await _config.GetByIdAsync(ProductConfig.SingletonId, ct) + ?? throw new NotFoundException("Product configuration has not been seeded."); + + if (config.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + + // Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the + // model binder has already rejected anything that would reach here with a null. + config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; + config.BrandsEnabled = request.BrandsEnabled!.Value; + config.ItemTypesEnabled = request.ItemTypesEnabled!.Value; + config.UpdatedAt = DateTime.UtcNow; + config.UpdatedBy = _currentUser.AuditUserId; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + } + + return new ETagged(Map(config), config.RowVersion); + } + + private static ProductConfigDto Map(ProductConfig c) => new( + c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index a86e142..1316272 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -24,6 +24,7 @@ public static class ErrorCodes public const string ReasonCodeRequired = "REASON_CODE_REQUIRED"; public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT"; public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY"; + public const string ConfigDisabled = "CONFIG_DISABLED"; // Auth proxy (AuthController → AuthHex, docs/11 §2.0) public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index fa8ccd2..e1c80ae 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -15,14 +15,41 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`) ## 1. Master Data -> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical. -- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU +> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting (**that endpoint was removed on 2026-07-16** — categories no longer nest; see the entry at the end of this section); `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical. +- [x] Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU - [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1) - [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert) -- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation) +- [x] Category — **rebuilt 2026-07-16 as a two-level Category/SubCategory model** (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely - [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`) - [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse) - [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation) +- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK +- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only +- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` +- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes + +> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2) +> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8. +> +> **Deviations / decisions (all recorded in docs/10 §B.8.4 #11–13):** +> - **`ItemType` enum → `StockNature`.** The Stocked/NonStocked/Service enum was renamed to free the name `ItemType` for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (`RenameColumn`, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it. +> - **`CATEGORY.parent_id` removed.** Arbitrary nesting is gone, replaced by a dedicated `SUBCATEGORY` table (exactly two levels). Items now carry **both** FKs; previously the frontend collapsed them (`effectiveCategoryId = subCategoryId ?? categoryId`), losing the parent. +> - **Item types are unlinked to items, deliberately.** No value table, no join — values live only in the client-generated SKU (`BL-100-0003`) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is *not* a product-variation model; none was requested. +> - **`itemTypesEnabled` is advisory, not enforced.** With no item-type reference on an item there is nothing on a write to reject; only `subcategoriesEnabled`/`brandsEnabled` produce `CONFIG_DISABLED`. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee. +> - **`PUT /product-config` is door-policy-gated only** — any ERP-admitted user can flip the flags. A `CONFIG_MANAGE` permission is reserved for when RBAC lands (open decision #13). +> - **`brandId` is now a documented field**, no longer the undocumented frontend-only extra it was. +> +> **Migration #2 (`AddBrandsSubcategoriesItemTypesAndProductConfig`) carries data, not just DDL.** The scaffolded version dropped `parent_id` outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into `subcategories`, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at **any** depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its *root*, not of its (now-nonexistent) parent category. `Down()` was likewise hand-written to restore the tree instead of dropping `subcategories` and losing it. +> +> Also fixed while writing it: the `ck_product_config_singleton` check constraint was scaffolded as `config_id = 1`, but the column is created quoted-PascalCase (`"ConfigId"`) — unquoted, Postgres folds it to a column that doesn't exist. And `UpdateProductConfigRequest`'s flags are `bool?` on purpose: `[Required]` on a non-nullable `bool` is a no-op, so a body of `{}` would have bound all three to `false` and silently switched every feature off. +> +> **Schema/migration verified:** `dotnet build` clean. Migration `Up` **and** `Down` exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and `StockNature` data preservation; the fixture was then removed. `DataSeeder` seeds `Color`/`Size` + the config singleton idempotently (it needed restructuring — an early `return` in the reason-code path would otherwise have skipped the new seeds on every start after the first). +> +> **Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session.** Auth note: a token *is* obtainable despite the `loginUser` blocker — **`POST /api/v1/auth/register` succeeds and issues the `erp_at` session cookie directly**, and the JWT handler's cookie fallback means that session authenticates every other controller. (`loginUser` still `500`s "Invalid credentials" for that same freshly-registered user, by username *or* email, with *or* without `userTypeId` — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal `roleId`/`userTypeId` GUIDs, supplied by the user; Admin = role `08de6a11-9e9f-4401-8a10-6859860b41ec` / userType `00000000-0000-0000-0000-000000000004`. +> +> Covered: brand/category/subcategory/item-type create; **case-insensitive duplicate name → 409** (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU `BL-100-0003` → 201 and full round-trip on `GET /items/{id}`; new `brandId`/`subCategoryId` list filters; **cross-FK guard → 422** ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; `PUT /product-config {}` → **400** (proving the `bool?` fix — an empty body no longer silently disables everything); `subcategoriesEnabled:false` + `subCategoryId` → **422 CONFIG_DISABLED**, same item without it → 201, and **pre-existing items with a subcategory still read back fine**; `brandsEnabled:false` + `brandId` → 422; **`itemTypesEnabled:false` correctly does NOT block item writes** (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → **412 CONCURRENCY_CONFLICT** (brand + subcategory) / absent → 428; `PATCH /status` → 204 then inactive-brand reference → 422; and **renaming an item type left existing SKUs untouched**, confirming the intended decoupling. Audit stamp confirmed live: `product_config.updatedBy` resolved to a JIT-provisioned shadow user (`SMOKE001`) from the AuthHex `UserId`/`NIC` claims. +> +> Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). **Two artifacts left behind on purpose:** the AuthHex user `smoketest_admin` / NIC `SMOKE001` in AuthHex's own MySQL store, and its ERPCore shadow user (`users.UserId = 3`) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted. ## 2. Procurement > Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired. @@ -147,3 +174,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed. - **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing. - **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved. + - **ROOT CAUSE FOUND (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners. Not fixed here: different project, outside this repo's scope.** Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. + 1. **The password is never stored.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 is commented out** (`//PasswordHash = PasswordHash`). Every registered user lands in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always throws `"Invalid credentials"`. Uncommenting line 116 should fix login outright. Note existing users are unrecoverable — their hashes were never written — so they need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). + 2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: ""` will not resolve a user; only email/mobile/NIC will. + - **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed. diff --git a/docs/00-CORE.md b/docs/00-CORE.md index c878db5..9277b56 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -311,7 +311,7 @@ dotnet run - Swagger UI at `https://localhost:/swagger` - Health at `https://localhost:/health` → `Healthy` -Entity/DbContext modeling (the 38 entities, configurations, enums) is specified in `10-BACKEND-PHASE1.md`. Do not invent the schema here — follow that file. +Entity/DbContext modeling (the 42 entities, configurations, enums) is specified in `10-BACKEND-PHASE1.md`. Do not invent the schema here — follow that file. --- diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 089f757..b181247 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -1,6 +1,6 @@ # 10 · BACKEND — Phase 1 Spec (Inventory & Supply Chain) -> **Authoritative for:** backend architecture, business rules, and the data model (the 38-entity schema). +> **Authoritative for:** backend architecture, business rules, and the data model (the 42-entity schema). > **Navigation:** you arrived here from `00-CORE.md`. API request/response contracts are in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`. > **Scope basis:** SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting. > **Authentication:** identity is owned by the **external AuthHex identity provider** (separate service), but as of 2026-07-16 the frontend no longer calls AuthHex directly — all login/registration/recovery/2FA/session traffic is proxied through ERPCore's own `AuthController` (`Controllers/AuthController.cs`, `Services/Auth/*`), which forwards to AuthHex and delivers the resulting session as httpOnly Secure cookies (docs/02-SECURITY.md §B.2). ERPCore still does not mint or sign tokens itself — it only forwards to and validates AuthHex's RS256 JWTs. See A.4 (Authentication / Audit actor) and `11-BACKEND-PHASE1.md §2.0` for the endpoint list. RBAC (per-endpoint) still deferred. @@ -118,14 +118,17 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users ### B.3.1 Master Data (FR-MD) | ID | Requirement | Pri | |---|---|---| -| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category, item type (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor. | M | +| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor. | M | | FR-MD-02 | Maintain **UOM master** with base UOM per item and **conversion factors** (purchase→stock→base). | M | | FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | M | -| FR-MD-04 | Maintain **hierarchical item categories**. | S | +| FR-MD-04 | Maintain **item categories with one optional subcategory level**. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S | | FR-MD-05 | Hold **reorder point** and **reorder quantity** per item, optionally per warehouse. | M | | FR-MD-06 | Maintain **Vendor master**: code, name, contact, terms, tax reg, status, currency. | M | | FR-MD-07 | Maintain **Warehouse master** and, within each, a **bin/location** structure. | M | | FR-MD-08 | Prevent deletion of any master referenced by a transaction; deactivate instead. | M | +| FR-MD-09 | Maintain **Brand master** (name, status); optionally referenced by an item. | S | +| FR-MD-10 | Maintain **Item Type master** (name, status — e.g. Color, Size, Material) as a **selectable list only**: it feeds the item builder's dropdown and is **not** referenced by any item. Chosen values are encoded into the client-generated SKU, not stored (Part C.9). No product-variation model. | S | +| FR-MD-11 | Maintain a singleton **Product Configuration** gating optional features. `subcategoriesEnabled`/`brandsEnabled` are **enforced server-side** — an item write carrying a gated field while its flag is off is rejected (`CONFIG_DISABLED`). `itemTypesEnabled` is **advisory** (frontend-honoured) since items hold no item-type reference. Reads are never gated. | S | ### B.3.2 Procurement (FR-PROC) | ID | Requirement | Pri | @@ -191,7 +194,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M | ## B.4 Data Model (summary) -Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission). +Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission). ## B.5 External Interfaces UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules. @@ -244,26 +247,41 @@ Adjustment: Damage, Theft/Loss, Count Variance, Expiry Write-off, System Correct | 8 | Costing method | **Resolved:** FIFO | | 9 | Tenancy | **Resolved:** single-tenant | | 10 | Authentication | **Resolved:** external **AuthHex** IdP (RS256; ERPCore validates only), **shadow-user** provisioning (`auth_user_id` GUID → local `int`), door-gated by ERP `UserType`/`Role`; per-endpoint RBAC deferred. *Open sub-item:* exact ERP `UserTypeCode`/`RoleCode` + RSA-key rotation process. | +| 11 | Category hierarchy depth | **Resolved:** dedicated `SUBCATEGORY` table, exactly two levels; `CATEGORY.parent_id` dropped. Item carries both FKs (subcategory nullable). Arbitrary nesting is not coming back. | +| 12 | Item types / variants | **Resolved:** the `ItemType` **enum** was replaced by an **unreferenced master list**; Stocked/NonStocked/Service survives as `stock_nature`. Values are **SKU-encoded only** — no value table, no item link, no product-variation model (Part C.9 records the accepted trade-off). | +| 13 | Product-config authorization | **Open:** `PUT /product-config` is gated by the door policy only, like every other endpoint. A `CONFIG_MANAGE` permission is reserved for when per-endpoint RBAC lands (decision #6). Until then any ERP-admitted user can flip the flags. | --- -# Part C — ER Model (38 entities) +# Part C — ER Model (42 entities) Costing: FIFO · Multi-warehouse · Single-tenant. Legend: **PK** primary key · **FK** foreign key. Companion visual diagrams (Mermaid / draw.io ERD) accompany this repo; this part is the authoritative textual model. ## C.1 Master Data ``` -CATEGORY(category_id PK, parent_id FK→CATEGORY, name) +CATEGORY(category_id PK, name, status) -- top level; no self-nesting +SUBCATEGORY(subcategory_id PK, category_id FK→CATEGORY, name, status) +BRAND(brand_id PK, name, status) +ITEM_TYPE(item_type_id PK, name, status) -- Color, Size, Material — standalone UOM(uom_id PK, name) UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor) -ITEM(item_id PK, sku, name, category_id FK→CATEGORY, base_uom_id FK→UOM, - default_vendor_id FK→VENDOR, item_type, tracking_mode, tax_class, status) +ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable], + brand_id FK→BRAND [nullable], base_uom_id FK→UOM, + default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class, status) ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty) VENDOR(vendor_id PK, code, name, terms, tax_reg, currency, status) WAREHOUSE(warehouse_id PK, code, name) BIN(bin_id PK, warehouse_id FK→WAREHOUSE, code, bin_type) +PRODUCT_CONFIG(config_id PK [singleton = 1], subcategories_enabled, brands_enabled, + item_types_enabled, updated_at, updated_by FK→USER) -- FR-MD-11 feature gate ``` +**Category hierarchy is exactly two levels.** `CATEGORY.parent_id` was removed (migration `AddBrandsSubcategoriesItemTypesAndProductConfig`); the optional level below a category is now `SUBCATEGORY`. An item carries **both** FKs — `category_id` required, `subcategory_id` nullable — and the service rejects a subcategory that does not belong to the given category (422). + +**`ITEM_TYPE` is deliberately unreferenced** — see C.9. + +**`stock_nature`** (Stocked/NonStocked/Service) is the former `item_type` column, renamed so the name could be taken by the `ITEM_TYPE` master. The two are unrelated concepts. + ## C.2 Procurement ``` REQUISITION(requisition_id PK, doc_no, requested_by FK→USER, status, created_at) @@ -339,6 +357,10 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) ``` ## C.9 Modeling notes (load-bearing) +- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. + - *Accepted trade-off (a decision, not an oversight):* the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (`Color` → `Colour`) does **not** touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand. +- **Two-level categories.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead. +- **Product config is a singleton, and only two of its flags are enforceable.** `subcategories_enabled` / `brands_enabled` gate item writes (`CONFIG_DISABLED`, 422). `item_types_enabled` is **advisory only** — since items carry no item-type reference, there is nothing on a write to reject; the frontend honours it by hiding the builder's type section. Reads are never gated, so existing data stays readable after a flag is switched off. - **FIFO = two structures.** `STOCK_LAYER` answers valuation ("what's on hand and at what cost"); `STOCK_LEDGER` answers history ("what moved, when, by whom"). Layers are keyed per item **per warehouse**. - **Polymorphic source.** `STOCK_LEDGER.source_doc_type/source_doc_id` (and `AUDIT_LOG`, `JOURNAL_ENTRY_STUB`) reference the originating document without a hard FK per type — new transaction types (Sales, Manufacturing) write to the ledger without a schema change. - **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost. @@ -348,10 +370,10 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) - **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required. ## C.10 Entity → implementation mapping -- Entities → `Domain/Entities`; enums (`ItemType`, `TrackingMode`, `HoldStatus`, `Direction`, `*Status`, `CountType`) → `Domain/Enums`. +- Entities → `Domain/Entities`; enums (`StockNature`, `TrackingMode`, `HoldStatus`, `Direction`, `*Status`, `CountType`) → `Domain/Enums`. **Note:** `ItemType` in `Domain/Entities` is the master entity; the old `ItemType` **enum** is now `StockNature` — there is no enum by that name. - EF configurations (`IEntityTypeConfiguration`, one per entity) → `Infra/Persistence/Configurations`. - FIFO logic → `Services/Stock/FifoCostingService` (Part A.2). Ledger writes only via stock services inside the UoW transaction. -- `RowVersion` (concurrency) on mutable aggregates: Item, Vendor, PurchaseOrder, GRN, transfers/adjustments/counts headers. +- `RowVersion` (concurrency) on mutable aggregates: Item, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, PurchaseOrder, GRN, transfers/adjustments/counts headers. --- diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index a570116..b8a81a5 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -105,38 +105,45 @@ session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered in `erp_rt` cookie rather than the request body. ### 2.1 Items +> **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9). + #### `GET /items` -Query: `q`, `status` (`Active|Inactive`), `categoryId`, `trackingMode` (`None|Batch|Serial`), + paging. +Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandId`, `trackingMode` (`None|Batch|Serial`), + paging. **200 OK** ```json { "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40", - "categoryId": 12, "baseUomId": 1, "defaultVendorId": 5, - "itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ], + "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, + "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ], "pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } } ``` #### `GET /items/{itemId}` → **200 OK** (header `ETag: "AAAAAAAAB9E="`) ```json { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40", - "description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "baseUomId": 1, - "defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD", + "description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "subCategoryId": 30, + "brandId": 2, "baseUomId": 1, + "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ], "createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" } ``` #### `POST /items` +The `sku` is **generated by the client** (it encodes the chosen item-type values, e.g. `BL-100-0003`); the server only enforces uniqueness. `subCategoryId`/`brandId` are optional. ```json { "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut", - "categoryId": 12, "baseUomId": 1, "defaultVendorId": 5, - "itemType": "Stocked", "trackingMode": "None", "taxClass": "STD" } + "categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5, + "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD" } ``` **201 Created** — `Location: /api/v1/items/1002` ```json -{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, "baseUomId": 1, - "defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "None", "taxClass": "STD", +{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, + "subCategoryId": 30, "brandId": 2, "baseUomId": 1, + "defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD", "status": "Active", "createdAt": "2026-07-07T09:30:00Z" } ``` `400` → `code: SKU_DUPLICATE` if SKU exists. +`422` → `code: CONFIG_DISABLED` if `subCategoryId` is sent while subcategories are disabled, or `brandId` while brands are disabled (§2.8). +`422` → validation error if the subcategory does not belong to `categoryId`, or if a referenced subcategory/brand/vendor is missing or inactive. #### `PUT /items/{itemId}` Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag mismatch. @@ -171,13 +178,58 @@ Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag "conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ] } ``` -### 2.3 Categories +### 2.3 Categories & Subcategories +> **Two-level hierarchy (2026-07-16).** Categories no longer self-nest: `parentId` and `GET /categories?tree=true` are **gone**, replaced by a dedicated Subcategory resource one level below. Categories also gained `status` + an `ETag` (they previously had neither, so there was no update path at all). + +#### `GET /categories` +Query: `q`, `status` (`Active|Inactive`), + paging. **200 OK** → list envelope of `CategoryDto`. + +#### `GET /categories/{categoryId}` → **200 OK** (+ `ETag`); `404` if absent. +```json +{ "categoryId": 12, "name": "Fasteners", "status": "Active", + "createdAt": "2026-06-01T08:00:00Z", "updatedAt": null } +``` + #### `POST /categories` ```json -{ "name": "Fasteners", "parentId": 3 } +{ "name": "Fasteners" } ``` -**201 Created** → `{ "categoryId": 12, "name": "Fasteners", "parentId": 3 }` -`GET /categories?tree=true` returns a nested tree. +**201 Created** → `{ "categoryId": 12, "name": "Fasteners", "status": "Active", "createdAt": "...", "updatedAt": null }` +`409` if the name already exists (names are unique, case-insensitive). + +#### `PUT /categories/{categoryId}` +Requires `If-Match`. → **200 OK**; `412` on ETag mismatch; `409` on duplicate name. + +#### `PATCH /categories/{categoryId}/status` +```json +{ "status": "Inactive" } +``` +**204 No Content**. Deactivate, never delete (FR-MD-08). + +#### `GET /categories/{categoryId}/subcategories` +Query: `q`, `status`, + paging. **200 OK** → list envelope of `SubCategoryDto`; `404` if the category itself is absent. + +#### `POST /categories/{categoryId}/subcategories` +The parent comes from the route. +```json +{ "name": "Hex Bolts" } +``` +**201 Created** — `Location: /api/v1/subcategories/30` +```json +{ "subCategoryId": 30, "categoryId": 12, "name": "Hex Bolts", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } +``` +`404` if the category does not exist · `422` if it is inactive · `409` if the name already exists **within that category** (names need only be unique per parent). + +#### `GET /subcategories/{subCategoryId}` → **200 OK** (+ `ETag`); `404` if absent. + +#### `PUT /subcategories/{subCategoryId}` +Requires `If-Match`. **Name only** — a subcategory cannot be moved to another category, since that would silently invalidate the `categoryId` of every item referencing it. → **200 OK**; `412` on mismatch. +```json +{ "name": "Hex Bolts (metric)" } +``` + +#### `PATCH /subcategories/{subCategoryId}/status` → **204 No Content**. ### 2.4 Vendors #### `POST /vendors` @@ -206,6 +258,88 @@ Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag **201 Created** → `{ "binId": 45, "warehouseId": 1, "code": "A-01-01", "binType": "Shelf" }` `GET /warehouses/{warehouseId}/bins` lists bins. +### 2.6 Brands +Referenced optionally by `Item.brandId`. Rejected on item writes when brands are disabled (§2.8). + +#### `GET /brands` +Query: `q`, `status` (`Active|Inactive`), + paging. **200 OK** → list envelope of `BrandDto`. + +#### `GET /brands/{brandId}` → **200 OK** (+ `ETag`); `404` if absent. + +#### `POST /brands` +```json +{ "name": "Bosch" } +``` +**201 Created** — `Location: /api/v1/brands/2` +```json +{ "brandId": 2, "name": "Bosch", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } +``` +`409` if the name already exists (unique, case-insensitive). + +#### `PUT /brands/{brandId}` +Requires `If-Match`. → **200 OK**; `412` on mismatch; `409` on duplicate name. + +#### `PATCH /brands/{brandId}/status` → **204 No Content**. Deactivate, never delete (FR-MD-08). + +### 2.7 Item Types +> **Read this before assuming a relationship exists.** An item type is a *dimension name* (Color, Size, Material) and nothing more. **No item references an item type**, and there is no value resource: the values chosen in the frontend builder (Red, S, M) are encoded into the **client-generated SKU** — `BL-0002` for one type, `BL-100-0003` for two — and are never stored or parsed server-side. `GET /item-types` exists to populate the builder's dropdown; that is the entire purpose of this master. Consequently the API cannot filter items by colour/size, and renaming an item type does not alter any existing SKU. See docs/10 Part C.9 for the recorded trade-off. Not to be confused with `stockNature` (§2.1), which is what the old `itemType` enum became. + +#### `GET /item-types` +Query: `q`, `status` (`Active|Inactive`), + paging. Pass `status=Active` for selectable rows. +**200 OK** +```json +{ "items": [ { "itemTypeId": 1, "name": "Color", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null }, + { "itemTypeId": 2, "name": "Size", "status": "Active", + "createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } ], + "pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1 } } +``` +`Color` and `Size` are seeded on first start; users add their own (e.g. `Material`). + +#### `GET /item-types/{itemTypeId}` → **200 OK** (+ `ETag`); `404` if absent. + +#### `POST /item-types` +```json +{ "name": "Material" } +``` +**201 Created** — `Location: /api/v1/item-types/3` → the `ItemTypeDto`. `409` if the name exists. +Callable from the item builder's inline "+" as well as the admin screen. + +#### `PUT /item-types/{itemTypeId}` +Requires `If-Match`. → **200 OK**; `412` on mismatch; `409` on duplicate name. +**Renaming does not touch existing items** — nothing joins back to this row. + +#### `PATCH /item-types/{itemTypeId}/status` → **204 No Content**. Deactivate, never delete (FR-MD-08). + +### 2.8 Product Configuration +A **singleton** feature gate (FR-MD-11), seeded with every flag `true`. + +**Enforcement is not uniform, by design:** + +| Flag | Enforced? | Effect when `false` | +|---|---|---| +| `subcategoriesEnabled` | **Server-side** | `POST`/`PUT /items` with a non-null `subCategoryId` → `422 CONFIG_DISABLED` | +| `brandsEnabled` | **Server-side** | `POST`/`PUT /items` with a non-null `brandId` → `422 CONFIG_DISABLED` | +| `itemTypesEnabled` | **Advisory only** | Nothing server-side. Items carry no item-type reference (§2.7), so there is nothing on a write to reject — the frontend honours it by hiding the builder's type section. | + +Reads are **never** gated: switching a flag off leaves existing items readable with their subcategory/brand intact. + +#### `GET /product-config` → **200 OK** (+ `ETag`) +```json +{ "subcategoriesEnabled": true, "brandsEnabled": true, "itemTypesEnabled": true, + "updatedAt": "2026-07-16T10:00:00Z", "updatedBy": 17 } +``` + +#### `PUT /product-config` +Requires `If-Match`. All three flags are **required** — a partial body is a `400`, so a feature can never be switched off by omission. `updatedBy` is derived from the token, never posted. +```json +{ "subcategoriesEnabled": false, "brandsEnabled": true, "itemTypesEnabled": true } +``` +**200 OK** → the updated resource; `412` on ETag mismatch. + +> **Authorization:** writes are admitted by the ERP door policy only — any authenticated ERP user may flip these flags. A `CONFIG_MANAGE` permission is **reserved** for when per-endpoint RBAC lands (FR-X-01, deferred); no schema or route change will be needed to enable it. Tracked as open decision #13 in docs/10 §B.8.4. + --- ## 3. Procurement @@ -471,6 +605,7 @@ Items at/below ROP (FR-STK-10); computed on read, no stored entity. | `EXPIRED_BATCH_BLOCKED` | 409 | Issue/pick of an expired batch. | | `ONHOLD_NOT_ISSUABLE` | 409 | Issue against on-hold/quarantined stock. | | `REASON_CODE_REQUIRED` | 400 | Adjustment/return without a reason code. | +| `CONFIG_DISABLED` | 422 | An item write carries a field whose feature is switched off in the product configuration (`subCategoryId` with subcategories disabled, `brandId` with brands disabled). See §2.8. | | `CONCURRENCY_CONFLICT` | 412 | ETag / RowVersion mismatch. | | `IDEMPOTENCY_REPLAY` | 200 | Duplicate `Idempotency-Key`; original result returned. | @@ -488,7 +623,7 @@ Example (`409`, `application/problem+json`): ## 8. Enumerations | Enum | Values | |---|---| -| `itemType` | `Stocked`, `NonStocked`, `Service` | +| `stockNature` | `Stocked`, `NonStocked`, `Service` — **renamed from `itemType`** (2026-07-16). Item *types* (Color/Size/Material) are now master **data**, not an enum: see §2.7. | | `trackingMode` | `None`, `Batch`, `Serial` | | `holdStatus` | `Available`, `OnHold`, `Rejected` | | `direction` (ledger) | `In`, `Out` | diff --git a/docs/20-FRONTEND.md b/docs/20-FRONTEND.md index 3c4167c..e77ccda 100644 --- a/docs/20-FRONTEND.md +++ b/docs/20-FRONTEND.md @@ -126,9 +126,22 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a | Count | Count | `POST /stock-counts`, `PUT /stock-counts/{id}/counts`, `POST /stock-counts/{id}/post` | ### 2.2 Master data screens (supporting, outside the core flow) -Vendors, Items, Categories, UOM, Warehouses, Brands, and Variant Categories are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls. +Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Types are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls. -**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) are UI-only additions with no corresponding endpoint in `11-BACKEND-PHASE1.md` — Item's `brandId` is built the same way. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. Flag Brand/Variant Category to whoever owns the backend contract if they should become real entities rather than staying frontend-only; see `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations. +> **2026-07-16 — these are real backend entities now; the UI has NOT caught up.** Brand, Subcategory, Item Type (the frontend's "Variant Categories") and a Product Configuration gate were built on the backend (`docs/11 §2.3/2.6/2.7/2.8`). The screens below still run on `lib/api/mock-data.ts` and do not call any of it. Reconciling them is outstanding frontend work — the contract drift is listed in §2.2.1. + +**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) began as UI-only additions with no backend. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. See `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations. + +#### 2.2.1 Contract drift to reconcile (backend is authoritative — §1) +- **`variantCategoriesApi` → `GET /item-types`.** Same shape (name-only list), new name. `variantCategoryId` → `itemTypeId`. +- **`Item.itemType` → `stockNature`.** The `Stocked|NonStocked|Service` field was renamed. `itemType` now means something else entirely (Color/Size), so this rename is not cosmetic — read `docs/11 §2.7` before touching it. +- **Send both category FKs.** `effectiveCategoryId = subCategoryId ?? categoryId` must become `categoryId` **and** `subCategoryId`; the server rejects a subcategory that doesn't belong to the category (422). Subcategories are their own resource now, not `Category.parentId`, and **`GET /categories?tree=true` no longer exists**. +- **Colour hex-packing stays frontend-only.** There is no value table, so `"Red|#EF4444"`, `encodeColorValue`/`decodeColorValue`/`isColorCategory` have nothing to reconcile against — keep them. +- **SKU generation stays client-side** (`buildVariantSku`) and is now the *only* record of which colour/size an item is; the server only uniqueness-checks it. Nothing can query items by colour. +- **`remove()` must become `PATCH /{id}/status`.** There are no `DELETE` endpoints on any master (FR-MD-08) — the mock's unconditional delete has no backend equivalent. +- **`initialQty` remains unbacked** — no Stock Core wiring; still informational-only. +- **New: Product Configuration** (`GET`/`PUT /product-config`) gates subcategories/brands/item-types. This is the backend for the toggle screen; note **only 3 of that design's ~13 toggles exist**, and `itemTypesEnabled` is advisory — the frontend is what honours it (`docs/11 §2.8`). No `Switch` primitive exists in `components/ui/` yet. +- **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. Real HTTP calls will make this failure mode visible in a way mock data never did. --- @@ -142,7 +155,7 @@ Vendors, Items, Categories, UOM, Warehouses, Brands, and Variant Categories are - Format: SKU pattern, numeric fields numeric, date format, positive integers. - Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`. - Simple cross-field input rules: transfer `destWarehouseId != srcWarehouseId`. -- Enum membership via constrained dropdowns (`itemType`, `trackingMode`, `countType`, `holdStatus`). +- Enum membership via constrained dropdowns (`stockNature` — ex-`itemType`, `trackingMode`, `countType`, `holdStatus`). Note the **Item Type** dropdown is *not* in this category: it's server data (`GET /item-types`), not an enum. **Server-authoritative (client MUST NOT assume — only the server can judge):** anything depending on current server state. - **Stock availability / negative-stock block** (depends on live ledger) — `STOCK_NEGATIVE_BLOCKED`.