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