completed invetory updates
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||||
|
[Route("api/v1/brands")]
|
||||||
|
public sealed class BrandsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBrandService _brands;
|
||||||
|
|
||||||
|
public BrandsController(IBrandService brands) => _brands = brands;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<BrandDto>>> 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<ActionResult<BrandDto>> 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<ActionResult<BrandDto>> 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<ActionResult<BrandDto>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{brandId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _brands.SetStatusAsync(brandId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Categories;
|
using ERPCore.Dtos.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||||
|
/// </summary>
|
||||||
[Route("api/v1/categories")]
|
[Route("api/v1/categories")]
|
||||||
public sealed class CategoriesController : ApiControllerBase
|
public sealed class CategoriesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase
|
|||||||
|
|
||||||
public CategoriesController(ICategoryService categories) => _categories = categories;
|
public CategoriesController(ICategoryService categories) => _categories = categories;
|
||||||
|
|
||||||
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
|
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
|
||||||
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
|
=> Ok(await _categories.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{categoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CategoryDto>> 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]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var dto = await _categories.CreateAsync(request, ct);
|
var result = await _categories.CreateAsync(request, ct);
|
||||||
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
|
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<ActionResult<CategoryDto>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{categoryId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> 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<SubCategoryDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> 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<ActionResult<SubCategoryDto>> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
|
||||||
|
/// dimension names. <c>GET</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/item-types")]
|
||||||
|
public sealed class ItemTypesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IItemTypeService _itemTypes;
|
||||||
|
|
||||||
|
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
|
||||||
|
|
||||||
|
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> 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<ActionResult<ItemTypeDto>> 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<ActionResult<ItemTypeDto>> 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<ActionResult<ItemTypeDto>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{itemTypeId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,9 +21,11 @@ public sealed class ItemsController : ApiControllerBase
|
|||||||
[FromQuery] PageQuery query,
|
[FromQuery] PageQuery query,
|
||||||
[FromQuery] EntityStatus? status,
|
[FromQuery] EntityStatus? status,
|
||||||
[FromQuery] int? categoryId,
|
[FromQuery] int? categoryId,
|
||||||
|
[FromQuery] int? subCategoryId,
|
||||||
|
[FromQuery] int? brandId,
|
||||||
[FromQuery] TrackingMode? trackingMode,
|
[FromQuery] TrackingMode? trackingMode,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
|
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||||
|
|
||||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||||
[HttpGet("{itemId:int}")]
|
[HttpGet("{itemId:int}")]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
|
||||||
|
/// feature gate for subcategories/brands/item-types.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
|
||||||
|
/// A dedicated <c>CONFIG_MANAGE</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[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<ActionResult<ProductConfigDto>> 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<ActionResult<ProductConfigDto>> Update(
|
||||||
|
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using ERPCore.Dtos.Categories;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
|
||||||
|
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
|
||||||
|
/// since a subcategory only exists in the context of one.
|
||||||
|
/// </summary>
|
||||||
|
[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<ActionResult<SubCategoryDto>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
|
||||||
|
[HttpPut("{subCategoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<SubCategoryDto>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{subCategoryId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(
|
||||||
|
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
|
||||||
|
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||||
|
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
|
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
|
||||||
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
|
||||||
|
/// <c>parent_id</c> tree was replaced in migration #2).
|
||||||
|
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||||
|
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Category
|
public class Category
|
||||||
{
|
{
|
||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
public int? ParentId { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public Category? Parent { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public ICollection<Category> Children { get; set; } = new List<Category>();
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ public class Item
|
|||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public Category? Category { get; set; }
|
public Category? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
|
||||||
|
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 int BaseUomId { get; set; }
|
||||||
public Uom? BaseUom { get; set; }
|
public Uom? BaseUom { get; set; }
|
||||||
|
|
||||||
public int? DefaultVendorId { get; set; }
|
public int? DefaultVendorId { get; set; }
|
||||||
public Vendor? DefaultVendor { get; set; }
|
public Vendor? DefaultVendor { get; set; }
|
||||||
|
|
||||||
public ItemType ItemType { get; set; }
|
public StockNature StockNature { get; set; }
|
||||||
public TrackingMode TrackingMode { get; set; }
|
public TrackingMode TrackingMode { get; set; }
|
||||||
public string? TaxClass { get; set; }
|
public string? TaxClass { get; set; }
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
|
||||||
|
/// Material.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||||
|
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||||
|
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||||
|
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||||
|
/// (e.g. <c>BL-100-0003</c>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||||
|
/// which is what the old <c>ItemType</c> enum became.
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
|
||||||
|
/// gating optional product master-data features.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
||||||
|
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
|
||||||
|
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
|
||||||
|
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ProductConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 — the singleton row's id.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subcategory — the single optional level below <see cref="Category"/> (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
|
||||||
|
/// <see cref="Item.SubCategoryId"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Item classification (FR-MD-01). Values match the <c>itemType</c> enum in
|
|
||||||
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
|
||||||
/// </summary>
|
|
||||||
public enum ItemType
|
|
||||||
{
|
|
||||||
Stocked,
|
|
||||||
NonStocked,
|
|
||||||
Service
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an item holds stock (FR-MD-01). Values match the <c>stockNature</c> enum in
|
||||||
|
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
||||||
|
/// Renamed from <c>ItemType</c> so that name could be taken by the ItemType master
|
||||||
|
/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9).
|
||||||
|
/// </summary>
|
||||||
|
public enum StockNature
|
||||||
|
{
|
||||||
|
Stocked,
|
||||||
|
NonStocked,
|
||||||
|
Service
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Brands;
|
||||||
|
|
||||||
|
/// <summary>Brand resource (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -1,15 +1,56 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Dtos.Categories;
|
namespace ERPCore.Dtos.Categories;
|
||||||
|
|
||||||
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
|
||||||
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
|
// 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.
|
||||||
|
|
||||||
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
|
/// <summary>Category resource — the top level.</summary>
|
||||||
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
|
public sealed record CategoryDto(
|
||||||
|
int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>Subcategory resource — the single optional level below a category.</summary>
|
||||||
|
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
|
public sealed class CreateCategoryRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Body for <c>POST /categories/{categoryId}/subcategories</c>; the parent comes from the route.</summary>
|
||||||
|
public sealed class CreateSubCategoryRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body for <c>PUT /subcategories/{id}</c>. Name only — a subcategory cannot be reparented,
|
||||||
|
/// since moving one would silently invalidate the category of every item referencing it.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
|
||||||
|
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductConfigDto(
|
||||||
|
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||||
|
DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||||
|
/// <para>
|
||||||
|
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
|
||||||
|
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
|
||||||
|
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
|
||||||
|
/// actually bind — an omitted flag is a 400, not an accidental disable.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateProductConfigRequest
|
||||||
|
{
|
||||||
|
[Required] public bool? SubcategoriesEnabled { get; set; }
|
||||||
|
[Required] public bool? BrandsEnabled { get; set; }
|
||||||
|
[Required] public bool? ItemTypesEnabled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.ItemTypes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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: <c>GET /item-types</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ namespace ERPCore.Dtos.Items;
|
|||||||
|
|
||||||
/// <summary>Row shape for <c>GET /items</c>.</summary>
|
/// <summary>Row shape for <c>GET /items</c>.</summary>
|
||||||
public sealed record ItemListItemDto(
|
public sealed record ItemListItemDto(
|
||||||
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
|
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||||
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||||
string? TaxClass, EntityStatus Status);
|
string? TaxClass, EntityStatus Status);
|
||||||
|
|
||||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||||
@@ -17,7 +17,8 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
|
|||||||
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
|
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
|
||||||
public sealed record ItemDetailDto(
|
public sealed record ItemDetailDto(
|
||||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
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<ItemReorderDto> Reorder,
|
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
@@ -33,15 +34,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settin
|
|||||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
// 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
|
public sealed class CreateItemRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(1000)] public string? Description { get; set; }
|
[StringLength(1000)] public string? Description { get; set; }
|
||||||
[Required] public int CategoryId { get; set; }
|
[Required] public int CategoryId { get; set; }
|
||||||
|
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
/// <summary>Optional. Rejected when brands are disabled.</summary>
|
||||||
|
public int? BrandId { get; set; }
|
||||||
[Required] public int BaseUomId { get; set; }
|
[Required] public int BaseUomId { get; set; }
|
||||||
public int? DefaultVendorId { 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;
|
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||||
[StringLength(20)] public string? TaxClass { get; set; }
|
[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;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(1000)] public string? Description { get; set; }
|
[StringLength(1000)] public string? Description { get; set; }
|
||||||
[Required] public int CategoryId { get; set; }
|
[Required] public int CategoryId { get; set; }
|
||||||
|
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
/// <summary>Optional. Rejected when brands are disabled.</summary>
|
||||||
|
public int? BrandId { get; set; }
|
||||||
[Required] public int BaseUomId { get; set; }
|
[Required] public int BaseUomId { get; set; }
|
||||||
public int? DefaultVendorId { 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;
|
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||||
[StringLength(20)] public string? TaxClass { get; set; }
|
[StringLength(20)] public string? TaxClass { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Brand>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Brand> 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<string>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
@@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
|
|||||||
builder.HasKey(c => c.CategoryId);
|
builder.HasKey(c => c.CategoryId);
|
||||||
|
|
||||||
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||||
|
builder.HasIndex(c => c.Name).IsUnique();
|
||||||
|
|
||||||
builder.HasOne(c => c.Parent)
|
builder.Property(c => c.Status)
|
||||||
.WithMany(c => c.Children)
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
.HasForeignKey(c => c.ParentId)
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
builder.Property(i => i.Description).HasMaxLength(1000);
|
builder.Property(i => i.Description).HasMaxLength(1000);
|
||||||
builder.Property(i => i.TaxClass).HasMaxLength(20);
|
builder.Property(i => i.TaxClass).HasMaxLength(20);
|
||||||
|
|
||||||
builder.Property(i => i.ItemType)
|
builder.Property(i => i.StockNature)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(i => i.TrackingMode)
|
builder.Property(i => i.TrackingMode)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
@@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
.HasForeignKey(i => i.CategoryId)
|
.HasForeignKey(i => i.CategoryId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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)
|
builder.HasOne(i => i.BaseUom)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(i => i.BaseUomId)
|
.HasForeignKey(i => i.BaseUomId)
|
||||||
@@ -49,5 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
|
|
||||||
builder.HasIndex(i => i.Status);
|
builder.HasIndex(i => i.Status);
|
||||||
builder.HasIndex(i => i.CategoryId);
|
builder.HasIndex(i => i.CategoryId);
|
||||||
|
builder.HasIndex(i => i.BrandId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no
|
||||||
|
/// relationships here — nothing references this table (docs/10 Part C.9).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ItemType> 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<string>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the singleton product-configuration row (FR-MD-11). The check constraint
|
||||||
|
/// is what makes "singleton" a database guarantee rather than a convention.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<ProductConfig>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProductConfig> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SubCategory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SubCategory> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("subcategories");
|
||||||
|
builder.HasKey(s => s.SubCategoryId);
|
||||||
|
|
||||||
|
builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(s => s.Status)
|
||||||
|
.HasConversion<string>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DataSeeder
|
public static class DataSeeder
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] StandardItemTypes = ["Color", "Size"];
|
||||||
|
|
||||||
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
|
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
|
||||||
[
|
[
|
||||||
("DMG", "Damage", ReasonContext.Adjustment),
|
("DMG", "Damage", ReasonContext.Adjustment),
|
||||||
@@ -26,6 +33,15 @@ public static class DataSeeder
|
|||||||
];
|
];
|
||||||
|
|
||||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
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<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existing = await db.ReasonCodes
|
var existing = await db.ReasonCodes
|
||||||
.Select(r => new { r.Context, r.Code })
|
.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 })
|
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (toAdd.Count == 0) return;
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
db.ReasonCodes.AddRange(toAdd);
|
db.ReasonCodes.AddRange(toAdd);
|
||||||
await db.SaveChangesAsync(ct);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace ERPCore.Infra.Persistence;
|
namespace ERPCore.Infra.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
||||||
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
||||||
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
|
/// 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.
|
/// 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) ---
|
// --- Master Data (docs/10 Part C.1) ---
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
|
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||||
|
public DbSet<Brand> Brands => Set<Brand>();
|
||||||
|
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
|
||||||
|
public DbSet<ItemType> ItemTypes => Set<ItemType>();
|
||||||
public DbSet<Uom> Uoms => Set<Uom>();
|
public DbSet<Uom> Uoms => Set<Uom>();
|
||||||
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
||||||
public DbSet<Item> Items => Set<Item>();
|
public DbSet<Item> Items => Set<Item>();
|
||||||
@@ -30,6 +34,8 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||||
public DbSet<Bin> Bins => Set<Bin>();
|
public DbSet<Bin> Bins => Set<Bin>();
|
||||||
|
/// <summary>Singleton row (FR-MD-11).</summary>
|
||||||
|
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||||
|
|
||||||
// --- Cross-cutting (docs/10 Part C.7) ---
|
// --- Cross-cutting (docs/10 Part C.7) ---
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
|
|||||||
+2454
File diff suppressed because it is too large
Load Diff
+442
@@ -0,0 +1,442 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// <para>
|
||||||
|
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
|
||||||
|
/// <c>categories.ParentId</c> 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 <c>subcategories</c> and repoint items
|
||||||
|
/// onto the correct (category, subcategory) pair before the column goes away.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int>(
|
||||||
|
name: "BrandId",
|
||||||
|
table: "items",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
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<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "categories",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Active");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "categories",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<uint>(
|
||||||
|
name: "xmin",
|
||||||
|
table: "categories",
|
||||||
|
type: "xid",
|
||||||
|
rowVersion: true,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0u);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "brands",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BrandId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(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<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(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<int>(type: "integer", nullable: false),
|
||||||
|
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(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<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverses the schema change and puts the subcategory data back where it came from.
|
||||||
|
/// <para>
|
||||||
|
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
|
||||||
|
/// discarded exactly what <see cref="Up"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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<int>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("bins", (string)null);
|
b.ToTable("bins", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BrandId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BrandId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
@@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int?>("ParentId")
|
b.Property<uint>("RowVersion")
|
||||||
.HasColumnType("integer");
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("CategoryId");
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
b.HasIndex("ParentId");
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.ToTable("categories", (string)null);
|
b.ToTable("categories", (string)null);
|
||||||
});
|
});
|
||||||
@@ -273,6 +334,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Property<int>("BaseUomId")
|
b.Property<int>("BaseUomId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("BrandId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -286,11 +350,6 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
b.Property<string>("ItemType")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -314,6 +373,14 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(20)")
|
.HasColumnType("character varying(20)")
|
||||||
.HasDefaultValue("Active");
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<string>("StockNature")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int?>("SubCategoryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("TaxClass")
|
b.Property<string>("TaxClass")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
@@ -330,6 +397,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("BaseUomId");
|
b.HasIndex("BaseUomId");
|
||||||
|
|
||||||
|
b.HasIndex("BrandId");
|
||||||
|
|
||||||
b.HasIndex("CategoryId");
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
b.HasIndex("DefaultVendorId");
|
b.HasIndex("DefaultVendorId");
|
||||||
@@ -339,6 +408,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("SubCategoryId");
|
||||||
|
|
||||||
b.ToTable("items", (string)null);
|
b.ToTable("items", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,6 +445,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("item_reorders", (string)null);
|
b.ToTable("item_reorders", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ItemTypeId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemTypeId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("JournalId")
|
b.Property<int>("JournalId")
|
||||||
@@ -490,6 +603,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("po_lines", (string)null);
|
b.ToTable("po_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ConfigId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("BrandsEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("ItemTypesEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<bool>("SubcategoriesEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("PoId")
|
b.Property<int>("PoId")
|
||||||
@@ -1239,6 +1394,51 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("stock_transfer_lines", (string)null);
|
b.ToTable("stock_transfer_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SubCategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SubCategoryId"));
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("UomId")
|
b.Property<int>("UomId")
|
||||||
@@ -1516,16 +1716,6 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
@@ -1616,6 +1806,11 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Brand", "Brand")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BrandId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CategoryId")
|
.HasForeignKey("CategoryId")
|
||||||
@@ -1627,11 +1822,20 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasForeignKey("DefaultVendorId")
|
.HasForeignKey("DefaultVendorId")
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("BaseUom");
|
b.Navigation("BaseUom");
|
||||||
|
|
||||||
|
b.Navigation("Brand");
|
||||||
|
|
||||||
b.Navigation("Category");
|
b.Navigation("Category");
|
||||||
|
|
||||||
b.Navigation("DefaultVendor");
|
b.Navigation("DefaultVendor");
|
||||||
|
|
||||||
|
b.Navigation("SubCategory");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||||
@@ -1688,6 +1892,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
@@ -2092,6 +2306,17 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Transfer");
|
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 =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
||||||
@@ -2159,7 +2384,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Children");
|
b.Navigation("SubCategories");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
|||||||
builder.Services.AddScoped<IItemService, ItemService>();
|
builder.Services.AddScoped<IItemService, ItemService>();
|
||||||
builder.Services.AddScoped<IUomService, UomService>();
|
builder.Services.AddScoped<IUomService, UomService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
|
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||||
|
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||||
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency
|
||||||
|
/// per docs/11-BACKEND-PHASE1.md §2.6.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BrandService : IBrandService
|
||||||
|
{
|
||||||
|
private readonly IRepository<Brand> _brands;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public BrandService(IRepository<Brand> brands, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_brands = brands;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<BrandDto>> 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<BrandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<BrandDto>?> 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<BrandDto>(Map(brand), brand.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<BrandDto>> 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<BrandDto>(Map(brand), brand.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<BrandDto>> 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<BrandDto>(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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Categories;
|
using ERPCore.Dtos.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public sealed class CategoryService : ICategoryService
|
public sealed class CategoryService : ICategoryService
|
||||||
{
|
{
|
||||||
private readonly IRepository<Category> _categories;
|
private readonly IRepository<Category> _categories;
|
||||||
|
private readonly IRepository<SubCategory> _subCategories;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
|
public CategoryService(
|
||||||
|
IRepository<Category> categories,
|
||||||
|
IRepository<SubCategory> subCategories,
|
||||||
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_categories = categories;
|
_categories = categories;
|
||||||
|
_subCategories = subCategories;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
// Categories ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public async Task<PagedResponse<CategoryDto>> ListAsync(
|
||||||
|
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _categories.Query().AsNoTracking();
|
var q = _categories.Query().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
|
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 total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(c => c.Name)
|
var rows = await q.OrderBy(c => c.Name)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
|
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var all = await _categories.Query().AsNoTracking()
|
var category = await _categories.Query().AsNoTracking()
|
||||||
.OrderBy(c => c.Name)
|
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
|
||||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
|
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var byParent = all.ToLookup(c => c.ParentId);
|
|
||||||
|
|
||||||
List<CategoryTreeDto> Build(int? parentId) =>
|
|
||||||
byParent[parentId]
|
|
||||||
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Build(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (request.ParentId is not null
|
var name = request.Name.Trim();
|
||||||
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
|
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
|
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 _categories.AddAsync(category, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
|
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<CategoryDto>> 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<CategoryDto>(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<PagedResponse<SubCategoryDto>> 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<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SubCategoryDto>?> 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<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SubCategoryDto>> 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<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SubCategoryDto>> 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<SubCategoryDto>(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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Brands;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||||
|
public interface IBrandService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
|
Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<BrandDto>> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Categories;
|
using ERPCore.Dtos.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
/// <summary>Category master business logic (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public interface ICategoryService
|
public interface ICategoryService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
|
Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default);
|
||||||
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<CategoryDto>> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Subcategories of one category. 404s when the category itself does not exist.</summary>
|
||||||
|
Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||||
|
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
public interface IItemService
|
public interface IItemService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<ItemListItemDto>> ListAsync(
|
Task<PagedResponse<ItemListItemDto>> 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<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.ItemTypes;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IItemTypeService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ItemTypeDto>> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Product configuration business logic (docs/11-BACKEND-PHASE1.md §2.8). Singleton.</summary>
|
||||||
|
public interface IProductConfigService
|
||||||
|
{
|
||||||
|
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||||
|
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -13,36 +13,52 @@ namespace ERPCore.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
|
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
|
||||||
/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per
|
/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic
|
||||||
/// docs/11-BACKEND-PHASE1.md §2.1–2.2 and 02-SECURITY C.1.
|
/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.1–2.2
|
||||||
|
/// and 02-SECURITY C.1.
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ItemService : IItemService
|
public sealed class ItemService : IItemService
|
||||||
{
|
{
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Category> _categories;
|
private readonly IRepository<Category> _categories;
|
||||||
|
private readonly IRepository<SubCategory> _subCategories;
|
||||||
|
private readonly IRepository<Brand> _brands;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<Vendor> _vendors;
|
private readonly IRepository<Vendor> _vendors;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IProductConfigService _config;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public ItemService(
|
public ItemService(
|
||||||
IRepository<Item> items,
|
IRepository<Item> items,
|
||||||
IRepository<Category> categories,
|
IRepository<Category> categories,
|
||||||
|
IRepository<SubCategory> subCategories,
|
||||||
|
IRepository<Brand> brands,
|
||||||
IRepository<Uom> uoms,
|
IRepository<Uom> uoms,
|
||||||
IRepository<Vendor> vendors,
|
IRepository<Vendor> vendors,
|
||||||
IRepository<Warehouse> warehouses,
|
IRepository<Warehouse> warehouses,
|
||||||
|
IProductConfigService config,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_items = items;
|
_items = items;
|
||||||
_categories = categories;
|
_categories = categories;
|
||||||
|
_subCategories = subCategories;
|
||||||
|
_brands = brands;
|
||||||
_uoms = uoms;
|
_uoms = uoms;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
|
_config = config;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
|
public async Task<PagedResponse<ItemListItemDto>> 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();
|
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 (status is not null) q = q.Where(i => i.Status == status);
|
||||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
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);
|
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(i => i.Sku)
|
var rows = await q.OrderBy(i => i.Sku)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(i => new ItemListItemDto(
|
.Select(i => new ItemListItemDto(
|
||||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
|
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||||
i.ItemType, i.TrackingMode, i.TaxClass, i.Status))
|
i.BaseUomId, i.DefaultVendorId,
|
||||||
|
i.StockNature, i.TrackingMode, i.TaxClass, i.Status))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<ItemListItemDto>.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))
|
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);
|
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
|
var item = new Item
|
||||||
{
|
{
|
||||||
@@ -88,9 +109,11 @@ public sealed class ItemService : IItemService
|
|||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
CategoryId = request.CategoryId,
|
CategoryId = request.CategoryId,
|
||||||
|
SubCategoryId = request.SubCategoryId,
|
||||||
|
BrandId = request.BrandId,
|
||||||
BaseUomId = request.BaseUomId,
|
BaseUomId = request.BaseUomId,
|
||||||
DefaultVendorId = request.DefaultVendorId,
|
DefaultVendorId = request.DefaultVendorId,
|
||||||
ItemType = request.ItemType,
|
StockNature = request.StockNature,
|
||||||
TrackingMode = request.TrackingMode,
|
TrackingMode = request.TrackingMode,
|
||||||
TaxClass = request.TaxClass,
|
TaxClass = request.TaxClass,
|
||||||
Status = EntityStatus.Active,
|
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))
|
&& 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);
|
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.Sku = request.Sku.Trim();
|
||||||
item.Name = request.Name.Trim();
|
item.Name = request.Name.Trim();
|
||||||
item.Description = request.Description;
|
item.Description = request.Description;
|
||||||
item.CategoryId = request.CategoryId;
|
item.CategoryId = request.CategoryId;
|
||||||
|
item.SubCategoryId = request.SubCategoryId;
|
||||||
|
item.BrandId = request.BrandId;
|
||||||
item.BaseUomId = request.BaseUomId;
|
item.BaseUomId = request.BaseUomId;
|
||||||
item.DefaultVendorId = request.DefaultVendorId;
|
item.DefaultVendorId = request.DefaultVendorId;
|
||||||
item.ItemType = request.ItemType;
|
item.StockNature = request.StockNature;
|
||||||
item.TrackingMode = request.TrackingMode;
|
item.TrackingMode = request.TrackingMode;
|
||||||
item.TaxClass = request.TaxClass;
|
item.TaxClass = request.TaxClass;
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -240,11 +267,56 @@ public sealed class ItemService : IItemService
|
|||||||
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
/// <summary>
|
||||||
|
/// 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 <c>itemTypesEnabled</c> has nothing to reject here —
|
||||||
|
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
|
||||||
|
/// </summary>
|
||||||
|
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))
|
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
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))
|
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
|
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(
|
private static ItemDetailDto ToDetail(Item i) => new(
|
||||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
|
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||||
i.ItemType, i.TrackingMode, i.TaxClass, i.Status,
|
i.BaseUomId, i.DefaultVendorId,
|
||||||
|
i.StockNature, i.TrackingMode, i.TaxClass, i.Status,
|
||||||
i.ReorderSettings
|
i.ReorderSettings
|
||||||
.OrderBy(r => r.WarehouseId)
|
.OrderBy(r => r.WarehouseId)
|
||||||
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that
|
||||||
|
/// <c>GET /item-types</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ItemTypeService : IItemTypeService
|
||||||
|
{
|
||||||
|
private readonly IRepository<ItemType> _itemTypes;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public ItemTypeService(IRepository<ItemType> itemTypes, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_itemTypes = itemTypes;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<ItemTypeDto>> 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<ItemTypeDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ItemTypeDto>?> 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<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ItemTypeDto>> 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<ItemTypeDto>(Map(itemType), itemType.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ItemTypeDto>> 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<ItemTypeDto>(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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductConfigService : IProductConfigService
|
||||||
|
{
|
||||||
|
private readonly IRepository<ProductConfig> _config;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public ProductConfigService(IRepository<ProductConfig> config, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ProductConfigDto>> 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<ProductConfigDto>(Map(config), config.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<ProductConfigDto>> 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<ProductConfigDto>(Map(config), config.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||||
|
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ public static class ErrorCodes
|
|||||||
public const string ReasonCodeRequired = "REASON_CODE_REQUIRED";
|
public const string ReasonCodeRequired = "REASON_CODE_REQUIRED";
|
||||||
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
||||||
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
||||||
|
public const string ConfigDisabled = "CONFIG_DISABLED";
|
||||||
|
|
||||||
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
||||||
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
||||||
|
|||||||
+34
-3
@@ -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`)
|
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
|
||||||
|
|
||||||
## 1. Master Data
|
## 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.
|
> 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 (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
|
- [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] 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] 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] 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] 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] 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
|
## 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.
|
> 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.
|
- **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.
|
- **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.
|
- **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: "<username>"` 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.
|
||||||
|
|||||||
+1
-1
@@ -311,7 +311,7 @@ dotnet run
|
|||||||
- Swagger UI at `https://localhost:<port>/swagger`
|
- Swagger UI at `https://localhost:<port>/swagger`
|
||||||
- Health at `https://localhost:<port>/health` → `Healthy`
|
- Health at `https://localhost:<port>/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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+32
-10
@@ -1,6 +1,6 @@
|
|||||||
# 10 · BACKEND — Phase 1 Spec (Inventory & Supply Chain)
|
# 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`.
|
> **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.
|
> **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.
|
> **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)
|
### B.3.1 Master Data (FR-MD)
|
||||||
| ID | Requirement | Pri |
|
| 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-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-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-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-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-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-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)
|
### B.3.2 Procurement (FR-PROC)
|
||||||
| ID | Requirement | Pri |
|
| 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 |
|
| FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M |
|
||||||
|
|
||||||
## B.4 Data Model (summary)
|
## 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
|
## 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.
|
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 |
|
| 8 | Costing method | **Resolved:** FIFO |
|
||||||
| 9 | Tenancy | **Resolved:** single-tenant |
|
| 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. |
|
| 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.
|
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
|
## 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(uom_id PK, name)
|
||||||
UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor)
|
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,
|
ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable],
|
||||||
default_vendor_id FK→VENDOR, item_type, tracking_mode, tax_class, status)
|
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)
|
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)
|
VENDOR(vendor_id PK, code, name, terms, tax_reg, currency, status)
|
||||||
WAREHOUSE(warehouse_id PK, code, name)
|
WAREHOUSE(warehouse_id PK, code, name)
|
||||||
BIN(bin_id PK, warehouse_id FK→WAREHOUSE, code, bin_type)
|
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
|
## C.2 Procurement
|
||||||
```
|
```
|
||||||
REQUISITION(requisition_id PK, doc_no, requested_by FK→USER, status, created_at)
|
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)
|
## 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**.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## 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<T>`, one per entity) → `Infra/Persistence/Configurations`.
|
- EF configurations (`IEntityTypeConfiguration<T>`, one per entity) → `Infra/Persistence/Configurations`.
|
||||||
- FIFO logic → `Services/Stock/FifoCostingService` (Part A.2). Ledger writes only via stock services inside the UoW transaction.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+149
-14
@@ -105,38 +105,45 @@ session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered in
|
|||||||
`erp_rt` cookie rather than the request body.
|
`erp_rt` cookie rather than the request body.
|
||||||
|
|
||||||
### 2.1 Items
|
### 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`
|
#### `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**
|
**200 OK**
|
||||||
```json
|
```json
|
||||||
{ "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
|
{ "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
|
||||||
"categoryId": 12, "baseUomId": 1, "defaultVendorId": 5,
|
"categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5,
|
||||||
"itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ],
|
"stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ],
|
||||||
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
|
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `GET /items/{itemId}` → **200 OK** (header `ETag: "AAAAAAAAB9E="`)
|
#### `GET /items/{itemId}` → **200 OK** (header `ETag: "AAAAAAAAB9E="`)
|
||||||
```json
|
```json
|
||||||
{ "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
|
{ "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
|
||||||
"description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "baseUomId": 1,
|
"description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "subCategoryId": 30,
|
||||||
"defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
|
"brandId": 2, "baseUomId": 1,
|
||||||
|
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
|
||||||
"status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
|
"status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
|
||||||
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
|
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `POST /items`
|
#### `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
|
```json
|
||||||
{ "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut",
|
{ "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut",
|
||||||
"categoryId": 12, "baseUomId": 1, "defaultVendorId": 5,
|
"categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5,
|
||||||
"itemType": "Stocked", "trackingMode": "None", "taxClass": "STD" }
|
"stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD" }
|
||||||
```
|
```
|
||||||
**201 Created** — `Location: /api/v1/items/1002`
|
**201 Created** — `Location: /api/v1/items/1002`
|
||||||
```json
|
```json
|
||||||
{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, "baseUomId": 1,
|
{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12,
|
||||||
"defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "None", "taxClass": "STD",
|
"subCategoryId": 30, "brandId": 2, "baseUomId": 1,
|
||||||
|
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD",
|
||||||
"status": "Active", "createdAt": "2026-07-07T09:30:00Z" }
|
"status": "Active", "createdAt": "2026-07-07T09:30:00Z" }
|
||||||
```
|
```
|
||||||
`400` → `code: SKU_DUPLICATE` if SKU exists.
|
`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}`
|
#### `PUT /items/{itemId}`
|
||||||
Full update; requires `If-Match`. → **200 OK** updated resource; `412` on ETag mismatch.
|
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 } ] }
|
"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`
|
#### `POST /categories`
|
||||||
```json
|
```json
|
||||||
{ "name": "Fasteners", "parentId": 3 }
|
{ "name": "Fasteners" }
|
||||||
```
|
```
|
||||||
**201 Created** → `{ "categoryId": 12, "name": "Fasteners", "parentId": 3 }`
|
**201 Created** → `{ "categoryId": 12, "name": "Fasteners", "status": "Active", "createdAt": "...", "updatedAt": null }`
|
||||||
`GET /categories?tree=true` returns a nested tree.
|
`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
|
### 2.4 Vendors
|
||||||
#### `POST /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" }`
|
**201 Created** → `{ "binId": 45, "warehouseId": 1, "code": "A-01-01", "binType": "Shelf" }`
|
||||||
`GET /warehouses/{warehouseId}/bins` lists bins.
|
`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
|
## 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. |
|
| `EXPIRED_BATCH_BLOCKED` | 409 | Issue/pick of an expired batch. |
|
||||||
| `ONHOLD_NOT_ISSUABLE` | 409 | Issue against on-hold/quarantined stock. |
|
| `ONHOLD_NOT_ISSUABLE` | 409 | Issue against on-hold/quarantined stock. |
|
||||||
| `REASON_CODE_REQUIRED` | 400 | Adjustment/return without a reason code. |
|
| `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. |
|
| `CONCURRENCY_CONFLICT` | 412 | ETag / RowVersion mismatch. |
|
||||||
| `IDEMPOTENCY_REPLAY` | 200 | Duplicate `Idempotency-Key`; original result returned. |
|
| `IDEMPOTENCY_REPLAY` | 200 | Duplicate `Idempotency-Key`; original result returned. |
|
||||||
|
|
||||||
@@ -488,7 +623,7 @@ Example (`409`, `application/problem+json`):
|
|||||||
## 8. Enumerations
|
## 8. Enumerations
|
||||||
| Enum | Values |
|
| 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` |
|
| `trackingMode` | `None`, `Batch`, `Serial` |
|
||||||
| `holdStatus` | `Available`, `OnHold`, `Rejected` |
|
| `holdStatus` | `Available`, `OnHold`, `Rejected` |
|
||||||
| `direction` (ledger) | `In`, `Out` |
|
| `direction` (ledger) | `In`, `Out` |
|
||||||
|
|||||||
+16
-3
@@ -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` |
|
| 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)
|
### 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.
|
- Format: SKU pattern, numeric fields numeric, date format, positive integers.
|
||||||
- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`.
|
- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`.
|
||||||
- Simple cross-field input rules: transfer `destWarehouseId != srcWarehouseId`.
|
- 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.
|
**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`.
|
- **Stock availability / negative-stock block** (depends on live ledger) — `STOCK_NEGATIVE_BLOCKED`.
|
||||||
|
|||||||
Reference in New Issue
Block a user