Feat/be inventory #10

Merged
ashan_rusiru merged 2 commits from feat/be-inventory into Dev 2026-07-18 08:38:48 +00:00
145 changed files with 7375 additions and 3381 deletions
+31 -2
View File
@@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
/// <summary>
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
/// <para>
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
/// </para>
/// <para>
/// The cookies are cleared even if the upstream revoke fails or no user can be
/// resolved: a logout that leaves the caller holding a live session cookie is worse
/// than one that leaves a stale session server-side (which lapses on its own).
/// </para>
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
{
await _users.LogoutUserAsync(request, ct);
var userId = request?.UserId ?? ResolveTokenUserId();
if (userId is not null)
{
try
{
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
}
catch (DomainException)
{
// Upstream unreachable or already-revoked — fall through and clear anyway.
}
}
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
private Guid? ResolveTokenUserId()
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
[HttpPut("me")]
[ValidateCsrf]
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
@@ -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.Common;
using ERPCore.Services.Interfaces;
@@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc;
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")]
public sealed class CategoriesController : ApiControllerBase
{
@@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase
public CategoriesController(ICategoryService categories) => _categories = categories;
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListAsync(query, status, ct));
[HttpGet("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<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]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
{
var dto = await _categories.CreateAsync(request, ct);
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
var result = await _categories.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
}
[HttpPut("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<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);
}
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
public GrnsController(IGrnService grns) => _grns = grns;
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -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] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[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);
}
}
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, status, ct));
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
{
var dto = await _adjustments.GetAsync(adjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
+16 -2
View File
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
public StockCountsController(ICountService counts) => _counts = counts;
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -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();
}
}
+21
View File
@@ -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; }
}
+15 -5
View File
@@ -1,15 +1,25 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
/// below is <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>
public class Category
{
public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
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; }
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
}
+8 -1
View File
@@ -17,13 +17,20 @@ public class Item
public int CategoryId { 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 Uom? BaseUom { get; set; }
public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public ItemType ItemType { get; set; }
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -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; }
}
-12
View File
@@ -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
}
+5 -1
View File
@@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse
public sealed class LogoutRequest
{
[Required] public Guid UserId { get; set; }
/// <summary>
/// Optional: AuthHex returns no <c>userId</c> on login, so browsers cannot supply one.
/// When omitted, the controller resolves it from the session token's UserId claim.
/// </summary>
public Guid? UserId { get; set; }
}
+26
View File
@@ -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 ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Categories;
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
// The hierarchy is exactly two levels: Category → SubCategory. The former
// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in
// migration #2 — see docs/10 Part C.1.
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
/// <summary>Category resource — the top level.</summary>
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
{
[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; }
}
+5
View File
@@ -13,6 +13,11 @@ public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
@@ -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; }
}
+28 -6
View File
@@ -7,18 +7,28 @@ namespace ERPCore.Dtos.Items;
/// <summary>Row shape for <c>GET /items</c>.</summary>
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// <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.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// </para>
/// </summary>
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
@@ -33,15 +43,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settin
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g.
// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here
// by design — items carry no item-type reference (docs/10 Part C.9).
public sealed class CreateItemRequest
{
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
[Required] public int CategoryId { get; set; }
/// <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; }
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
@@ -52,9 +70,13 @@ public sealed class UpdateItemRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
[Required] public int CategoryId { get; set; }
/// <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; }
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
@@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record PurchaseReturnSummaryDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
@@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
public sealed record RfqSummaryDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
+7 -1
View File
@@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
+6 -1
View File
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
@@ -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.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
builder.HasKey(c => c.CategoryId);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(c => c.Name).IsUnique();
builder.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasIndex(c => c.ParentId);
builder.Property(c => c.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
builder.Property(i => i.ItemType)
builder.Property(i => i.StockNature)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
.HasConversion<string>().HasMaxLength(20).IsRequired();
@@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
.HasForeignKey(i => i.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.SubCategory)
.WithMany()
.HasForeignKey(i => i.SubCategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Brand)
.WithMany()
.HasForeignKey(i => i.BrandId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.BaseUom)
.WithMany()
.HasForeignKey(i => i.BaseUomId)
@@ -49,5 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.HasIndex(i => i.Status);
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>
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 =
[
("DMG", "Damage", ReasonContext.Adjustment),
@@ -26,6 +33,15 @@ public static class DataSeeder
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
{
var existing = await db.ReasonCodes
.Select(r => new { r.Context, r.Code })
@@ -37,9 +53,44 @@ public static class DataSeeder
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
.ToList();
if (toAdd.Count == 0) return;
if (toAdd.Count == 0) return false;
db.ReasonCodes.AddRange(toAdd);
await db.SaveChangesAsync(ct);
return true;
}
private static async Task<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;
/// <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
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
@@ -23,6 +23,10 @@ public class ErpDbContext : DbContext
// --- Master Data (docs/10 Part C.1) ---
public DbSet<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<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
@@ -30,6 +34,8 @@ public class ErpDbContext : DbContext
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
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) ---
public DbSet<User> Users => Set<User>();
@@ -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);
});
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 =>
{
b.Property<int>("CategoryId")
@@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("ParentId")
.HasColumnType("integer");
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("CategoryId");
b.HasIndex("ParentId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("categories", (string)null);
});
@@ -273,6 +334,9 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("BaseUomId")
.HasColumnType("integer");
b.Property<int?>("BrandId")
.HasColumnType("integer");
b.Property<int>("CategoryId")
.HasColumnType("integer");
@@ -286,11 +350,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -314,6 +373,14 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("StockNature")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int?>("SubCategoryId")
.HasColumnType("integer");
b.Property<string>("TaxClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
@@ -330,6 +397,8 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("BaseUomId");
b.HasIndex("BrandId");
b.HasIndex("CategoryId");
b.HasIndex("DefaultVendorId");
@@ -339,6 +408,8 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("Status");
b.HasIndex("SubCategoryId");
b.ToTable("items", (string)null);
});
@@ -374,6 +445,48 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("item_reorders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b =>
{
b.Property<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 =>
{
b.Property<int>("JournalId")
@@ -490,6 +603,48 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("po_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{
b.Property<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 =>
{
b.Property<int>("PoId")
@@ -1239,6 +1394,51 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("stock_transfer_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{
b.Property<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 =>
{
b.Property<int>("UomId")
@@ -1516,16 +1716,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -1616,6 +1806,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Brand", "Brand")
.WithMany()
.HasForeignKey("BrandId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
@@ -1627,11 +1822,20 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasForeignKey("DefaultVendorId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory")
.WithMany()
.HasForeignKey("SubCategoryId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("BaseUom");
b.Navigation("Brand");
b.Navigation("Category");
b.Navigation("DefaultVendor");
b.Navigation("SubCategory");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
@@ -1688,6 +1892,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -2092,6 +2306,17 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Transfer");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany("SubCategories")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Category");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
@@ -2159,7 +2384,7 @@ namespace ERPCore.Infra.Persistence.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("Children");
b.Navigation("SubCategories");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
+3
View File
@@ -62,6 +62,9 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IUomService, UomService>();
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<IWarehouseService, WarehouseService>();
+54 -8
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null;
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
return (entity, refs);
}, ct);
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs);
}
@@ -55,8 +55,11 @@ public sealed class AuthUserService : IAuthUserService
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
/// <summary>The controller resolves the id (from body or token claim) before calling here.</summary>
public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
=> _authHex.LogoutUserAsync(request.UserId, ct);
=> request.UserId is null
? Task.CompletedTask
: _authHex.LogoutUserAsync(request.UserId.Value, ct);
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
+114
View File
@@ -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);
}
+181 -23
View File
@@ -1,4 +1,6 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Infra.UoW;
@@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <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
{
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IUnitOfWork _uow;
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
public CategoryService(
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IUnitOfWork uow)
{
_categories = categories;
_subCategories = subCategories;
_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();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<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()
.OrderBy(c => c.Name)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.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);
var category = await _categories.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
}
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
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
var name = request.Name.Trim();
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
throw new ConflictException($"A category named '{name}' already exists.");
var category = new Category
{
Name = name,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
await _categories.AddAsync(category, ct);
await _uow.SaveChangesAsync(ct);
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
return new ETagged<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);
}
+26 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
_uow = uow;
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
@@ -175,7 +200,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+27
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
_uow = uow;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -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.Common;
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
{
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<CategoryDto>?> GetAsync(int categoryId, 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);
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
public interface ICountService
{
Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces;
public interface IItemService
{
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);
@@ -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);
}
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
public interface IPurchaseReturnService
{
Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
public interface IRequisitionService
{
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default);
Task<RequisitionDto?> GetAsync(int requisitionId, CancellationToken ct = default);
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
Task<RequisitionDto> SubmitAsync(int requisitionId, CancellationToken ct = default);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces;
/// <summary>RFQ &amp; vendor-quotation business logic (docs/11 §3.2).</summary>
public interface IRfqService
{
Task<PagedResponse<RfqSummaryDto>> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default);
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
Task<VendorQuotationDto> AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default);
@@ -8,8 +8,13 @@ public interface IStockService
{
Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
public interface ITransferService
{
Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
+91 -12
View File
@@ -13,36 +13,52 @@ namespace ERPCore.Services;
/// <summary>
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per
/// docs/11-BACKEND-PHASE1.md §2.12.2 and 02-SECURITY C.1.
/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic
/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.12.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>
public sealed class ItemService : IItemService
{
private readonly IRepository<Item> _items;
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IRepository<Brand> _brands;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IProductConfigService _config;
private readonly IUnitOfWork _uow;
public ItemService(
IRepository<Item> items,
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IRepository<Brand> brands,
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IProductConfigService config,
IUnitOfWork uow)
{
_items = items;
_categories = categories;
_subCategories = subCategories;
_brands = brands;
_uoms = uoms;
_vendors = vendors;
_warehouses = warehouses;
_config = config;
_uow = uow;
}
public async Task<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();
@@ -53,14 +69,17 @@ public sealed class ItemService : IItemService
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status))
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
@@ -70,6 +89,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -80,7 +100,9 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
var item = new Item
{
@@ -88,9 +110,11 @@ public sealed class ItemService : IItemService
Name = request.Name.Trim(),
Description = request.Description,
CategoryId = request.CategoryId,
SubCategoryId = request.SubCategoryId,
BrandId = request.BrandId,
BaseUomId = request.BaseUomId,
DefaultVendorId = request.DefaultVendorId,
ItemType = request.ItemType,
StockNature = request.StockNature,
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
Status = EntityStatus.Active,
@@ -108,6 +132,7 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -118,15 +143,19 @@ public sealed class ItemService : IItemService
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
item.CategoryId = request.CategoryId;
item.SubCategoryId = request.SubCategoryId;
item.BrandId = request.BrandId;
item.BaseUomId = request.BaseUomId;
item.DefaultVendorId = request.DefaultVendorId;
item.ItemType = request.ItemType;
item.StockNature = request.StockNature;
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.UpdatedAt = DateTime.UtcNow;
@@ -240,11 +269,56 @@ public sealed class ItemService : IItemService
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
}
private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct)
/// <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))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
if (subCategoryId is not null)
{
if (!config.SubcategoriesEnabled)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Subcategories are disabled in the product configuration; subCategoryId must be null.", 422);
var sub = await _subCategories.Query().AsNoTracking()
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
if (sub is null)
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
if (sub.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422);
// The two FKs must agree, or the item would claim a category its subcategory
// does not belong to.
if (sub.CategoryId != categoryId)
throw new DomainException(
ErrorCodes.Validation,
$"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422);
}
if (brandId is not null)
{
if (!config.BrandsEnabled)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Brands are disabled in the product configuration; brandId must be null.", 422);
var brand = await _brands.Query().AsNoTracking()
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
if (brand is null)
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
if (brand.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422);
}
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
@@ -272,11 +346,16 @@ public sealed class ItemService : IItemService
}
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status,
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
+118
View File
@@ -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);
}
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
@@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _returns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.ReturnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new PurchaseReturnSummaryDto(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
// Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
}
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
}, ct);
// Map ledger ids after commit so they are populated.
return new PurchaseReturnDto(
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
entity.CreatedBy,
entity.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerEntries.Select(r => r.LedgerId).ToList());
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
}
private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerRefs);
}
@@ -31,7 +31,8 @@ public sealed class RequisitionService : IRequisitionService
_uow = uow;
}
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default)
{
var q = _requisitions.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RequisitionId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt))
.Select(r => new RequisitionSummaryDto(
r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
+26
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
@@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService
_uow = uow;
}
public async Task<PagedResponse<RfqSummaryDto>> ListAsync(
PageQuery query, RfqStatus? status, CancellationToken ct = default)
{
var q = _rfqs.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.RfqId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new RfqSummaryDto(
r.RfqId, r.DocNo, r.RequisitionId, r.Status,
r.Lines.Count,
// Correlated subquery: there is no Rfq.Quotations navigation to count.
_quotations.Query().Count(qt => qt.RfqId == r.RfqId)))
.ToListAsync(ct);
return PagedResponse<RfqSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default)
{
var rfq = await _rfqs.Query().AsNoTracking()
+72 -1
View File
@@ -50,14 +50,85 @@ public sealed class StockService : IStockService
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
}
/// <summary>
/// On-hand across every (item, warehouse) pair that holds stock — backs the Stock
/// Enquiry list. Deliberately set-based: four grouped queries regardless of page size,
/// rather than calling <see cref="GetOnHandAsync"/> per row (which would be N+1).
/// Pairs are sourced from <c>StockLayer</c>, so an item that never had a receipt in a
/// warehouse simply does not appear.
/// </summary>
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default)
{
var layers = _layers.Query().AsNoTracking();
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
var grouped = layers
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, OnHand = g.Sum(x => x.QtyRemaining) });
var total = await grouped.CountAsync(ct);
var page = await grouped
.OrderBy(x => x.ItemId).ThenBy(x => x.WarehouseId)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
if (page.Count == 0)
return PagedResponse<StockOnHandDto>.Create([], query.Page, query.PageSize, total);
// Filtering by the page's ids gives a superset (the cross-product of both lists);
// the join below narrows it back to the actual pairs.
var itemIds = page.Select(p => p.ItemId).Distinct().ToList();
var warehouseIds = page.Select(p => p.WarehouseId).Distinct().ToList();
var onHold = (await _layers.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
.GroupBy(l => new { l.ItemId, l.WarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
var inTransit = (await _transferLines.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId)
&& l.Transfer != null
&& warehouseIds.Contains(l.Transfer.SrcWarehouseId)
&& l.Transfer.Status == TransferStatus.InTransit)
.GroupBy(l => new { l.ItemId, WarehouseId = l.Transfer!.SrcWarehouseId })
.Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.Qty - x.QtyReceived) })
.ToListAsync(ct))
.ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty);
var asOf = DateTime.UtcNow;
var rows = page.Select(p =>
{
var key = (p.ItemId, p.WarehouseId);
var hold = onHold.GetValueOrDefault(key);
var transit = inTransit.GetValueOrDefault(key);
const decimal reserved = 0m;
// Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted.
return new StockOnHandDto(
p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf);
}).ToList();
return PagedResponse<StockOnHandDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
// Source-doc filter: the ledger references its originating document polymorphically
// (docs/10 C.9), so this is the only way to ask "what did document X post?" —
// needed by any screen that reports on a document's costed movements.
if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(l => l.SourceDocType == sourceDocType);
if (sourceDocId is not null) q = q.Where(l => l.SourceDocId == sourceDocId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LedgerId)
+27 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService
_uow = uow;
}
public async Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default)
{
var q = _transfers.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct);
return PagedResponse<TransferSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
{
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
@@ -190,7 +216,7 @@ public sealed class TransferService : ITransferService
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}
@@ -24,6 +24,7 @@ public static class ErrorCodes
public const string ReasonCodeRequired = "REASON_CODE_REQUIRED";
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
public const string ConfigDisabled = "CONFIG_DISABLED";
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
+48 -5
View File
@@ -15,14 +15,41 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
## 1. Master Data
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting (**that endpoint was removed on 2026-07-16** — categories no longer nest; see the entry at the end of this section); `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
- [x] Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
- [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
- [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
- [x] Category **rebuilt 2026-07-16 as a two-level Category/SubCategory model** (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely
- [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only
- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId`
- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes
> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2)
> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8.
>
> **Deviations / decisions (all recorded in docs/10 §B.8.4 #1113):**
> - **`ItemType` enum → `StockNature`.** The Stocked/NonStocked/Service enum was renamed to free the name `ItemType` for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (`RenameColumn`, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it.
> - **`CATEGORY.parent_id` removed.** Arbitrary nesting is gone, replaced by a dedicated `SUBCATEGORY` table (exactly two levels). Items now carry **both** FKs; previously the frontend collapsed them (`effectiveCategoryId = subCategoryId ?? categoryId`), losing the parent.
> - **Item types are unlinked to items, deliberately.** No value table, no join — values live only in the client-generated SKU (`BL-100-0003`) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is *not* a product-variation model; none was requested.
> - **`itemTypesEnabled` is advisory, not enforced.** With no item-type reference on an item there is nothing on a write to reject; only `subcategoriesEnabled`/`brandsEnabled` produce `CONFIG_DISABLED`. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee.
> - **`PUT /product-config` is door-policy-gated only** — any ERP-admitted user can flip the flags. A `CONFIG_MANAGE` permission is reserved for when RBAC lands (open decision #13).
> - **`brandId` is now a documented field**, no longer the undocumented frontend-only extra it was.
>
> **Migration #2 (`AddBrandsSubcategoriesItemTypesAndProductConfig`) carries data, not just DDL.** The scaffolded version dropped `parent_id` outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into `subcategories`, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at **any** depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its *root*, not of its (now-nonexistent) parent category. `Down()` was likewise hand-written to restore the tree instead of dropping `subcategories` and losing it.
>
> Also fixed while writing it: the `ck_product_config_singleton` check constraint was scaffolded as `config_id = 1`, but the column is created quoted-PascalCase (`"ConfigId"`) — unquoted, Postgres folds it to a column that doesn't exist. And `UpdateProductConfigRequest`'s flags are `bool?` on purpose: `[Required]` on a non-nullable `bool` is a no-op, so a body of `{}` would have bound all three to `false` and silently switched every feature off.
>
> **Schema/migration verified:** `dotnet build` clean. Migration `Up` **and** `Down` exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and `StockNature` data preservation; the fixture was then removed. `DataSeeder` seeds `Color`/`Size` + the config singleton idempotently (it needed restructuring — an early `return` in the reason-code path would otherwise have skipped the new seeds on every start after the first).
>
> **Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session.** Auth note: a token *is* obtainable despite the `loginUser` blocker — **`POST /api/v1/auth/register` succeeds and issues the `erp_at` session cookie directly**, and the JWT handler's cookie fallback means that session authenticates every other controller. (`loginUser` still `500`s "Invalid credentials" for that same freshly-registered user, by username *or* email, with *or* without `userTypeId` — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal `roleId`/`userTypeId` GUIDs, supplied by the user; Admin = role `08de6a11-9e9f-4401-8a10-6859860b41ec` / userType `00000000-0000-0000-0000-000000000004`.
>
> Covered: brand/category/subcategory/item-type create; **case-insensitive duplicate name → 409** (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU `BL-100-0003` → 201 and full round-trip on `GET /items/{id}`; new `brandId`/`subCategoryId` list filters; **cross-FK guard → 422** ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; `PUT /product-config {}` → **400** (proving the `bool?` fix — an empty body no longer silently disables everything); `subcategoriesEnabled:false` + `subCategoryId` → **422 CONFIG_DISABLED**, same item without it → 201, and **pre-existing items with a subcategory still read back fine**; `brandsEnabled:false` + `brandId` → 422; **`itemTypesEnabled:false` correctly does NOT block item writes** (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → **412 CONCURRENCY_CONFLICT** (brand + subcategory) / absent → 428; `PATCH /status` → 204 then inactive-brand reference → 422; and **renaming an item type left existing SKUs untouched**, confirming the intended decoupling. Audit stamp confirmed live: `product_config.updatedBy` resolved to a JIT-provisioned shadow user (`SMOKE001`) from the AuthHex `UserId`/`NIC` claims.
>
> Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). **Two artifacts left behind on purpose:** the AuthHex user `smoketest_admin` / NIC `SMOKE001` in AuthHex's own MySQL store, and its ERPCore shadow user (`users.UserId = 3`) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted.
## 2. Procurement
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
@@ -32,7 +59,18 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. **Consequence surfaced 2026-07-17:** no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table.
> ### 2026-07-17 — read endpoints added so the frontend could be connected
> The frontend rewire (see `Frontend/PROGRESS.md`) needed reads that did not exist. `stock-adjustments` and `purchase-returns` had **no GET at all** — a UI could not re-display a record it had just created.
> - **New:** `GET /grns`, `GET /rfqs`, `GET /stock-transfers`, `GET /stock-counts`, `GET /stock-adjustments` **+ `/{id}`**, `GET /purchase-returns` **+ `/{id}`**, `GET /stock/on-hand/list`. All follow `ItemService.ListAsync` (ILike on `q`, filters, `PagedResponse<T>.Create`) with matching `*SummaryDto`s carrying a `lineCount`.
> - **`GET /stock/on-hand/list`** is deliberately set-based — four grouped queries regardless of page size — rather than calling `GetOnHandAsync` per row (N+1). It replaces a client-side loop the mock used to do.
> - **`GET /stock/ledger` gained `sourceDocType`/`sourceDocId`.** The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history.
> - **`ItemDetailDto` gained `conversions`** (+ `.Include(i => i.UomConversions)`): they could only be *written* (`PUT /items/{id}/uom-conversions` returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation `Frontend/PROGRESS.md` had flagged.
> - **DTOs gained fields the entities already had** and the UI needed: `createdBy`/`createdAt` on transfers + counts, `createdAt` on purchase returns, `lineCount` + a `status` filter on requisitions. Cheaper and more honest than deleting working columns from the screens.
> - **Bug fixed — `POST /auth/logout` made `userId` optional.** AuthHex returns `user.userId: null` on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's `UserId` claim and **always** clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser.
> - **Verified:** `dotnet build` clean; every new endpoint returns a correct `PagedResponse` against a live cookie session; `conversions` round-trips; `CONFIG_DISABLED` (422), `CONCURRENCY_CONFLICT` (412) and the cross-FK 422 (*"Subcategory 5 belongs to category 10, not 11"*) all confirmed through the browser. Test data removed afterwards.
> - **Not done — serial numbers (FR-GRN-04, priority M):** `CreateGrnLineInput` carries `batch` but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does **not** collect them rather than silently discarding them. `SERIAL`/`StockLayer.serial_id` already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4.
## 3. Goods Receipt
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
@@ -146,4 +184,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision.
- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register``200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN``403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed.
- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved.
- **✅ RESOLVED 2026-07-17 — login works.** The AuthHex fix below was applied (`ERP_Auth_Service`, uncommenting the `PasswordHash` assignment) and verified: `POST /api/v1/auth/login` now returns **200 + `Set-Cookie: erp_at`** for a freshly-registered user, where it previously returned `500 "Invalid credentials"`. This unblocked the §1–§5 live verification that had been pending for two sessions. **Users registered before the fix have a null hash and can never log in** — they must be re-registered (the session's `smoketest_admin` among them).
- **Historical:** `loginUser` used to fail with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` **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) — a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy.
- **ROOT CAUSE (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners.** Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`.
1. **The password was never stored — FIXED 2026-07-17.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 was commented out** (`//PasswordHash = PasswordHash`). Every registered user landed in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always threw `"Invalid credentials"`. **Uncommenting that line fixed login outright** — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and 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 5665, 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.
+51 -10
View File
@@ -5,20 +5,23 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
## 0. Foundation
- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — currently unused now that the fetch client is gone (see 2026-07-15 note below)
- [ ] Typed API client / fetch wrapper — **removed 2026-07-15** (`lib/api-client.ts` + `lib/auth-token.ts` deleted per explicit user request: "delete all api connections"). No live code path in the frontend makes an HTTP call to any backend anymore — every screen is 100% mock data (`lib/api/mock-data.ts`).
- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn); `types/common.ts` now also carries `ApiResult<T>` (moved here 2026-07-15 when `lib/api-client.ts` was deleted, since it's a plain data envelope, not fetch-specific)
- [x] **Transport: same-origin Next `rewrites()` proxy** (`next.config.ts`, `/api/*``BACKEND_ORIGIN`, default `http://localhost:5224`). `BACKEND_ORIGIN` in `.env.local` / `.env.local.example`**not** `NEXT_PUBLIC_*`; the browser never sees the backend URL. Chosen over backend CORS because it makes the API same-origin, so CORS and the Secure/SameSite=Strict session cookies stop being problems at all. (`.gitignore`'s `.env*` was silently swallowing the example file — added a `!.env.local.example` negation.)
- [x] **Typed API client rebuilt** (`lib/api-client.ts`, 2026-07-17) — recovered the pre-deletion version from git (`0e4bcf1^`) and adapted: relative `/api/v1` base, **`credentials: "include"`** (never present before), `ApiResult`/`ProblemDetails` imported from `@/types/common` rather than redeclared, `readCsrfToken()` for the eight `[ValidateCsrf]` auth actions. `ApiError`, `apiRequest`, `apiRequestWithETag`, `buildQuery`, `ifMatch`/`idempotencyKey` all carried over.
- [x] **Route guard** (`proxy.ts` — Next 16's rename of `middleware.ts`; the old name still works but warns). Redirects `/dashboard/*` to `/login?next=…` when the `erp_at` cookie is absent. **Presence check only** — the cookie is httpOnly and the JWT is RS256, so the edge cannot validate it; the API stays the authority.
- [x] **Auth** (`lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`) — real login/logout. No token is stored: the session is httpOnly cookies. `lib/auth-session.ts` caches the user *profile* in localStorage for the Header, because there is no `GET /auth/me` and the user object only arrives in the login response. It is display data, not a credential.
- [x] Shared TS types mirroring API DTOs (`types/{common,master-data,procurement,grn,stock,auth}.ts`) — **reconciled field-by-field against the live schemas 2026-07-17**; see the entry below for what had drifted.
- [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3)
- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection (the `ApiError` class they used to check via `instanceof` no longer exists); this also fixed a latent bug where the mock layer's plain `Error`-plus-`.code` rejects never matched the old `instanceof ApiError` check, so `CODE_MESSAGES` silently never applied to any mock error
> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built.
- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection. **Fixed 2026-07-17:** generic framework codes (`conflict`/`not_found`/`validation_error`) were shadowing the server's specific `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes now lose to `detail`; specific domain codes still win.
> **⚠️ The 2026-07-15 note below is HISTORY, not current state.** The fetch infrastructure was rebuilt on 2026-07-17 and `lib/api/mock-data.ts` is deleted — see "2026-07-17 — connected to the real API" at the bottom of this file. Its parting advice (re-verify the RFQ contract; expect to rebuild the client from scratch) was followed and proved correct.
>
> **2026-07-15 — fetch infrastructure deleted outright (not just reverted to mock).** Following an earlier same-week pass that wired every `lib/api/*.ts` module to real `fetch` calls (then reverted via `git revert --no-commit` at the user's request — see `Backend`-adjacent history if relevant later), the user asked to go further and delete the underlying connection mechanism entirely, not just leave it unused. Deleted `lib/api-client.ts` (`apiRequest`/`apiRequestWithETag`/`buildQuery`/`ApiError`) and `lib/auth-token.ts` (bearer-token storage) as files. Follow-on fixes this required: (1) `ApiResult<T>` — used by `items.ts`/`purchase-orders.ts`/`vendors.ts` for their mock ETag pattern — moved into `types/common.ts`; (2) `lib/error-map.ts` rewritten to duck-type instead of `instanceof ApiError`; (3) three detail pages (`vendors/[id]`, `products/[id]`, `procurement/purchase-orders/[id]`) had their `err instanceof ApiError ? err.code : (err as {code?:string})?.code` conflict-detection simplified to the duck-typed form only. Also stripped the now-dangling commented-out "real implementation" blocks (`// import { apiRequest... } from "@/lib/api-client"` etc.) from all 15 `lib/api/*.ts` files, since they referenced a now-deleted module. `tsc --noEmit`/`eslint` clean (same pre-existing `login/page.tsx` error and established `set-state-in-effect` pattern only — confirmed unchanged by this pass).
>
> **If real backend integration is attempted again**, note two things found during the reverted pass: the RFQ backend contract had drifted from this file's assumed shapes (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs`/`RfqService.cs` — no persisted invited-vendor list, `requisitionId` required on create, different comparison DTO field names) — re-verify against the actual backend rather than trusting old assumptions; and a typed fetch client + ETag/error-normalization layer will need to be rebuilt from scratch since `lib/api-client.ts`/`lib/auth-token.ts` no longer exist.
## 1. Auth
- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage
- [x] Login screen — **wired 2026-07-17** to `POST /auth/login`. Previously it `console.log`'d the plaintext password and pushed to `/dashboard` unconditionally; any schema-valid input "logged in". Now: real call, session cookies, server errors surfaced, `?next=` honoured (same-origin paths only — an absolute URL there would be an open redirect).
- [x] Route guard (`proxy.ts`) + real logout in `components/Layouts/Header.tsx` — the Header no longer hardcodes `john52martinez@gmail.com`, and "Log out" is a real `POST /auth/logout` rather than a `<Link href="/login">`.
- [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API
- [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API
- [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API
@@ -46,7 +49,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed`
- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn`
> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below).
> **⚠️ The two notes below are HISTORY (2026-07-13).** The GRN backend exists and these screens call it as of 2026-07-17; `GET /grns` + `GET /grns/{id}` are real, and GRN edit/delete were removed because the API has no `PUT`/`DELETE`. The FIFO engine they describe as living in `mock-data.ts` is deleted — the server owns it.
>
> **`[~]` not `[x]`, by design (at the time):** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend existed yet** (this was frontend-only work; see the deviation below).
>
> **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`).
>
@@ -64,7 +69,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type).
- Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`)
> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4).
> **⚠️ HISTORY (2026-07-13).** The Stock Core backend exists and these screens call it as of 2026-07-17; the list endpoints assumed here (`GET /stock-transfers`, `/stock-adjustments`, `/stock-counts`, on-hand list) were all added for real. The in-memory Stock Core described below is deleted.
>
> **`[~]` not `[x]`, by design (at the time) — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4).
>
> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions).
>
@@ -83,6 +90,40 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## Done
<!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-17 — connected to the real API (mock-data.ts deleted)
**The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated.
**Transport + auth**
- Same-origin **Next `rewrites()` proxy** rather than backend CORS (see §0). The backend has no CORS and now needs none.
- Rebuilt `lib/api-client.ts` from `git show 0e4bcf1^`; added `credentials: "include"`.
- New `proxy.ts` route guard, `lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`. Login/logout are real.
- **Fixed the long-standing `app/login/page.tsx` resolver type error** — `lib/validations.ts` used `z.preprocess`, which widens the schema's *input* type to `unknown`, so `zodResolver` produced a `Resolver<{email: unknown}>` that could not satisfy `useForm<LoginValues>`. Form fields always yield strings (RHF defaults them to `""`), so the null-coercion it guarded against cannot happen. **`tsc --noEmit` is now fully clean** — the first time in this file's history.
**Two real bugs found by driving the browser (both fixed, both invisible to unit-level checks)**
1. **Logout didn't log you out.** AuthHex returns `user.userId: null` on login, so the Header could not supply the `userId` that `POST /auth/logout` required; the call was skipped and `erp_at` survived. Fixed backend-side (`userId` optional, resolved from the token claim, cookies always cleared). Verified: cookies now `[]` after logout.
2. **Generic error codes shadowed the server's message.** `errorMessage()` checked `CODE_MESSAGES[code]` before `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes (`conflict`/`not_found`/`validation_error`) now lose to `detail`.
**Contract drift reconciled** (types were rewritten field-by-field against the live OpenAPI, not assumed):
- `itemType``stockNature`; `ItemType` is now the Color/Size master. `variants.ts``item-types.ts`; the screen moved to `/dashboard/products/item-types`.
- `RfqComparison` was `{lines[].cells[]}` in this app but `{rows[].quotes[]}` on the server, and cells carry `quotationId`. `Rfq` has no `vendorIds`/`createdAt`; `StockTransfer`/`StockCount` had `createdBy`/`createdAt` the DTOs never returned (added server-side rather than dropping the columns); `ReasonCodeContext` had `"CountVariance"` where the server says `"Count"`; `EnterCounts` returns the whole `CountDto`, not `{lines}`; `PostCountResponse.adjustmentId` is nullable; `createReorderRequisition` returns a full `Requisition`, not `{qty}`.
- `remove()``updateStatus(id, "Inactive")` on brands/categories/item-types, each with a Status column and Deactivate/Activate (no `DELETE` exists — FR-MD-08).
- New `app/dashboard/products/categories/[id]` for subcategories (their own resource now); new `app/dashboard/products/settings` for Product Configuration (added the shadcn `switch` primitive via the CLI).
**Features deliberately removed rather than left lying**
- **`initialQty`** and the builder's Quantity column — no such field on the Item contract and no initial-receipt flow; stock arrives via a GRN. It never worked under the mock either.
- **GRN edit/delete** + the `grn/[id]/edit` route — the API has no `PUT`/`DELETE` for a GRN (FR-X-05).
- **RFQ "vendors invited"** — not persisted server-side. The screens show quotations received; the quote form offers any active vendor instead of "invited but pending".
- **Serial capture on GRN** — `CreateGrnLineInput` has no serial field despite FR-GRN-04 (priority M). Not collected rather than silently discarded. Flagged in `Backend/PROGRESS.md` + docs/11 §4.
**Fixed while rewiring:** the builder hardcoded `baseUomId: 1`, which only worked because the mock seeded that id — against a real DB it is a 422 or, worse, the wrong unit. It now adopts the first real UOM and says so when none exists. The per-row create loop still has no transaction, but the error now reports how many items landed before the failure instead of implying none did.
**Verified end-to-end in a real browser (Playwright), not just typechecked** — 17/17 then 9/9 on a recheck: guard redirect + `?next=` round-trip; login → cookies (`erp_at` httpOnly) → real user in Header; brand created via the UI; **duplicate → server 409 with its own message**; product-config screen reads the singleton; item-types shows the seeded Color/Size; logout clears cookies. Plus, through the page's own session: **cross-FK guard 422** (*"Subcategory 5 belongs to category 10, not 11"*), item created with **both** `categoryId` and `subCategoryId` + `brandId`, `conversions` present on the detail, **`CONFIG_DISABLED` 422** with the same item succeeding without the gated field and pre-existing items still readable, and a stale `If-Match`**412 `CONCURRENCY_CONFLICT`**. Test data was removed afterwards; the dev DB is back to empty masters.
> **The DB is near-empty and that is now visible.** The mock silently supplied warehouses, UOMs, reason codes and sample POs. Screens are blank until that data is created — correct behaviour, not a regression, but a dev seed would make the app pleasant to open. `lib/api/mock-data.ts`'s FIFO engine (`receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) is gone with it: **the browser no longer does inventory maths** — the server does.
>
> **Not yet exercised against real data:** GRN confirm → Stock Enquiry/Ledger/Valuation, transfers, counts and the wastage report. They compile and are wired, but proving the FIFO chain needs a warehouse + PO + receipt seeded first. That is the highest-value next verification.
### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes)
- Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface.
- Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers.
@@ -123,7 +164,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core.
- Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation).
- Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes.
- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged).
- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend existed at the time (`Backend/PROGRESS.md` §2 unchanged). **Superseded 2026-07-17** — the Procurement backend exists and these screens now call it; several assumed shapes turned out wrong (see the 2026-07-17 entry).
- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server.
### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes)
+7
View File
@@ -0,0 +1,7 @@
# Origin of the ERPCore backend. Used ONLY by the Next rewrite proxy in next.config.ts
# (server-side), so it is intentionally not NEXT_PUBLIC_* — the browser never sees it and
# only ever calls this Next server at same-origin /api/v1.
#
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse.
BACKEND_ORIGIN=http://localhost:5224
+2
View File
@@ -32,6 +32,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# ...except the template, which carries no secrets and documents what to set.
!.env.local.example
# vercel
.vercel
@@ -115,7 +115,9 @@ function NewPurchaseOrderContent() {
setVendorId(rfqVendorId)
setLines(
rfq.lines.map((l): DraftLine => {
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
const cell = comparison.rows
.find((row) => row.itemId === l.itemId)
?.quotes.find((q) => q.vendorId === rfqVendorId)
return {
key: newKey(),
itemId: l.itemId,
@@ -69,11 +69,22 @@ export default function RfqDetailPage() {
const quotedVendorIds = useMemo(() => {
const set = new Set<number>()
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId)
return set
}, [comparison])
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
/**
* Vendors still available to quote.
*
* This used to be "invited but not yet quoted", but the invited list does not survive:
* `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor
* link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the
* comparison's columns come from who actually quoted rather than who was asked.
*/
const pendingVendors = useMemo(
() => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId),
[vendors, quotedVendorIds],
)
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
@@ -155,9 +166,9 @@ export default function RfqDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
<RfqStatusBadge status={rfq.status} />
</div>
{/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}
<p className="text-base text-muted-foreground">
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
</p>
</div>
</div>
@@ -191,7 +202,7 @@ export default function RfqDetailPage() {
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
{comparison.lines.every((l) => l.cells.length === 0) ? (
{comparison.rows.every((r) => r.quotes.length === 0) ? (
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
) : (
<div className="overflow-x-auto">
@@ -199,19 +210,20 @@ export default function RfqDetailPage() {
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
{rfq.vendorIds.map((vid) => (
{/* Columns are the vendors that actually quoted — the server computes this. */}
{comparison.vendorIds.map((vid) => (
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{comparison.lines.map((line) => {
{comparison.rows.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
{rfq.vendorIds.map((vid) => {
const cell = line.cells.find((c) => c.vendorId === vid)
{comparison.vendorIds.map((vid) => {
const cell = line.quotes.find((c) => c.vendorId === vid)
return (
<TableCell key={vid} className="px-3 py-3.5">
{cell ? (
@@ -256,7 +268,7 @@ export default function RfqDetailPage() {
<Label className="text-base">Vendor</Label>
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select an invited vendor" />
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{pendingVendors.map((vid) => (
@@ -99,6 +99,12 @@ function NewRfqContent() {
setHeaderError(null)
setSubmitError(null)
// The server requires a requisition — an RFQ is always raised against one
// (docs/11 §3.2). Catch it here rather than letting the POST 400.
if (requisitionId === null) {
setHeaderError("Select the requisition this RFQ is raised against.")
return
}
if (vendorIds.size === 0) {
setHeaderError("Select at least one vendor to invite.")
return
@@ -5,10 +5,8 @@ import Link from "next/link"
import { FileText, Plus } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { RfqSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
@@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
useEffect(() => {
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
.then(([r, v]) => {
setRfqs(r.items)
setVendors(v.items)
})
rfqsApi
.list()
.then((r) => setRfqs(r.items))
.catch((err) => setError(errorMessage(err)))
}, [])
function vendorNames(vendorIds: number[]) {
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -75,9 +68,11 @@ export default function RfqsListPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Requisition</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendors invited</TableHead>
{/* "Vendors invited" is gone: the invite list is validated on create but not
persisted (docs/11 §3.2). Quotations received is the fact that survives. */}
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Quotations</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -89,11 +84,11 @@ export default function RfqsListPage() {
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="px-3 py-3.5">{vendorNames(r.vendorIds)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.quotationCount}</TableCell>
<TableCell className="px-3 py-3.5">
<RfqStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
@@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -63,9 +63,13 @@ export default function ItemDetailPage() {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked")
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("")
@@ -93,9 +97,11 @@ export default function ItemDetailPage() {
setName(data.name)
setDescription(data.description ?? "")
setCategoryId(data.categoryId)
setSubCategoryId(data.subCategoryId)
setBrandId(data.brandId)
setBaseUomId(data.baseUomId)
setDefaultVendorId(data.defaultVendorId)
setItemType(data.itemType)
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setTaxClass(data.taxClass ?? "")
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
@@ -139,7 +145,7 @@ export default function ItemDetailPage() {
try {
const result = await itemsApi.update(
item.itemId,
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
{ sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null },
etag
)
applyItem(result.data)
@@ -389,8 +395,10 @@ export default function ItemDetailPage() {
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
{/* "Item type" now means a Color/Size dimension master — this field is the
stock-nature one it used to be confused with (docs/11 §8). */}
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function BrandsPage() {
function load() {
setError(null)
brandsApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setBrands(res.items)
setPagination(res.pagination)
@@ -87,10 +88,16 @@ export default function BrandsPage() {
setSubmitting(true)
try {
const brand = editing
? await brandsApi.update(editing.brandId, { name })
: await brandsApi.create({ name })
toast.success(editing ? "Brand updated" : "Brand created", brand.name)
let result
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
} else {
result = await brandsApi.create({ name })
}
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +111,16 @@ export default function BrandsPage() {
}
}
async function handleDelete(brand: Brand) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setDeletingId(brand.brandId)
try {
await brandsApi.remove(brand.brandId)
toast.success("Brand deleted", brand.name)
await brandsApi.updateStatus(brand.brandId, next)
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
load()
} catch (err) {
toast.error("Could not delete brand", errorMessage(err))
toast.error("Could not update brand status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +215,7 @@ export default function BrandsPage() {
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
@@ -215,6 +225,9 @@ export default function BrandsPage() {
<TableRow key={b.brandId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
@@ -227,26 +240,32 @@ export default function BrandsPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by transactions must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${b.name}`}
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
<Trash2 className="size-4" />
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${b.name}?`}
description="This permanently removes the brand."
confirmLabel="Delete"
onConfirm={() => handleDelete(b)}
variant={b.status === "Active" ? "destructive" : "success"}
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
description={
b.status === "Active"
? "The brand stays on existing items but cannot be assigned to new ones."
: "The brand becomes selectable again."
}
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(b)}
/>
</AlertDialog>
</div>
@@ -0,0 +1,233 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/**
* Subcategories of one category — the single optional level below it (FR-MD-04).
* The hierarchy is exactly two deep, so there is no recursion here by design.
*/
export default function CategorySubCategoriesPage() {
const params = useParams<{ id: string }>()
const categoryId = Number(params.id)
const [category, setCategory] = useState<Category | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<SubCategory | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
categoriesApi
.get(categoryId)
.then((res) => setCategory(res.data))
.catch((err) => setError(errorMessage(err)))
categoriesApi
.listSubCategories(categoryId, { pageSize: 200 })
.then((res) => setSubCategories(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [categoryId])
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(sub: SubCategory) {
setEditing(sub)
setName(sub.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await subCategoriesApi.get(editing.subCategoryId)
await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "")
} else {
await categoriesApi.createSubCategory(categoryId, { name })
}
toast.success(editing ? "Subcategory updated" : "Subcategory created", name)
setOpen(false)
setName("")
setEditing(null)
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleToggleStatus(sub: SubCategory) {
const next = sub.status === "Active" ? "Inactive" : "Active"
setTogglingId(sub.subCategoryId)
try {
await subCategoriesApi.updateStatus(sub.subCategoryId, next)
toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
<p className="text-base text-muted-foreground">
The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Subcategory</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit subcategory" : "New subcategory"}</DialogTitle>
<DialogDescription>Give the subcategory a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="sub-name">Name</FieldLabel>
<Input id="sub-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Hex Bolts" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && subCategories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && subCategories !== null && subCategories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Network className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No subcategories yet items can attach straight to the category.</p>
</div>
)}
{!error && subCategories !== null && subCategories.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subCategories.map((s) => (
<TableRow key={s.subCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
disabled={togglingId === s.subCategoryId}
/>
}
>
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={s.status === "Active" ? "destructive" : "success"}
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
description={
s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function CategoriesPage() {
function load() {
setError(null)
categoriesApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setCategories(res.items)
setPagination(res.pagination)
@@ -87,10 +88,15 @@ export default function CategoriesPage() {
setSubmitting(true)
try {
const category = editing
? await categoriesApi.update(editing.categoryId, { name })
: await categoriesApi.create({ name })
toast.success(editing ? "Category updated" : "Category created", category.name)
let result
if (editing) {
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
const current = await categoriesApi.get(editing.categoryId)
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
} else {
result = await categoriesApi.create({ name })
}
toast.success(editing ? "Category updated" : "Category created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +110,16 @@ export default function CategoriesPage() {
}
}
async function handleDelete(category: Category) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(category: Category) {
const next = category.status === "Active" ? "Inactive" : "Active"
setDeletingId(category.categoryId)
try {
await categoriesApi.remove(category.categoryId)
toast.success("Category deleted", category.name)
await categoriesApi.updateStatus(category.categoryId, next)
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update category status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +214,7 @@ export default function CategoriesPage() {
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
@@ -215,9 +224,21 @@ export default function CategoriesPage() {
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree. */}
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
<Button
variant="ghost"
size="icon-sm"
@@ -227,26 +248,31 @@ export default function CategoriesPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
<Trash2 className="size-4" />
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
@@ -2,15 +2,16 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName } from "@/lib/validations/master-data"
import { validateItemTypeName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { VariantCategory } from "@/types/master-data"
import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -19,22 +20,30 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function VariantsPage() {
const [categories, setCategories] = useState<VariantCategory[] | null>(null)
/**
* Item Types (docs/11 §2.7) the dimension names (Color, Size, Material) the item
* builder's checkboxes read. Formerly "Variant Categories" in this app.
*
* These are names only. The values (Red, S, M) live in each item's generated SKU and are
* not stored, so nothing here links to an item renaming a type leaves existing SKUs
* untouched.
*/
export default function ItemTypesPage() {
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<VariantCategory | null>(null)
const [editing, setEditing] = useState<ItemType | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
variantCategoriesApi
.list()
.then((res) => setCategories(res.items))
itemTypesApi
.list({ pageSize: 200 })
.then((res) => setItemTypes(res.items))
.catch((err) => setError(errorMessage(err)))
}
@@ -47,24 +56,28 @@ export default function VariantsPage() {
setOpen(true)
}
function openEditDialog(category: VariantCategory) {
setEditing(category)
setName(category.name)
function openEditDialog(itemType: ItemType) {
setEditing(itemType)
setName(itemType.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateVariantCategoryName(name)
const nextErrors = validateItemTypeName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const category = editing
? await variantCategoriesApi.update(editing.variantCategoryId, { name })
: await variantCategoriesApi.create({ name })
toast.success(editing ? "Variant category updated" : "Variant category created", category.name)
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await itemTypesApi.get(editing.itemTypeId)
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
} else {
await itemTypesApi.create({ name })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
setOpen(false)
setName("")
setEditing(null)
@@ -72,22 +85,24 @@ export default function VariantsPage() {
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete(category: VariantCategory) {
setDeletingId(category.variantCategoryId)
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(itemType: ItemType) {
const next = itemType.status === "Active" ? "Inactive" : "Active"
setTogglingId(itemType.itemTypeId)
try {
await variantCategoriesApi.remove(category.variantCategoryId)
toast.success("Variant category deleted", category.name)
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update status", errorMessage(err))
} finally {
setDeletingId(null)
setTogglingId(null)
}
}
@@ -99,23 +114,25 @@ export default function VariantsPage() {
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Variants</h1>
<p className="text-base text-muted-foreground">Variant categories used by the item variant builder (e.g. Color, Size, Material).</p>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Category</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit variant category" : "New variant category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription>
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
<DialogDescription>Give the item type a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
<FieldLabel htmlFor="item-type-name">Name</FieldLabel>
<Input
id="variant-category-name"
id="item-type-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Material"
@@ -140,7 +157,7 @@ export default function VariantsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
{!error && itemTypes === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
@@ -148,37 +165,36 @@ export default function VariantsPage() {
</div>
)}
{!error && categories !== null && categories.length === 0 && (
{!error && itemTypes !== null && itemTypes.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SwatchBook className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No variant categories yet.</p>
<p className="text-base text-muted-foreground">No item types yet.</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
{!error && itemTypes !== null && itemTypes.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((c) => (
<TableRow key={c.variantCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.variantCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{itemTypes.map((t) => (
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
<Pencil className="size-4" />
</Button>
@@ -188,20 +204,24 @@ export default function VariantsPage() {
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
disabled={deletingId === c.variantCategoryId}
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
disabled={togglingId === t.itemTypeId}
/>
}
>
<Trash2 className="size-4" />
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the variant category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={t.status === "Active" ? "destructive" : "success"}
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
description={
t.status === "Active"
? "It disappears from the item builder. Existing items keep their SKUs — nothing references this record."
: "It reappears in the item builder."
}
confirmLabel={t.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(t)}
/>
</AlertDialog>
</div>
@@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, VariantCategory } from "@/types/master-data"
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -34,6 +36,10 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
}
/**
* Colour is special-cased by name. This stays a frontend concern: item types are names
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9).
*/
function isColorCategory(categoryName: string): boolean {
return categoryName.trim().toLowerCase() === "color"
}
@@ -52,26 +58,31 @@ function partLabel(part: { name: string; value: string }): string {
return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value
}
// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data).
const DEFAULT_BASE_UOM_ID = 1
export default function NewItemPage() {
const router = useRouter()
const [categories, setCategories] = useState<Category[] | null>(null)
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
const [variantCategories, setVariantCategories] = useState<VariantCategory[] | null>(null)
const [brands, setBrands] = useState<Brand[] | null>(null)
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
/**
* This form has no Base UOM field by design, so it adopts the first UOM as the base.
* It used to hardcode `uomId: 1`, which only worked because the mock seeded that id —
* against a real database that is a 422 waiting to happen, or worse, silently the wrong
* unit. Null here means "no UOM exists yet" and the form says so rather than guessing.
*/
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState<number[]>([])
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
const [quantities, setQuantities] = useState<Record<string, string>>({})
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
@@ -83,23 +94,40 @@ export default function NewItemPage() {
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
.then(([cat, br, vc]) => {
Promise.all([
categoriesApi.list({ pageSize: 200, status: "Active" }),
brandsApi.list({ pageSize: 200, status: "Active" }),
itemTypesApi.list({ pageSize: 200, status: "Active" }),
productConfig(),
uomsApi.list({ pageSize: 1 }),
])
.then(([cat, br, types, cfg, uoms]) => {
setCategories(cat.items)
setBrands(br.items)
setVariantCategories(vc.items)
setItemTypes(types.items)
setConfig(cfg)
setBaseUomId(uoms.items[0]?.uomId ?? null)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories])
const subCategoryOptions = useMemo(
() => (categories ?? []).filter((c) => c.parentId === categoryId),
[categories, categoryId]
)
const effectiveCategoryId = subCategoryId ?? categoryId
const effectiveCategoryLabel =
(categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? ""
// Subcategories are their own resource now — fetched per category rather than filtered
// out of a flat list by parentId (that column no longer exists).
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) {
setSubCategories([])
return
}
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategories(res.items))
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
function handleCategoryChange(value: number | null) {
@@ -107,28 +135,27 @@ export default function NewItemPage() {
setSubCategoryId(null)
}
function toggleVariantCategory(variantCategoryId: number) {
setCheckedVariantCategoryIds((prev) =>
prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId]
function toggleItemType(itemTypeId: number) {
setCheckedItemTypeIds((prev) =>
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
)
setQuantities({})
}
async function handleAddVariantCategory() {
const nextErrors = validateVariantCategoryName(newCategoryName)
async function handleAddItemType() {
const nextErrors = validateItemTypeName(newCategoryName)
if (nextErrors.name) {
setNewCategoryError(nextErrors.name)
return
}
setAddingCategorySubmitting(true)
try {
const category = await variantCategoriesApi.create({ name: newCategoryName })
setVariantCategories((prev) => [...(prev ?? []), category])
setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId])
const created = await itemTypesApi.create({ name: newCategoryName })
setItemTypes((prev) => [...(prev ?? []), created.data])
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
setNewCategoryName("")
setNewCategoryError(null)
setAddingCategory(false)
toast.success("Variant category created", category.name)
toast.success("Item type created", created.data.name)
} catch (err) {
setNewCategoryError(errorMessage(err))
} finally {
@@ -136,34 +163,32 @@ export default function NewItemPage() {
}
}
function addValue(variantCategoryId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim()
function addValue(itemTypeId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
if (value) {
setValuesByCategory((prev) => {
const existing = prev[variantCategoryId] ?? []
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [variantCategoryId]: [...existing, value] }
return { ...prev, [itemTypeId]: [...existing, value] }
})
setQuantities({})
}
setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" }))
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(variantCategoryId: number, value: string) {
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value),
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
}))
setQuantities({})
}
const activeCategories = useMemo(
() =>
(variantCategories ?? [])
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
.map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] }))
.filter((vc) => vc.values.length > 0),
[variantCategories, checkedVariantCategoryIds, valuesByCategory]
(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] }))
.filter((t) => t.values.length > 0),
[itemTypes, checkedItemTypeIds, valuesByCategory]
)
const variants = useMemo(() => {
@@ -183,44 +208,58 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)),
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
}))
}, [activeCategories, effectiveCategoryLabel])
}, [activeCategories, effectiveLabel])
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (baseUomId === null) {
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
return
}
setSubmitting(true)
let created = 0
try {
let created = 0
for (const variant of variants) {
const qty = Number(quantities[variant.key] || 0)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
categoryId: effectiveCategoryId as number,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
categoryId: categoryId as number,
subCategoryId,
brandId,
baseUomId: DEFAULT_BASE_UOM_ID,
itemType: "Stocked",
baseUomId,
stockNature: "Stocked",
trackingMode: "None",
initialQty: Number.isFinite(qty) ? qty : 0,
})
created += 1
}
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
router.push("/dashboard/products")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create variants", errorMessage(err))
// Each row is its own POST with no transaction, so a failure partway (e.g. a
// duplicate SKU) leaves the earlier rows created. Say so rather than implying
// nothing happened.
const detail = errorMessage(err)
setSubmitError(
created > 0
? `${detail}${created} item${created === 1 ? "" : "s"} were already created before this failed.`
: detail,
)
toast.error("Could not create all variants", detail)
} finally {
setSubmitting(false)
}
}
const loading = !categories || !brands || !variantCategories
const loading = !categories || !brands || !itemTypes || !config
return (
<div className="flex flex-col gap-6">
@@ -230,7 +269,7 @@ export default function NewItemPage() {
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and variant categories (FR-MD-01).</p>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
</div>
</div>
@@ -240,6 +279,16 @@ export default function NewItemPage() {
{loading && !loadError && <Skeleton className="h-64 w-full" />}
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM {" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
create one first
</Link>
.
</div>
)}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -250,7 +299,7 @@ export default function NewItemPage() {
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{topCategories.map((c) => (
{(categories ?? []).map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
@@ -259,54 +308,63 @@ export default function NewItemPage() {
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategoryOptions.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Config flags are honoured by hiding the field: sending a gated value would
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
{config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategories.map((s) => (
<SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Variants</h2>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<p className="text-sm text-muted-foreground">
Check the variant categories that apply, then add their values to generate a SKU per combination.
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{(variantCategories ?? []).map((vc) => (
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => (
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
<Checkbox
checked={checkedVariantCategoryIds.includes(vc.variantCategoryId)}
onCheckedChange={() => toggleVariantCategory(vc.variantCategoryId)}
checked={checkedItemTypeIds.includes(t.itemTypeId)}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{vc.name}</span>
<span className="text-base font-medium">{t.name}</span>
</label>
))}
{!addingCategory && (
@@ -314,7 +372,7 @@ export default function NewItemPage() {
type="button"
variant="outline"
size="icon-sm"
aria-label="Add another variant category"
aria-label="Add another item type"
onClick={() => setAddingCategory(true)}
>
<Plus className="size-4" />
@@ -331,7 +389,7 @@ export default function NewItemPage() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddVariantCategory()
handleAddItemType()
}
}}
placeholder="Material"
@@ -339,7 +397,7 @@ export default function NewItemPage() {
aria-invalid={!!newCategoryError}
autoFocus
/>
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
<Plus className="size-4" />
Add
</Button>
@@ -363,38 +421,38 @@ export default function NewItemPage() {
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
{checkedVariantCategoryIds.length > 0 && (
{checkedItemTypeIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{(variantCategories ?? [])
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
.map((vc) => {
const isColor = isColorCategory(vc.name)
const currentInput = inputByCategory[vc.variantCategoryId] ?? ""
const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? ""
{(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => {
const isColor = isColorCategory(t.name)
const currentInput = inputByCategory[t.itemTypeId] ?? ""
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
function addColor() {
const name = currentColorName.trim()
if (!name) return
addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" }))
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
}
return (
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
<Label className="text-base">{vc.name} values</Label>
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label>
<div className="flex gap-2">
{isColor ? (
<>
<input
type="color"
value={currentInput || "#EF4444"}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
aria-label="Pick color"
/>
<Input
value={currentColorName}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
@@ -408,24 +466,24 @@ export default function NewItemPage() {
) : (
<Input
value={currentInput}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(vc.variantCategoryId)
addValue(t.itemTypeId)
}
}}
placeholder={vc.name}
placeholder={t.name}
className="h-11 text-base"
/>
)}
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(vc.variantCategoryId))}>
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
<Plus className="size-4" />
Add {vc.name}
Add {t.name}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
const decoded = isColor ? decodeColorValue(v) : null
return (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
@@ -439,7 +497,7 @@ export default function NewItemPage() {
{decoded ? decoded.name : v}
<button
type="button"
onClick={() => removeValue(vc.variantCategoryId, v)}
onClick={() => removeValue(t.itemTypeId, v)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${decoded ? decoded.name : v}`}
>
@@ -461,10 +519,13 @@ export default function NewItemPage() {
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
{activeCategories.map((cat) => (
<TableHead key={cat.variantCategoryId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm text-indigo-700">Quantity</TableHead>
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
</TableRow>
</TableHeader>
<TableBody>
@@ -488,16 +549,6 @@ export default function NewItemPage() {
)
})}
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
value={quantities[variant.key] ?? ""}
onChange={(e) => setQuantities((prev) => ({ ...prev, [variant.key]: e.target.value }))}
placeholder="0"
className="h-9 w-24 text-sm"
/>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -505,6 +556,7 @@ export default function NewItemPage() {
</div>
)}
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
@@ -191,7 +191,7 @@ export default function ItemsPage() {
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
<TableCell className="px-3 py-3.5">{item.itemType}</TableCell>
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
@@ -0,0 +1,168 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Info } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
*
* Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are enforced by the
* server (an item write carrying a gated field gets 422 CONFIG_DISABLED);
* `itemTypesEnabled` is advisory — items hold no item-type reference, so the frontend
* hiding the builder's type section IS the enforcement. That distinction is surfaced in
* the UI rather than hidden, because it changes what "off" actually guarantees.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<keyof ProductConfig | null>(null)
function load() {
setError(null)
productConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
[flag]: next,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
} finally {
setSaving(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
<p className="text-base text-muted-foreground">
Switch optional product features on or off for this deployment (FR-MD-11).
</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
<ToggleRow
label="Subcategories"
description="Adds one optional level below a category. Off ⇒ items attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
/>
<ToggleRow
label="Brands"
description="Items may carry a brand."
checked={config.brandsEnabled}
busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)}
/>
<ToggleRow
label="Item types"
description="The item builder offers Color / Size / Material dimensions when creating items."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
note="Advisory: the app honours this, but the server cannot enforce it — items store no item-type reference. Turning it off hides the builder's section; it does not reject anything."
/>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
const LABELS: Record<string, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Item types",
}
function ToggleRow({
label,
description,
checked,
busy,
onChange,
note,
}: {
label: string
description: string
checked: boolean
busy: boolean
onChange: (next: boolean) => void
note?: string
}) {
return (
<div className="flex items-start justify-between gap-6 border-t py-4 first:border-t-0">
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">{label}</span>
<span className="text-sm text-muted-foreground">{description}</span>
{note && (
<span className="mt-1 inline-flex items-start gap-1.5 text-sm text-amber-700">
<Info className="mt-0.5 size-4 shrink-0" />
{note}
</span>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} disabled={busy} aria-label={label} />
</div>
)
}
@@ -1,444 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn"
import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
binId: number | null
qty: string
unitCost: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `egline-${keySeq}`
}
function emptyLine(): DraftLine {
return {
key: newKey(),
poLineId: null,
itemId: null,
uomId: null,
binId: null,
qty: "",
unitCost: "",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
}
}
export default function EditGrnPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const grnId = Number(params.id)
const [grn, setGrn] = useState<Grn | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([])
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!Number.isFinite(grnId)) return
Promise.all([
grnsApi.get(grnId),
warehousesApi.list(),
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
])
.then(([g, wh, it, uo]) => {
if (g.status !== "Draft") {
setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`)
setGrn(g)
return
}
setGrn(g)
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
setWarehouseId(g.warehouseId)
setLines(
g.lines.map(
(l): DraftLine => ({
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
binId: l.binId,
qty: String(l.qty),
unitCost: String(l.unitCost),
holdStatus: l.holdStatus,
batchNo: "",
expiryDate: "",
serialNumbersText: "",
})
)
)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [grnId])
useEffect(() => {
if (!warehouseId) {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [warehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeLine(key: string) {
setLines((prev) => prev.filter((l) => l.key !== key))
}
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
async function handleSubmit() {
if (!grn) return
setSubmitError(null)
setHeaderError(null)
if (!warehouseId) {
setHeaderError("Select a warehouse.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const errors = validateLine({
itemId: line.itemId,
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
uomId: l.uomId as number,
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
}
})
setSubmitting(true)
try {
const updated = await grnsApi.update(grn.grnId, {
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: warehouseId as number,
lines: payloadLines,
})
toast.success("GRN updated", `${updated.docNo} saved.`)
router.push(`/dashboard/receiving/grn/${updated.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not update GRN", errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (loadError) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-2xl font-bold text-foreground">Edit GRN</h1>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
</div>
)
}
const loading = !grn || !warehouses || !items || !uoms
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Edit {grn?.docNo ?? "GRN"}</h1>
<p className="text-base text-muted-foreground">Only Draft GRNs can be edited confirming posts stock layers permanently.</p>
</div>
</div>
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
{lines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link
href={`/dashboard/receiving/grn/${grnId}`}
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving…" : "Save changes"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -50,7 +50,7 @@ export default function GrnDetailPage() {
useEffect(() => {
if (!grn) return
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
}, [grn?.warehouseId])
function itemFor(itemId: number) {
@@ -112,7 +112,7 @@ export default function NewGrnPage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
function switchMode(next: Mode) {
@@ -2,14 +2,13 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -23,7 +22,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -40,7 +38,6 @@ export default function GrnListPage() {
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
// Debounce the search box so typing doesn't refetch on every keystroke.
useEffect(() => {
@@ -71,19 +68,6 @@ export default function GrnListPage() {
useEffect(load, [page, query, status])
async function handleDelete(grn: GrnSummary) {
setDeletingId(grn.grnId)
try {
await grnsApi.remove(grn.grnId)
toast.success("GRN deleted", `${grn.docNo} has been removed.`)
load()
} catch (err) {
toast.error("Could not delete GRN", errorMessage(err))
} finally {
setDeletingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All"
return (
@@ -196,7 +180,6 @@ export default function GrnListPage() {
</TableHeader>
<TableBody>
{grns.map((grn) => {
const isDraft = grn.status === "Draft"
return (
<TableRow key={grn.grnId}>
<TableCell className="px-3 py-3.5">
@@ -224,47 +207,9 @@ export default function GrnListPage() {
<Eye className="size-4" />
</Link>
{isDraft ? (
<Link
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${grn.docNo}`}
>
<Pencil className="size-4" />
</Link>
) : (
<Button
variant="ghost"
size="icon-sm"
disabled
aria-label={`Edit ${grn.docNo} (not editable once ${grn.status.toLowerCase()})`}
>
<Pencil className="size-4" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${grn.docNo}`}
disabled={!isDraft || deletingId === grn.grnId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${grn.docNo}?`}
description="This permanently removes the draft GRN. It has not been confirmed, so no stock layers or ledger entries exist yet."
confirmLabel="Delete"
onConfirm={() => handleDelete(grn)}
/>
</AlertDialog>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
a GRN. A receipt is corrected with a reversing document, never
edited or erased (FR-X-05). */}
</div>
</TableCell>
</TableRow>
@@ -28,9 +28,9 @@ export default function StockEnquiryPage() {
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
useEffect(() => {
Promise.all([stockApi.onHandList(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([r, it, wh]) => {
setRows(r)
setRows(r.items)
setItems(it.items)
setWarehouses(wh.items)
})
@@ -42,9 +42,11 @@ export default function ReorderAlertsPage() {
const key = `${alert.itemId}-${alert.warehouseId}`
setRequesting(key)
try {
// The server returns the full requisition; the suggested qty is on its line.
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
setRequested((prev) => new Set(prev).add(key))
toast.success("Requisition created", `${res.docNo} for ${res.qty} units.`)
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
} catch (err) {
toast.error("Could not create requisition", errorMessage(err))
} finally {
@@ -71,7 +71,7 @@ export default function NewTransferPage() {
setSrcBins([])
return
}
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
warehousesApi.listBins(srcWarehouseId).then(setSrcBins).catch(() => setSrcBins([]))
}, [srcWarehouseId])
useEffect(() => {
@@ -79,7 +79,7 @@ export default function NewTransferPage() {
setDestBins([])
return
}
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
warehousesApi.listBins(destWarehouseId).then(setDestBins).catch(() => setDestBins([]))
}, [destWarehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
import { wastageApi, wastageReasonCodeIds } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -42,10 +42,9 @@ export default function NewWastagePage() {
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setWarehouses(wh.items)
setItems(it.items)
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
@@ -55,7 +54,7 @@ export default function NewWastagePage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
async function handleSubmit() {

Some files were not shown because too many files have changed in this diff Show More