Merge pull request 'Feat/be inventory' (#10) from feat/be-inventory into Dev
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -125,16 +125,45 @@ public sealed class AuthController : ControllerBase
|
|||||||
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
||||||
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), 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")]
|
[HttpPost("logout")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[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);
|
AuthCookieWriter.ClearSession(Response);
|
||||||
return NoContent();
|
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")]
|
[HttpPut("me")]
|
||||||
[ValidateCsrf]
|
[ValidateCsrf]
|
||||||
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
[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.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace ERPCore.Controllers;
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
/// <summary>
|
||||||
|
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
|
||||||
|
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
|
||||||
|
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||||
|
/// </summary>
|
||||||
[Route("api/v1/categories")]
|
[Route("api/v1/categories")]
|
||||||
public sealed class CategoriesController : ApiControllerBase
|
public sealed class CategoriesController : ApiControllerBase
|
||||||
{
|
{
|
||||||
@@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase
|
|||||||
|
|
||||||
public CategoriesController(ICategoryService categories) => _categories = categories;
|
public CategoriesController(ICategoryService categories) => _categories = categories;
|
||||||
|
|
||||||
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
|
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
|
||||||
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
|
=> Ok(await _categories.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{categoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _categories.GetAsync(categoryId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var dto = await _categories.CreateAsync(request, ct);
|
var result = await _categories.CreateAsync(request, ct);
|
||||||
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{categoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<CategoryDto>> Update(
|
||||||
|
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{categoryId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(
|
||||||
|
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _categories.SetStatusAsync(categoryId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subcategories — nested under their parent category (docs/11 §2.3).
|
||||||
|
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
|
||||||
|
|
||||||
|
[HttpGet("{categoryId:int}/subcategories")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
|
||||||
|
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
|
||||||
|
|
||||||
|
[HttpPost("{categoryId:int}/subcategories")]
|
||||||
|
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
|
||||||
|
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Grn;
|
using ERPCore.Dtos.Grn;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
|
|||||||
|
|
||||||
public GrnsController(IGrnService grns) => _grns = grns;
|
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}")]
|
[HttpGet("{grnId:int}")]
|
||||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[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] PageQuery query,
|
||||||
[FromQuery] EntityStatus? status,
|
[FromQuery] EntityStatus? status,
|
||||||
[FromQuery] int? categoryId,
|
[FromQuery] int? categoryId,
|
||||||
|
[FromQuery] int? subCategoryId,
|
||||||
|
[FromQuery] int? brandId,
|
||||||
[FromQuery] TrackingMode? trackingMode,
|
[FromQuery] TrackingMode? trackingMode,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
|
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||||
|
|
||||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||||
[HttpGet("{itemId:int}")]
|
[HttpGet("{itemId:int}")]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
|
||||||
|
/// feature gate for subcategories/brands/item-types.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
|
||||||
|
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
|
||||||
|
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
|
||||||
|
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
|
||||||
|
/// the accepted Phase-1 posture, consistent with every other endpoint.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/product-config")]
|
||||||
|
public sealed class ProductConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IProductConfigService _config;
|
||||||
|
|
||||||
|
public ProductConfigController(IProductConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<ProductConfigDto>> Update(
|
||||||
|
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
|
|||||||
|
|
||||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
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>
|
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
|
||||||
=> Ok(await _requisitions.ListAsync(query, ct));
|
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _requisitions.ListAsync(query, status, ct));
|
||||||
|
|
||||||
[HttpGet("{requisitionId:int}")]
|
[HttpGet("{requisitionId:int}")]
|
||||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
|
|||||||
|
|
||||||
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
|
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}")]
|
[HttpGet("{rfqId:int}")]
|
||||||
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
|||||||
|
|
||||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
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>
|
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||||
|
|||||||
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
|
|||||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, 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")]
|
[HttpGet("ledger")]
|
||||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
||||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
[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")]
|
[HttpGet("valuation")]
|
||||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
|
|||||||
|
|
||||||
public StockCountsController(ICountService counts) => _counts = counts;
|
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}")]
|
[HttpGet("{countId:int}")]
|
||||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
|
|||||||
|
|
||||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
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}")]
|
[HttpGet("{transferId:int}")]
|
||||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
|
||||||
|
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||||
|
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class Brand
|
||||||
|
{
|
||||||
|
public int BrandId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
|
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
|
||||||
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
|
||||||
|
/// <c>parent_id</c> tree was replaced in migration #2).
|
||||||
|
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||||
|
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Category
|
public class Category
|
||||||
{
|
{
|
||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
public int? ParentId { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public Category? Parent { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public ICollection<Category> Children { get; set; } = new List<Category>();
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ public class Item
|
|||||||
public int CategoryId { get; set; }
|
public int CategoryId { get; set; }
|
||||||
public Category? Category { get; set; }
|
public Category? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
public SubCategory? SubCategory { get; set; }
|
||||||
|
|
||||||
|
public int? BrandId { get; set; }
|
||||||
|
public Brand? Brand { get; set; }
|
||||||
|
|
||||||
public int BaseUomId { get; set; }
|
public int BaseUomId { get; set; }
|
||||||
public Uom? BaseUom { get; set; }
|
public Uom? BaseUom { get; set; }
|
||||||
|
|
||||||
public int? DefaultVendorId { get; set; }
|
public int? DefaultVendorId { get; set; }
|
||||||
public Vendor? DefaultVendor { get; set; }
|
public Vendor? DefaultVendor { get; set; }
|
||||||
|
|
||||||
public ItemType ItemType { get; set; }
|
public StockNature StockNature { get; set; }
|
||||||
public TrackingMode TrackingMode { get; set; }
|
public TrackingMode TrackingMode { get; set; }
|
||||||
public string? TaxClass { get; set; }
|
public string? TaxClass { get; set; }
|
||||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
|
||||||
|
/// Material.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||||
|
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||||
|
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||||
|
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||||
|
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
|
||||||
|
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||||
|
/// </para>
|
||||||
|
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||||
|
/// which is what the old <c>ItemType</c> enum became.
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ItemType
|
||||||
|
{
|
||||||
|
public int ItemTypeId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
|
||||||
|
/// gating optional product master-data features.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
||||||
|
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
|
||||||
|
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
|
||||||
|
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
|
||||||
|
/// so there is nothing on a write to reject; the frontend honours it by hiding the
|
||||||
|
/// builder's type section. Reads are never gated, so existing data stays visible after a
|
||||||
|
/// flag is switched off.
|
||||||
|
/// </para>
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ProductConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 — the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
public bool SubcategoriesEnabled { get; set; } = true;
|
||||||
|
public bool BrandsEnabled { get; set; } = true;
|
||||||
|
public bool ItemTypesEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subcategory — the single optional level below <see cref="Category"/> (FR-MD-04).
|
||||||
|
/// Replaces the former self-referencing CATEGORY.parent_id tree: the hierarchy is
|
||||||
|
/// exactly two levels deep and cannot nest further. Referenced optionally by
|
||||||
|
/// <see cref="Item.SubCategoryId"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class SubCategory
|
||||||
|
{
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int CategoryId { get; set; }
|
||||||
|
public Category? Category { get; set; }
|
||||||
|
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace ERPCore.Domain.Enums;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Item classification (FR-MD-01). Values match the <c>itemType</c> enum in
|
|
||||||
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
|
||||||
/// </summary>
|
|
||||||
public enum ItemType
|
|
||||||
{
|
|
||||||
Stocked,
|
|
||||||
NonStocked,
|
|
||||||
Service
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an item holds stock (FR-MD-01). Values match the <c>stockNature</c> enum in
|
||||||
|
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
||||||
|
/// Renamed from <c>ItemType</c> so that name could be taken by the ItemType master
|
||||||
|
/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9).
|
||||||
|
/// </summary>
|
||||||
|
public enum StockNature
|
||||||
|
{
|
||||||
|
Stocked,
|
||||||
|
NonStocked,
|
||||||
|
Service
|
||||||
|
}
|
||||||
@@ -175,5 +175,9 @@ public sealed class TwoFaStatusResponse
|
|||||||
|
|
||||||
public sealed class LogoutRequest
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Brands;
|
||||||
|
|
||||||
|
/// <summary>Brand resource (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||||
|
public sealed record BrandDto(
|
||||||
|
int BrandId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||||
|
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||||
|
|
||||||
|
public sealed class CreateBrandRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateBrandRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateBrandStatusRequest
|
||||||
|
{
|
||||||
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
|
}
|
||||||
@@ -1,15 +1,56 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Dtos.Categories;
|
namespace ERPCore.Dtos.Categories;
|
||||||
|
|
||||||
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
|
||||||
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
|
// The hierarchy is exactly two levels: Category → SubCategory. The former
|
||||||
|
// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in
|
||||||
|
// migration #2 — see docs/10 Part C.1.
|
||||||
|
|
||||||
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
|
/// <summary>Category resource — the top level.</summary>
|
||||||
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
|
public sealed record CategoryDto(
|
||||||
|
int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>Subcategory resource — the single optional level below a category.</summary>
|
||||||
|
public sealed record SubCategoryDto(
|
||||||
|
int SubCategoryId, int CategoryId, string Name, EntityStatus Status,
|
||||||
|
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||||
|
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||||
|
|
||||||
public sealed class CreateCategoryRequest
|
public sealed class CreateCategoryRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
public int? ParentId { get; set; }
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateCategoryRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateCategoryStatusRequest
|
||||||
|
{
|
||||||
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Body for <c>POST /categories/{categoryId}/subcategories</c>; the parent comes from the route.</summary>
|
||||||
|
public sealed class CreateSubCategoryRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body for <c>PUT /subcategories/{id}</c>. Name only — a subcategory cannot be reparented,
|
||||||
|
/// since moving one would silently invalidate the category of every item referencing it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateSubCategoryRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateSubCategoryStatusRequest
|
||||||
|
{
|
||||||
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
|
||||||
|
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductConfigDto(
|
||||||
|
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||||
|
DateTime? UpdatedAt, int? UpdatedBy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||||
|
/// <para>
|
||||||
|
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
|
||||||
|
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
|
||||||
|
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
|
||||||
|
/// actually bind — an omitted flag is a 400, not an accidental disable.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateProductConfigRequest
|
||||||
|
{
|
||||||
|
[Required] public bool? SubcategoriesEnabled { get; set; }
|
||||||
|
[Required] public bool? BrandsEnabled { get; set; }
|
||||||
|
[Required] public bool? ItemTypesEnabled { get; set; }
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ public sealed record GrnDto(
|
|||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
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(
|
public sealed record CreatedLayerDto(
|
||||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
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; }
|
||||||
|
}
|
||||||
@@ -7,18 +7,28 @@ namespace ERPCore.Dtos.Items;
|
|||||||
|
|
||||||
/// <summary>Row shape for <c>GET /items</c>.</summary>
|
/// <summary>Row shape for <c>GET /items</c>.</summary>
|
||||||
public sealed record ItemListItemDto(
|
public sealed record ItemListItemDto(
|
||||||
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
|
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||||
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||||
string? TaxClass, EntityStatus Status);
|
string? TaxClass, EntityStatus Status);
|
||||||
|
|
||||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
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(
|
public sealed record ItemDetailDto(
|
||||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||||
int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||||
|
StockNature StockNature, TrackingMode TrackingMode,
|
||||||
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||||
|
IReadOnlyList<UomConversionDto> Conversions,
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
/// <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)
|
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||||
|
|
||||||
|
// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g.
|
||||||
|
// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here
|
||||||
|
// by design — items carry no item-type reference (docs/10 Part C.9).
|
||||||
|
|
||||||
public sealed class CreateItemRequest
|
public sealed class CreateItemRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(1000)] public string? Description { get; set; }
|
[StringLength(1000)] public string? Description { get; set; }
|
||||||
[Required] public int CategoryId { get; set; }
|
[Required] public int CategoryId { get; set; }
|
||||||
|
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
/// <summary>Optional. Rejected when brands are disabled.</summary>
|
||||||
|
public int? BrandId { get; set; }
|
||||||
[Required] public int BaseUomId { get; set; }
|
[Required] public int BaseUomId { get; set; }
|
||||||
public int? DefaultVendorId { get; set; }
|
public int? DefaultVendorId { get; set; }
|
||||||
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
|
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||||
[StringLength(20)] public string? TaxClass { get; set; }
|
[StringLength(20)] public string? TaxClass { get; set; }
|
||||||
}
|
}
|
||||||
@@ -52,9 +70,13 @@ public sealed class UpdateItemRequest
|
|||||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
[StringLength(1000)] public string? Description { get; set; }
|
[StringLength(1000)] public string? Description { get; set; }
|
||||||
[Required] public int CategoryId { get; set; }
|
[Required] public int CategoryId { get; set; }
|
||||||
|
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
/// <summary>Optional. Rejected when brands are disabled.</summary>
|
||||||
|
public int? BrandId { get; set; }
|
||||||
[Required] public int BaseUomId { get; set; }
|
[Required] public int BaseUomId { get; set; }
|
||||||
public int? DefaultVendorId { get; set; }
|
public int? DefaultVendorId { get; set; }
|
||||||
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
|
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||||
[StringLength(20)] public string? TaxClass { get; set; }
|
[StringLength(20)] public string? TaxClass { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
|||||||
|
|
||||||
public sealed record PurchaseReturnDto(
|
public sealed record PurchaseReturnDto(
|
||||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
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 ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
|
|||||||
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
||||||
|
|
||||||
public sealed record RequisitionSummaryDto(
|
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 ----
|
// 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(
|
public sealed record RfqDto(
|
||||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
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 QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
|
||||||
|
|
||||||
public sealed record VendorQuotationDto(
|
public sealed record VendorQuotationDto(
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
|
|||||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
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 ----------------------------------------------------------------------
|
// Requests ----------------------------------------------------------------------
|
||||||
|
|
||||||
public sealed class CreateAdjustmentLineInput
|
public sealed class CreateAdjustmentLineInput
|
||||||
|
|||||||
@@ -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 CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
|
||||||
|
|
||||||
public sealed record CountDto(
|
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);
|
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
|
|||||||
|
|
||||||
public sealed record TransferDto(
|
public sealed record TransferDto(
|
||||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
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);
|
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.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
@@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
|
|||||||
builder.HasKey(c => c.CategoryId);
|
builder.HasKey(c => c.CategoryId);
|
||||||
|
|
||||||
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||||
|
builder.HasIndex(c => c.Name).IsUnique();
|
||||||
|
|
||||||
builder.HasOne(c => c.Parent)
|
builder.Property(c => c.Status)
|
||||||
.WithMany(c => c.Children)
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
.HasForeignKey(c => c.ParentId)
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
builder.HasIndex(c => c.ParentId);
|
builder.Property(c => c.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasIndex(c => c.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
builder.Property(i => i.Description).HasMaxLength(1000);
|
builder.Property(i => i.Description).HasMaxLength(1000);
|
||||||
builder.Property(i => i.TaxClass).HasMaxLength(20);
|
builder.Property(i => i.TaxClass).HasMaxLength(20);
|
||||||
|
|
||||||
builder.Property(i => i.ItemType)
|
builder.Property(i => i.StockNature)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(i => i.TrackingMode)
|
builder.Property(i => i.TrackingMode)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
@@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
.HasForeignKey(i => i.CategoryId)
|
.HasForeignKey(i => i.CategoryId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(i => i.SubCategory)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.SubCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne(i => i.Brand)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.BrandId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
builder.HasOne(i => i.BaseUom)
|
builder.HasOne(i => i.BaseUom)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(i => i.BaseUomId)
|
.HasForeignKey(i => i.BaseUomId)
|
||||||
@@ -49,5 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
|
|
||||||
builder.HasIndex(i => i.Status);
|
builder.HasIndex(i => i.Status);
|
||||||
builder.HasIndex(i => i.CategoryId);
|
builder.HasIndex(i => i.CategoryId);
|
||||||
|
builder.HasIndex(i => i.BrandId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no
|
||||||
|
/// relationships here — nothing references this table (docs/10 Part C.9).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ItemType> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("item_types");
|
||||||
|
builder.HasKey(t => t.ItemTypeId);
|
||||||
|
|
||||||
|
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
|
||||||
|
builder.HasIndex(t => t.Name).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(t => t.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|
||||||
|
builder.Property(t => t.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(t => t.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasIndex(t => t.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the singleton product-configuration row (FR-MD-11). The check constraint
|
||||||
|
/// is what makes "singleton" a database guarantee rather than a convention.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<ProductConfig>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ProductConfig> builder)
|
||||||
|
{
|
||||||
|
// The column is created as quoted PascalCase ("ConfigId"), so the constraint must
|
||||||
|
// quote it too — an unquoted config_id would fold to a column that does not exist.
|
||||||
|
builder.ToTable("product_config", t =>
|
||||||
|
t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}"));
|
||||||
|
|
||||||
|
builder.HasKey(c => c.ConfigId);
|
||||||
|
|
||||||
|
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||||
|
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||||
|
|
||||||
|
builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
|
||||||
|
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
|
||||||
|
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(c => c.UpdatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UpdatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class SubCategoryConfiguration : IEntityTypeConfiguration<SubCategory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SubCategory> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("subcategories");
|
||||||
|
builder.HasKey(s => s.SubCategoryId);
|
||||||
|
|
||||||
|
builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(s => s.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|
||||||
|
builder.Property(s => s.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(s => s.RowVersion).IsRowVersion();
|
||||||
|
|
||||||
|
builder.HasOne(s => s.Category)
|
||||||
|
.WithMany(c => c.SubCategories)
|
||||||
|
.HasForeignKey(s => s.CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Names need only be unique within their parent category.
|
||||||
|
builder.HasIndex(s => new { s.CategoryId, s.Name }).IsUnique();
|
||||||
|
builder.HasIndex(s => s.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DataSeeder
|
public static class DataSeeder
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Item type names the frontend builder has always assumed exist (they were hardcoded
|
||||||
|
/// while it ran on mock data). Seeded so the dropdown is not empty on a fresh database;
|
||||||
|
/// users add their own (e.g. Material) from the admin screen.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] StandardItemTypes = ["Color", "Size"];
|
||||||
|
|
||||||
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
|
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
|
||||||
[
|
[
|
||||||
("DMG", "Damage", ReasonContext.Adjustment),
|
("DMG", "Damage", ReasonContext.Adjustment),
|
||||||
@@ -26,6 +33,15 @@ public static class DataSeeder
|
|||||||
];
|
];
|
||||||
|
|
||||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||||
|
dirty |= await SeedItemTypesAsync(db, ct);
|
||||||
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
|
|
||||||
|
if (dirty) await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existing = await db.ReasonCodes
|
var existing = await db.ReasonCodes
|
||||||
.Select(r => new { r.Context, r.Code })
|
.Select(r => new { r.Context, r.Code })
|
||||||
@@ -37,9 +53,44 @@ public static class DataSeeder
|
|||||||
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
|
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (toAdd.Count == 0) return;
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
db.ReasonCodes.AddRange(toAdd);
|
db.ReasonCodes.AddRange(toAdd);
|
||||||
await db.SaveChangesAsync(ct);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> SeedItemTypesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var have = await db.ItemTypes.Select(t => t.Name).ToListAsync(ct);
|
||||||
|
|
||||||
|
var toAdd = StandardItemTypes
|
||||||
|
.Where(name => !have.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.Select(name => new ItemType { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (toAdd.Count == 0) return false;
|
||||||
|
|
||||||
|
db.ItemTypes.AddRange(toAdd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the singleton product-config row exists (FR-MD-11). Migration #2 inserts it,
|
||||||
|
/// so this only fires for a database built some other way — but without it every Item
|
||||||
|
/// write would 404 on the missing config, so it is worth the one query at startup.
|
||||||
|
/// New deployments start with all features on.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<bool> SeedProductConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await db.ProductConfig.AnyAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)) return false;
|
||||||
|
|
||||||
|
db.ProductConfig.Add(new ProductConfig
|
||||||
|
{
|
||||||
|
ConfigId = ProductConfig.SingletonId,
|
||||||
|
SubcategoriesEnabled = true,
|
||||||
|
BrandsEnabled = true,
|
||||||
|
ItemTypesEnabled = true
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace ERPCore.Infra.Persistence;
|
namespace ERPCore.Infra.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// EF Core context for the ERP database. The 38 Phase 1 entities and their
|
/// EF Core context for the ERP database. The 42 Phase 1 entities and their
|
||||||
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
||||||
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
|
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
|
||||||
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
|
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
|
||||||
@@ -23,6 +23,10 @@ public class ErpDbContext : DbContext
|
|||||||
|
|
||||||
// --- Master Data (docs/10 Part C.1) ---
|
// --- Master Data (docs/10 Part C.1) ---
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
|
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||||
|
public DbSet<Brand> Brands => Set<Brand>();
|
||||||
|
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
|
||||||
|
public DbSet<ItemType> ItemTypes => Set<ItemType>();
|
||||||
public DbSet<Uom> Uoms => Set<Uom>();
|
public DbSet<Uom> Uoms => Set<Uom>();
|
||||||
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
||||||
public DbSet<Item> Items => Set<Item>();
|
public DbSet<Item> Items => Set<Item>();
|
||||||
@@ -30,6 +34,8 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<Vendor> Vendors => Set<Vendor>();
|
public DbSet<Vendor> Vendors => Set<Vendor>();
|
||||||
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
|
||||||
public DbSet<Bin> Bins => Set<Bin>();
|
public DbSet<Bin> Bins => Set<Bin>();
|
||||||
|
/// <summary>Singleton row (FR-MD-11).</summary>
|
||||||
|
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
|
||||||
|
|
||||||
// --- Cross-cutting (docs/10 Part C.7) ---
|
// --- Cross-cutting (docs/10 Part C.7) ---
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
|
|||||||
+2454
File diff suppressed because it is too large
Load Diff
+442
@@ -0,0 +1,442 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the Brand / SubCategory / ItemType masters and the singleton product config,
|
||||||
|
/// and converts CATEGORY from a self-nesting tree into a fixed two-level
|
||||||
|
/// Category → SubCategory hierarchy (docs/10 Part C.1).
|
||||||
|
/// <para>
|
||||||
|
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
|
||||||
|
/// <c>categories.ParentId</c> outright, which would have silently flattened every
|
||||||
|
/// child category into a root and left items pointing at what is now a top-level
|
||||||
|
/// category — losing the parent entirely. The hand-written steps below (marked
|
||||||
|
/// "data migration") move child categories into <c>subcategories</c> and repoint items
|
||||||
|
/// onto the correct (category, subcategory) pair before the column goes away.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// NOTE: the ParentId drop is deliberately deferred to the bottom of this method —
|
||||||
|
// the data migration reads it. Order here is load-bearing.
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "ItemType",
|
||||||
|
table: "items",
|
||||||
|
newName: "StockNature");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BrandId",
|
||||||
|
table: "items",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "categories",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "categories",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Active");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "categories",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<uint>(
|
||||||
|
name: "xmin",
|
||||||
|
table: "categories",
|
||||||
|
type: "xid",
|
||||||
|
rowVersion: true,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0u);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "brands",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BrandId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_brands", x => x.BrandId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "item_types",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "product_config",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_product_config", x => x.ConfigId);
|
||||||
|
table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_product_config_users_UpdatedBy",
|
||||||
|
column: x => x.UpdatedBy,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "UserId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subcategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SubCategoryId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subcategories", x => x.SubCategoryId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subcategories_categories_CategoryId",
|
||||||
|
column: x => x.CategoryId,
|
||||||
|
principalTable: "categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DATA MIGRATION — must run before ParentId is dropped.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Existing categories predate CreatedAt; the added column defaulted them to
|
||||||
|
// 0001-01-01. Stamp them with the migration time instead of a sentinel date.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc';
|
||||||
|
");
|
||||||
|
|
||||||
|
// Carry the old category id alongside each new subcategory so items can be
|
||||||
|
// repointed by join below. Dropped again once the repoint is done.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
ALTER TABLE subcategories ADD COLUMN legacy_category_id integer;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Walk the old tree to its roots. The previous model allowed unlimited nesting,
|
||||||
|
// but the new one is exactly two levels — so a category at any depth below the
|
||||||
|
// root collapses into a subcategory of its ROOT ancestor (a grandchild cannot
|
||||||
|
// become a subcategory of its immediate parent, since that parent is itself
|
||||||
|
// ceasing to be a category).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
WITH RECURSIVE tree AS (
|
||||||
|
SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id
|
||||||
|
FROM categories
|
||||||
|
WHERE ""ParentId"" IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id
|
||||||
|
FROM categories c
|
||||||
|
JOIN tree t ON c.""ParentId"" = t.""CategoryId""
|
||||||
|
)
|
||||||
|
INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id)
|
||||||
|
SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId""
|
||||||
|
FROM tree t
|
||||||
|
WHERE t.""ParentId"" IS NOT NULL;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Repoint items: an item that pointed at a child category now carries the root
|
||||||
|
// category plus the subcategory it actually meant.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE items i
|
||||||
|
SET ""SubCategoryId"" = s.""SubCategoryId"",
|
||||||
|
""CategoryId"" = s.""CategoryId""
|
||||||
|
FROM subcategories s
|
||||||
|
WHERE s.legacy_category_id = i.""CategoryId"";
|
||||||
|
");
|
||||||
|
|
||||||
|
// The self-FK must go before the delete, or RESTRICT rejects removing a parent
|
||||||
|
// whose own child row is still present.
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_categories_categories_ParentId",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
// Every non-root category now lives in `subcategories`, and no item references
|
||||||
|
// one any more (repointed above), so the rows can go.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DELETE FROM categories WHERE ""ParentId"" IS NOT NULL;
|
||||||
|
ALTER TABLE subcategories DROP COLUMN legacy_category_id;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_categories_ParentId",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentId",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
// Seed the singleton config (FR-MD-11) — all features on. Item writes read this
|
||||||
|
// row, so it must exist before the app serves a single request.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"")
|
||||||
|
VALUES (1, TRUE, TRUE, TRUE)
|
||||||
|
ON CONFLICT (""ConfigId"") DO NOTHING;
|
||||||
|
");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_items_BrandId",
|
||||||
|
table: "items",
|
||||||
|
column: "BrandId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_items_SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
column: "SubCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_categories_Name",
|
||||||
|
table: "categories",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_categories_Status",
|
||||||
|
table: "categories",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_brands_Name",
|
||||||
|
table: "brands",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_brands_Status",
|
||||||
|
table: "brands",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_item_types_Name",
|
||||||
|
table: "item_types",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_item_types_Status",
|
||||||
|
table: "item_types",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_product_config_UpdatedBy",
|
||||||
|
table: "product_config",
|
||||||
|
column: "UpdatedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subcategories_CategoryId_Name",
|
||||||
|
table: "subcategories",
|
||||||
|
columns: new[] { "CategoryId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subcategories_Status",
|
||||||
|
table: "subcategories",
|
||||||
|
column: "Status");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_items_brands_BrandId",
|
||||||
|
table: "items",
|
||||||
|
column: "BrandId",
|
||||||
|
principalTable: "brands",
|
||||||
|
principalColumn: "BrandId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_items_subcategories_SubCategoryId",
|
||||||
|
table: "items",
|
||||||
|
column: "SubCategoryId",
|
||||||
|
principalTable: "subcategories",
|
||||||
|
principalColumn: "SubCategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverses the schema change and puts the subcategory data back where it came from.
|
||||||
|
/// <para>
|
||||||
|
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
|
||||||
|
/// discarded exactly what <see cref="Up"/> preserved. Instead each subcategory is
|
||||||
|
/// restored as a child category and its items are repointed back onto it. This is
|
||||||
|
/// not perfectly lossless: the old tree's depth is gone (a former grandchild comes
|
||||||
|
/// back as a direct child of its root), and Brand data cannot survive a schema that
|
||||||
|
/// has nowhere to put it.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_items_brands_BrandId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_items_subcategories_SubCategoryId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
// Restore the parent column + self-FK first so subcategories have somewhere to
|
||||||
|
// land, then move them back before the table is dropped.
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ParentId",
|
||||||
|
table: "categories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DATA MIGRATION (reverse) — must run before `subcategories` is dropped.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Each subcategory becomes a child category again under the same parent.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id)
|
||||||
|
SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId""
|
||||||
|
FROM subcategories s;
|
||||||
|
");
|
||||||
|
|
||||||
|
// Items that carried a subcategory point back at the restored child category.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE items i
|
||||||
|
SET ""CategoryId"" = c.""CategoryId""
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.legacy_subcategory_id = i.""SubCategoryId"";
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
ALTER TABLE categories DROP COLUMN legacy_subcategory_id;
|
||||||
|
");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "brands");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "item_types");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "product_config");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subcategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_items_BrandId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_items_SubCategoryId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_categories_Name",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_categories_Status",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BrandId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubCategoryId",
|
||||||
|
table: "items");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "xmin",
|
||||||
|
table: "categories");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "StockNature",
|
||||||
|
table: "items",
|
||||||
|
newName: "ItemType");
|
||||||
|
|
||||||
|
// ParentId itself was re-added at the top of this method, ahead of the reverse
|
||||||
|
// data migration that populates it.
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_categories_ParentId",
|
||||||
|
table: "categories",
|
||||||
|
column: "ParentId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_categories_categories_ParentId",
|
||||||
|
table: "categories",
|
||||||
|
column: "ParentId",
|
||||||
|
principalTable: "categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("bins", (string)null);
|
b.ToTable("bins", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BrandId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BrandId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("BrandId");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("brands", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
@@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int?>("ParentId")
|
b.Property<uint>("RowVersion")
|
||||||
.HasColumnType("integer");
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("CategoryId");
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
b.HasIndex("ParentId");
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.ToTable("categories", (string)null);
|
b.ToTable("categories", (string)null);
|
||||||
});
|
});
|
||||||
@@ -273,6 +334,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Property<int>("BaseUomId")
|
b.Property<int>("BaseUomId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("BrandId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -286,11 +350,6 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
b.Property<string>("ItemType")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -314,6 +373,14 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(20)")
|
.HasColumnType("character varying(20)")
|
||||||
.HasDefaultValue("Active");
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<string>("StockNature")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int?>("SubCategoryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("TaxClass")
|
b.Property<string>("TaxClass")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
@@ -330,6 +397,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("BaseUomId");
|
b.HasIndex("BaseUomId");
|
||||||
|
|
||||||
|
b.HasIndex("BrandId");
|
||||||
|
|
||||||
b.HasIndex("CategoryId");
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
b.HasIndex("DefaultVendorId");
|
b.HasIndex("DefaultVendorId");
|
||||||
@@ -339,6 +408,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("SubCategoryId");
|
||||||
|
|
||||||
b.ToTable("items", (string)null);
|
b.ToTable("items", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,6 +445,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("item_reorders", (string)null);
|
b.ToTable("item_reorders", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ItemTypeId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemTypeId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("ItemTypeId");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("item_types", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("JournalId")
|
b.Property<int>("JournalId")
|
||||||
@@ -490,6 +603,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("po_lines", (string)null);
|
b.ToTable("po_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ConfigId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("BrandsEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("ItemTypesEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<bool>("SubcategoriesEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("UpdatedBy")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("ConfigId");
|
||||||
|
|
||||||
|
b.HasIndex("UpdatedBy");
|
||||||
|
|
||||||
|
b.ToTable("product_config", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("PoId")
|
b.Property<int>("PoId")
|
||||||
@@ -1239,6 +1394,51 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("stock_transfer_lines", (string)null);
|
b.ToTable("stock_transfer_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SubCategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SubCategoryId"));
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("SubCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("subcategories", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("UomId")
|
b.Property<int>("UomId")
|
||||||
@@ -1516,16 +1716,6 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
|
|
||||||
.WithMany("Children")
|
|
||||||
.HasForeignKey("ParentId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.Navigation("Parent");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
@@ -1616,6 +1806,11 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Brand", "Brand")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BrandId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CategoryId")
|
.HasForeignKey("CategoryId")
|
||||||
@@ -1627,11 +1822,20 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasForeignKey("DefaultVendorId")
|
.HasForeignKey("DefaultVendorId")
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("BaseUom");
|
b.Navigation("BaseUom");
|
||||||
|
|
||||||
|
b.Navigation("Brand");
|
||||||
|
|
||||||
b.Navigation("Category");
|
b.Navigation("Category");
|
||||||
|
|
||||||
b.Navigation("DefaultVendor");
|
b.Navigation("DefaultVendor");
|
||||||
|
|
||||||
|
b.Navigation("SubCategory");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||||
@@ -1688,6 +1892,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UpdatedBy")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("UpdatedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||||
@@ -2092,6 +2306,17 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Transfer");
|
b.Navigation("Transfer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
||||||
|
.WithMany("SubCategories")
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
||||||
@@ -2159,7 +2384,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Children");
|
b.Navigation("SubCategories");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
|||||||
builder.Services.AddScoped<IItemService, ItemService>();
|
builder.Services.AddScoped<IItemService, ItemService>();
|
||||||
builder.Services.AddScoped<IUomService, UomService>();
|
builder.Services.AddScoped<IUomService, UomService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
|
builder.Services.AddScoped<IBrandService, BrandService>();
|
||||||
|
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||||||
|
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IStockMutator _mutator;
|
private readonly IStockMutator _mutator;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
|
|
||||||
public AdjustmentService(
|
public AdjustmentService(
|
||||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||||
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
|
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||||
ICurrentUser currentUser, IUnitOfWork uow)
|
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_adjustments = adjustments;
|
_adjustments = adjustments;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_items = items;
|
_items = items;
|
||||||
_reasonCodes = reasonCodes;
|
_reasonCodes = reasonCodes;
|
||||||
|
_ledger = ledger;
|
||||||
_mutator = mutator;
|
_mutator = mutator;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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)
|
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (request.ReasonCodeId is null)
|
if (request.ReasonCodeId is null)
|
||||||
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
return (entity, refs);
|
return (entity, refs);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
return new AdjustmentDto(
|
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
|
||||||
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
|
=> _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)
|
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)
|
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
|
||||||
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
|
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Brands;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Infra.UoW;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency
|
||||||
|
/// per docs/11-BACKEND-PHASE1.md §2.6.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BrandService : IBrandService
|
||||||
|
{
|
||||||
|
private readonly IRepository<Brand> _brands;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
|
public BrandService(IRepository<Brand> brands, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
_brands = brands;
|
||||||
|
_uow = uow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _brands.Query().AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%"));
|
||||||
|
}
|
||||||
|
if (status is not null) q = q.Where(b => b.Status == status);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderBy(b => b.Name)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
|
.Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return PagedResponse<BrandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var brand = await _brands.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||||
|
return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct))
|
||||||
|
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||||
|
|
||||||
|
var brand = new Brand
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _brands.AddAsync(brand, ct);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<BrandDto>> UpdateAsync(
|
||||||
|
int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||||
|
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||||
|
|
||||||
|
if (brand.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
|
||||||
|
&& await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct))
|
||||||
|
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||||
|
|
||||||
|
brand.Name = name;
|
||||||
|
brand.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||||
|
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||||
|
|
||||||
|
brand.Status = status;
|
||||||
|
brand.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Categories;
|
using ERPCore.Dtos.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels:
|
||||||
|
/// categories no longer self-nest, so there is no cycle to detect and no tree to build
|
||||||
|
/// (docs/11-BACKEND-PHASE1.md §2.3).
|
||||||
|
/// </summary>
|
||||||
public sealed class CategoryService : ICategoryService
|
public sealed class CategoryService : ICategoryService
|
||||||
{
|
{
|
||||||
private readonly IRepository<Category> _categories;
|
private readonly IRepository<Category> _categories;
|
||||||
|
private readonly IRepository<SubCategory> _subCategories;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
|
public CategoryService(
|
||||||
|
IRepository<Category> categories,
|
||||||
|
IRepository<SubCategory> subCategories,
|
||||||
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_categories = categories;
|
_categories = categories;
|
||||||
|
_subCategories = subCategories;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
// Categories ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public async Task<PagedResponse<CategoryDto>> ListAsync(
|
||||||
|
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _categories.Query().AsNoTracking();
|
var q = _categories.Query().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
|
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
|
||||||
}
|
}
|
||||||
|
if (status is not null) q = q.Where(c => c.Status == status);
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(c => c.Name)
|
var rows = await q.OrderBy(c => c.Name)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
|
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
|
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var all = await _categories.Query().AsNoTracking()
|
var category = await _categories.Query().AsNoTracking()
|
||||||
.OrderBy(c => c.Name)
|
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
|
||||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
|
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var byParent = all.ToLookup(c => c.ParentId);
|
|
||||||
|
|
||||||
List<CategoryTreeDto> Build(int? parentId) =>
|
|
||||||
byParent[parentId]
|
|
||||||
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Build(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (request.ParentId is not null
|
var name = request.Name.Trim();
|
||||||
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
|
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
|
throw new ConflictException($"A category named '{name}' already exists.");
|
||||||
|
|
||||||
|
var category = new Category
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
|
|
||||||
await _categories.AddAsync(category, ct);
|
await _categories.AddAsync(category, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
|
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<CategoryDto>> UpdateAsync(
|
||||||
|
int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||||
|
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||||
|
|
||||||
|
if (category.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412);
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (!string.Equals(category.Name, name, StringComparison.Ordinal)
|
||||||
|
&& await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct))
|
||||||
|
throw new ConflictException($"A category named '{name}' already exists.");
|
||||||
|
|
||||||
|
category.Name = name;
|
||||||
|
category.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await SaveGuardingConcurrencyAsync("category", ct);
|
||||||
|
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||||
|
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||||
|
|
||||||
|
category.Status = status;
|
||||||
|
category.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subcategories ------------------------------------------------------------
|
||||||
|
|
||||||
|
public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||||
|
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||||
|
throw new NotFoundException($"Category {categoryId} was not found.");
|
||||||
|
|
||||||
|
var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
|
{
|
||||||
|
var term = query.Q.Trim();
|
||||||
|
q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%"));
|
||||||
|
}
|
||||||
|
if (status is not null) q = q.Where(s => s.Status == status);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var rows = await q.OrderBy(s => s.Name)
|
||||||
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
|
.Select(s => new SubCategoryDto(
|
||||||
|
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return PagedResponse<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sub = await _subCategories.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||||
|
return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(
|
||||||
|
int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var category = await _categories.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct)
|
||||||
|
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||||
|
|
||||||
|
if (category.Status != EntityStatus.Active)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422);
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (await _subCategories.Query().AnyAsync(
|
||||||
|
s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct))
|
||||||
|
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
|
||||||
|
|
||||||
|
var sub = new SubCategory
|
||||||
|
{
|
||||||
|
CategoryId = categoryId,
|
||||||
|
Name = name,
|
||||||
|
Status = EntityStatus.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _subCategories.AddAsync(sub, ct);
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(
|
||||||
|
int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||||
|
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||||
|
|
||||||
|
if (sub.RowVersion != expectedRowVersion)
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412);
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
|
||||||
|
&& await _subCategories.Query().AnyAsync(
|
||||||
|
s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct))
|
||||||
|
throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}.");
|
||||||
|
|
||||||
|
// Name only — reparenting is not offered, since it would silently invalidate the
|
||||||
|
// category of every item pointing at this subcategory.
|
||||||
|
sub.Name = name;
|
||||||
|
sub.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await SaveGuardingConcurrencyAsync("subcategory", ct);
|
||||||
|
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||||
|
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||||
|
|
||||||
|
sub.Status = status;
|
||||||
|
sub.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
throw new DomainException(ErrorCodes.ConcurrencyConflict, $"The {label} was modified by another request.", 412);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CategoryDto Map(Category c) => new(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt);
|
||||||
|
|
||||||
|
private static SubCategoryDto MapSub(SubCategory s) => new(
|
||||||
|
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
|
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(
|
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)
|
c.Lines.OrderBy(l => l.CountLineId)
|
||||||
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
|
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Grn;
|
using ERPCore.Dtos.Grn;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var grn = await _grns.Query().AsNoTracking()
|
var grn = await _grns.Query().AsNoTracking()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
|
||||||
public interface IAdjustmentService
|
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);
|
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.Categories;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
/// <summary>Category master business logic (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
/// <summary>
|
||||||
|
/// Category + subcategory master business logic (docs/11-BACKEND-PHASE1.md §2.3).
|
||||||
|
/// The hierarchy is exactly two levels deep; there is no tree endpoint any more.
|
||||||
|
/// </summary>
|
||||||
public interface ICategoryService
|
public interface ICategoryService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
|
Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default);
|
||||||
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<CategoryDto>> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Subcategories of one category. 404s when the category itself does not exist.</summary>
|
||||||
|
Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||||
|
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
|
||||||
public interface ICountService
|
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?> GetAsync(int countId, CancellationToken ct = default);
|
||||||
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
||||||
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest 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;
|
using ERPCore.Dtos.Grn;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
|
||||||
public interface IGrnService
|
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?> GetAsync(int grnId, CancellationToken ct = default);
|
||||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
public interface IItemService
|
public interface IItemService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<ItemListItemDto>> ListAsync(
|
Task<PagedResponse<ItemListItemDto>> ListAsync(
|
||||||
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default);
|
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||||
|
TrackingMode? trackingMode, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.ItemTypes;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master business logic (docs/11-BACKEND-PHASE1.md §2.7). Plain CRUD over an
|
||||||
|
/// unlinked list — no item ever references an item type (docs/10 Part C.9), so there is
|
||||||
|
/// nothing here beyond maintaining the names the builder's dropdown reads.
|
||||||
|
/// </summary>
|
||||||
|
public interface IItemTypeService
|
||||||
|
{
|
||||||
|
Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default);
|
||||||
|
Task<ETagged<ItemTypeDto>> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
|
||||||
|
namespace ERPCore.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Product configuration business logic (docs/11-BACKEND-PHASE1.md §2.8). Singleton.</summary>
|
||||||
|
public interface IProductConfigService
|
||||||
|
{
|
||||||
|
Task<ETagged<ProductConfigDto>> GetAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<ETagged<ProductConfigDto>> UpdateAsync(
|
||||||
|
UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
|
||||||
public interface IPurchaseReturnService
|
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);
|
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
|
|
||||||
@@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
|
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
|
||||||
public interface IRequisitionService
|
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?> GetAsync(int requisitionId, CancellationToken ct = default);
|
||||||
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
|
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
|
||||||
Task<RequisitionDto> SubmitAsync(int requisitionId, 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;
|
using ERPCore.Dtos.Procurement;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
namespace ERPCore.Services.Interfaces;
|
||||||
@@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>RFQ & vendor-quotation business logic (docs/11 §3.2).</summary>
|
/// <summary>RFQ & vendor-quotation business logic (docs/11 §3.2).</summary>
|
||||||
public interface IRfqService
|
public interface IRfqService
|
||||||
{
|
{
|
||||||
|
Task<PagedResponse<RfqSummaryDto>> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
|
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
|
||||||
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
|
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
|
||||||
Task<VendorQuotationDto> AddQuotationAsync(int rfqId, CreateQuotationRequest 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);
|
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(
|
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);
|
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;
|
using ERPCore.Dtos.Stock;
|
||||||
|
|
||||||
namespace ERPCore.Services.Interfaces;
|
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>
|
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
|
||||||
public interface ITransferService
|
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?> GetAsync(int transferId, CancellationToken ct = default);
|
||||||
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
|
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -13,36 +13,52 @@ namespace ERPCore.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
|
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
|
||||||
/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per
|
/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic
|
||||||
/// docs/11-BACKEND-PHASE1.md §2.1–2.2 and 02-SECURITY C.1.
|
/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.1–2.2
|
||||||
|
/// and 02-SECURITY C.1.
|
||||||
|
/// <para>
|
||||||
|
/// The SKU arrives generated by the client (it encodes the chosen item-type values,
|
||||||
|
/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no
|
||||||
|
/// item-type reference at all — see docs/10 Part C.9.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ItemService : IItemService
|
public sealed class ItemService : IItemService
|
||||||
{
|
{
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<Category> _categories;
|
private readonly IRepository<Category> _categories;
|
||||||
|
private readonly IRepository<SubCategory> _subCategories;
|
||||||
|
private readonly IRepository<Brand> _brands;
|
||||||
private readonly IRepository<Uom> _uoms;
|
private readonly IRepository<Uom> _uoms;
|
||||||
private readonly IRepository<Vendor> _vendors;
|
private readonly IRepository<Vendor> _vendors;
|
||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
|
private readonly IProductConfigService _config;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
public ItemService(
|
public ItemService(
|
||||||
IRepository<Item> items,
|
IRepository<Item> items,
|
||||||
IRepository<Category> categories,
|
IRepository<Category> categories,
|
||||||
|
IRepository<SubCategory> subCategories,
|
||||||
|
IRepository<Brand> brands,
|
||||||
IRepository<Uom> uoms,
|
IRepository<Uom> uoms,
|
||||||
IRepository<Vendor> vendors,
|
IRepository<Vendor> vendors,
|
||||||
IRepository<Warehouse> warehouses,
|
IRepository<Warehouse> warehouses,
|
||||||
|
IProductConfigService config,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_items = items;
|
_items = items;
|
||||||
_categories = categories;
|
_categories = categories;
|
||||||
|
_subCategories = subCategories;
|
||||||
|
_brands = brands;
|
||||||
_uoms = uoms;
|
_uoms = uoms;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
|
_config = config;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
|
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
|
||||||
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default)
|
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||||
|
TrackingMode? trackingMode, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _items.Query().AsNoTracking();
|
var q = _items.Query().AsNoTracking();
|
||||||
|
|
||||||
@@ -53,14 +69,17 @@ public sealed class ItemService : IItemService
|
|||||||
}
|
}
|
||||||
if (status is not null) q = q.Where(i => i.Status == status);
|
if (status is not null) q = q.Where(i => i.Status == status);
|
||||||
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
|
||||||
|
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
|
||||||
|
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
|
||||||
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(i => i.Sku)
|
var rows = await q.OrderBy(i => i.Sku)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(i => new ItemListItemDto(
|
.Select(i => new ItemListItemDto(
|
||||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
|
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||||
i.ItemType, i.TrackingMode, i.TaxClass, i.Status))
|
i.BaseUomId, i.DefaultVendorId,
|
||||||
|
i.StockNature, i.TrackingMode, i.TaxClass, i.Status))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -70,6 +89,7 @@ public sealed class ItemService : IItemService
|
|||||||
{
|
{
|
||||||
var item = await _items.Query().AsNoTracking()
|
var item = await _items.Query().AsNoTracking()
|
||||||
.Include(i => i.ReorderSettings)
|
.Include(i => i.ReorderSettings)
|
||||||
|
.Include(i => i.UomConversions)
|
||||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
|
||||||
|
|
||||||
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
|
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))
|
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
|
||||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||||
|
|
||||||
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
|
await ValidateReferencesAsync(
|
||||||
|
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||||
|
request.BaseUomId, request.DefaultVendorId, ct);
|
||||||
|
|
||||||
var item = new Item
|
var item = new Item
|
||||||
{
|
{
|
||||||
@@ -88,9 +110,11 @@ public sealed class ItemService : IItemService
|
|||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
CategoryId = request.CategoryId,
|
CategoryId = request.CategoryId,
|
||||||
|
SubCategoryId = request.SubCategoryId,
|
||||||
|
BrandId = request.BrandId,
|
||||||
BaseUomId = request.BaseUomId,
|
BaseUomId = request.BaseUomId,
|
||||||
DefaultVendorId = request.DefaultVendorId,
|
DefaultVendorId = request.DefaultVendorId,
|
||||||
ItemType = request.ItemType,
|
StockNature = request.StockNature,
|
||||||
TrackingMode = request.TrackingMode,
|
TrackingMode = request.TrackingMode,
|
||||||
TaxClass = request.TaxClass,
|
TaxClass = request.TaxClass,
|
||||||
Status = EntityStatus.Active,
|
Status = EntityStatus.Active,
|
||||||
@@ -108,6 +132,7 @@ public sealed class ItemService : IItemService
|
|||||||
{
|
{
|
||||||
var item = await _items.Query()
|
var item = await _items.Query()
|
||||||
.Include(i => i.ReorderSettings)
|
.Include(i => i.ReorderSettings)
|
||||||
|
.Include(i => i.UomConversions)
|
||||||
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
|
||||||
?? throw new NotFoundException($"Item {itemId} was not found.");
|
?? 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))
|
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
|
||||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||||
|
|
||||||
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
|
await ValidateReferencesAsync(
|
||||||
|
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||||
|
request.BaseUomId, request.DefaultVendorId, ct);
|
||||||
|
|
||||||
item.Sku = request.Sku.Trim();
|
item.Sku = request.Sku.Trim();
|
||||||
item.Name = request.Name.Trim();
|
item.Name = request.Name.Trim();
|
||||||
item.Description = request.Description;
|
item.Description = request.Description;
|
||||||
item.CategoryId = request.CategoryId;
|
item.CategoryId = request.CategoryId;
|
||||||
|
item.SubCategoryId = request.SubCategoryId;
|
||||||
|
item.BrandId = request.BrandId;
|
||||||
item.BaseUomId = request.BaseUomId;
|
item.BaseUomId = request.BaseUomId;
|
||||||
item.DefaultVendorId = request.DefaultVendorId;
|
item.DefaultVendorId = request.DefaultVendorId;
|
||||||
item.ItemType = request.ItemType;
|
item.StockNature = request.StockNature;
|
||||||
item.TrackingMode = request.TrackingMode;
|
item.TrackingMode = request.TrackingMode;
|
||||||
item.TaxClass = request.TaxClass;
|
item.TaxClass = request.TaxClass;
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -240,11 +269,56 @@ public sealed class ItemService : IItemService
|
|||||||
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
/// <summary>
|
||||||
|
/// Validates every FK on an item write, and gates the optional ones on the product
|
||||||
|
/// configuration (FR-MD-11). Note there is no item-type check: nothing on an item
|
||||||
|
/// references an item type, so <c>itemTypesEnabled</c> has nothing to reject here —
|
||||||
|
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
|
||||||
|
/// </summary>
|
||||||
|
private async Task ValidateReferencesAsync(
|
||||||
|
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var config = (await _config.GetAsync(ct)).Value;
|
||||||
|
|
||||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
||||||
|
|
||||||
|
if (subCategoryId is not null)
|
||||||
|
{
|
||||||
|
if (!config.SubcategoriesEnabled)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.ConfigDisabled,
|
||||||
|
"Subcategories are disabled in the product configuration; subCategoryId must be null.", 422);
|
||||||
|
|
||||||
|
var sub = await _subCategories.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||||
|
if (sub is null)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
|
||||||
|
if (sub.Status != EntityStatus.Active)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422);
|
||||||
|
// The two FKs must agree, or the item would claim a category its subcategory
|
||||||
|
// does not belong to.
|
||||||
|
if (sub.CategoryId != categoryId)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.Validation,
|
||||||
|
$"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brandId is not null)
|
||||||
|
{
|
||||||
|
if (!config.BrandsEnabled)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCodes.ConfigDisabled,
|
||||||
|
"Brands are disabled in the product configuration; brandId must be null.", 422);
|
||||||
|
|
||||||
|
var brand = await _brands.Query().AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||||
|
if (brand is null)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
|
||||||
|
if (brand.Status != EntityStatus.Active)
|
||||||
|
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
|
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
|
||||||
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
|
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
|
||||||
|
|
||||||
@@ -272,11 +346,16 @@ public sealed class ItemService : IItemService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ItemDetailDto ToDetail(Item i) => new(
|
private static ItemDetailDto ToDetail(Item i) => new(
|
||||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
|
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||||
i.ItemType, i.TrackingMode, i.TaxClass, i.Status,
|
i.BaseUomId, i.DefaultVendorId,
|
||||||
|
i.StockNature, i.TrackingMode, i.TaxClass, i.Status,
|
||||||
i.ReorderSettings
|
i.ReorderSettings
|
||||||
.OrderBy(r => r.WarehouseId)
|
.OrderBy(r => r.WarehouseId)
|
||||||
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
i.UomConversions
|
||||||
|
.OrderBy(c => c.ConversionId)
|
||||||
|
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
|
||||||
|
.ToList(),
|
||||||
i.CreatedAt, i.UpdatedAt);
|
i.CreatedAt, i.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||||
private readonly IRepository<GrnLine> _grnLines;
|
private readonly IRepository<GrnLine> _grnLines;
|
||||||
|
private readonly IRepository<StockLedger> _ledger;
|
||||||
private readonly IStockMutator _mutator;
|
private readonly IStockMutator _mutator;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
@@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
public PurchaseReturnService(
|
public PurchaseReturnService(
|
||||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
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;
|
_returns = returns;
|
||||||
_vendors = vendors;
|
_vendors = vendors;
|
||||||
@@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
_items = items;
|
_items = items;
|
||||||
_reasonCodes = reasonCodes;
|
_reasonCodes = reasonCodes;
|
||||||
_grnLines = grnLines;
|
_grnLines = grnLines;
|
||||||
|
_ledger = ledger;
|
||||||
_mutator = mutator;
|
_mutator = mutator;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_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)
|
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (request.ReasonCodeId is null)
|
if (request.ReasonCodeId is null)
|
||||||
@@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
// Map ledger ids after commit so they are populated.
|
// Map ledger ids after commit so they are populated.
|
||||||
return new PurchaseReturnDto(
|
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
_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();
|
var q = _requisitions.Query().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
|
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 total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(r => r.RequisitionId)
|
var rows = await q.OrderByDescending(r => r.RequisitionId)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<RequisitionSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Procurement;
|
using ERPCore.Dtos.Procurement;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
using ERPCore.Repositories.Interfaces;
|
using ERPCore.Repositories.Interfaces;
|
||||||
@@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var rfq = await _rfqs.Query().AsNoTracking()
|
var rfq = await _rfqs.Query().AsNoTracking()
|
||||||
|
|||||||
@@ -50,14 +50,85 @@ public sealed class StockService : IStockService
|
|||||||
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
|
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(
|
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();
|
var q = _ledger.Query().AsNoTracking();
|
||||||
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
|
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 (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 (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));
|
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 total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderByDescending(l => l.LedgerId)
|
var rows = await q.OrderByDescending(l => l.LedgerId)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ERPCore.Domain;
|
using ERPCore.Domain;
|
||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
using ERPCore.Domain.Enums;
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Stock;
|
using ERPCore.Dtos.Stock;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService
|
|||||||
_uow = uow;
|
_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)
|
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
|
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(
|
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(
|
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());
|
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 ReasonCodeRequired = "REASON_CODE_REQUIRED";
|
||||||
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
||||||
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
||||||
|
public const string ConfigDisabled = "CONFIG_DISABLED";
|
||||||
|
|
||||||
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
||||||
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
||||||
|
|||||||
+48
-5
@@ -15,14 +15,41 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
|||||||
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
|
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
|
||||||
|
|
||||||
## 1. Master Data
|
## 1. Master Data
|
||||||
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
|
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting (**that endpoint was removed on 2026-07-16** — categories no longer nest; see the entry at the end of this section); `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
|
||||||
- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
|
- [x] Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
|
||||||
- [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
|
- [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
|
||||||
- [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
|
- [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
|
||||||
- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
|
- [x] Category — **rebuilt 2026-07-16 as a two-level Category/SubCategory model** (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely
|
||||||
- [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
|
- [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
|
||||||
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
|
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
|
||||||
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
|
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
|
||||||
|
- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK
|
||||||
|
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only
|
||||||
|
- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId`
|
||||||
|
- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes
|
||||||
|
|
||||||
|
> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2)
|
||||||
|
> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8.
|
||||||
|
>
|
||||||
|
> **Deviations / decisions (all recorded in docs/10 §B.8.4 #11–13):**
|
||||||
|
> - **`ItemType` enum → `StockNature`.** The Stocked/NonStocked/Service enum was renamed to free the name `ItemType` for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (`RenameColumn`, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it.
|
||||||
|
> - **`CATEGORY.parent_id` removed.** Arbitrary nesting is gone, replaced by a dedicated `SUBCATEGORY` table (exactly two levels). Items now carry **both** FKs; previously the frontend collapsed them (`effectiveCategoryId = subCategoryId ?? categoryId`), losing the parent.
|
||||||
|
> - **Item types are unlinked to items, deliberately.** No value table, no join — values live only in the client-generated SKU (`BL-100-0003`) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is *not* a product-variation model; none was requested.
|
||||||
|
> - **`itemTypesEnabled` is advisory, not enforced.** With no item-type reference on an item there is nothing on a write to reject; only `subcategoriesEnabled`/`brandsEnabled` produce `CONFIG_DISABLED`. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee.
|
||||||
|
> - **`PUT /product-config` is door-policy-gated only** — any ERP-admitted user can flip the flags. A `CONFIG_MANAGE` permission is reserved for when RBAC lands (open decision #13).
|
||||||
|
> - **`brandId` is now a documented field**, no longer the undocumented frontend-only extra it was.
|
||||||
|
>
|
||||||
|
> **Migration #2 (`AddBrandsSubcategoriesItemTypesAndProductConfig`) carries data, not just DDL.** The scaffolded version dropped `parent_id` outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into `subcategories`, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at **any** depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its *root*, not of its (now-nonexistent) parent category. `Down()` was likewise hand-written to restore the tree instead of dropping `subcategories` and losing it.
|
||||||
|
>
|
||||||
|
> Also fixed while writing it: the `ck_product_config_singleton` check constraint was scaffolded as `config_id = 1`, but the column is created quoted-PascalCase (`"ConfigId"`) — unquoted, Postgres folds it to a column that doesn't exist. And `UpdateProductConfigRequest`'s flags are `bool?` on purpose: `[Required]` on a non-nullable `bool` is a no-op, so a body of `{}` would have bound all three to `false` and silently switched every feature off.
|
||||||
|
>
|
||||||
|
> **Schema/migration verified:** `dotnet build` clean. Migration `Up` **and** `Down` exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and `StockNature` data preservation; the fixture was then removed. `DataSeeder` seeds `Color`/`Size` + the config singleton idempotently (it needed restructuring — an early `return` in the reason-code path would otherwise have skipped the new seeds on every start after the first).
|
||||||
|
>
|
||||||
|
> **Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session.** Auth note: a token *is* obtainable despite the `loginUser` blocker — **`POST /api/v1/auth/register` succeeds and issues the `erp_at` session cookie directly**, and the JWT handler's cookie fallback means that session authenticates every other controller. (`loginUser` still `500`s "Invalid credentials" for that same freshly-registered user, by username *or* email, with *or* without `userTypeId` — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal `roleId`/`userTypeId` GUIDs, supplied by the user; Admin = role `08de6a11-9e9f-4401-8a10-6859860b41ec` / userType `00000000-0000-0000-0000-000000000004`.
|
||||||
|
>
|
||||||
|
> Covered: brand/category/subcategory/item-type create; **case-insensitive duplicate name → 409** (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU `BL-100-0003` → 201 and full round-trip on `GET /items/{id}`; new `brandId`/`subCategoryId` list filters; **cross-FK guard → 422** ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; `PUT /product-config {}` → **400** (proving the `bool?` fix — an empty body no longer silently disables everything); `subcategoriesEnabled:false` + `subCategoryId` → **422 CONFIG_DISABLED**, same item without it → 201, and **pre-existing items with a subcategory still read back fine**; `brandsEnabled:false` + `brandId` → 422; **`itemTypesEnabled:false` correctly does NOT block item writes** (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → **412 CONCURRENCY_CONFLICT** (brand + subcategory) / absent → 428; `PATCH /status` → 204 then inactive-brand reference → 422; and **renaming an item type left existing SKUs untouched**, confirming the intended decoupling. Audit stamp confirmed live: `product_config.updatedBy` resolved to a JIT-provisioned shadow user (`SMOKE001`) from the AuthHex `UserId`/`NIC` claims.
|
||||||
|
>
|
||||||
|
> Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). **Two artifacts left behind on purpose:** the AuthHex user `smoketest_admin` / NIC `SMOKE001` in AuthHex's own MySQL store, and its ERPCore shadow user (`users.UserId = 3`) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted.
|
||||||
|
|
||||||
## 2. Procurement
|
## 2. Procurement
|
||||||
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
|
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
|
||||||
@@ -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.)
|
- [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.
|
> **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
|
## 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.
|
> 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.
|
- **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.
|
- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed.
|
||||||
- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
|
- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
|
||||||
- **Login/refresh/logout left unverified live** — `loginUser` currently fails with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` on AuthHex **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved.
|
- **✅ 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 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: "<username>"` will not resolve a user; only email/mobile/NIC will.
|
||||||
|
- **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed.
|
||||||
|
|||||||
+51
-10
@@ -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.
|
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
|
## 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)
|
- [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.)
|
||||||
- [ ] 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] **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] 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] **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)
|
- [~] 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
|
- [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.
|
||||||
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
|
> **⚠️ 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).
|
> **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.
|
> **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
|
## 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 — 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 — 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
|
- [~] 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`
|
- [~] 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`
|
- 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`).
|
> **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).
|
- [~] 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`)
|
- 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).
|
> **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
|
## Done
|
||||||
<!-- move [x] items here with date + note if the active list grows long -->
|
<!-- 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)
|
### 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.
|
- 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.
|
- 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.
|
- **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).
|
- 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.
|
- 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.
|
- 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)
|
### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -32,6 +32,8 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
# ...except the template, which carries no secrets and documents what to set.
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ function NewPurchaseOrderContent() {
|
|||||||
setVendorId(rfqVendorId)
|
setVendorId(rfqVendorId)
|
||||||
setLines(
|
setLines(
|
||||||
rfq.lines.map((l): DraftLine => {
|
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 {
|
return {
|
||||||
key: newKey(),
|
key: newKey(),
|
||||||
itemId: l.itemId,
|
itemId: l.itemId,
|
||||||
|
|||||||
@@ -69,11 +69,22 @@ export default function RfqDetailPage() {
|
|||||||
|
|
||||||
const quotedVendorIds = useMemo(() => {
|
const quotedVendorIds = useMemo(() => {
|
||||||
const set = new Set<number>()
|
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
|
return set
|
||||||
}, [comparison])
|
}, [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) {
|
function itemFor(itemId: number) {
|
||||||
return items.find((i) => i.itemId === itemId)
|
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>
|
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
|
||||||
<RfqStatusBadge status={rfq.status} />
|
<RfqStatusBadge status={rfq.status} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""}
|
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
|
||||||
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +202,7 @@ export default function RfqDetailPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
|
<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>
|
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -199,19 +210,20 @@ export default function RfqDetailPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
<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>
|
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{comparison.lines.map((line) => {
|
{comparison.rows.map((line) => {
|
||||||
const item = itemFor(line.itemId)
|
const item = itemFor(line.itemId)
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.itemId}>
|
<TableRow key={line.itemId}>
|
||||||
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
|
||||||
{rfq.vendorIds.map((vid) => {
|
{comparison.vendorIds.map((vid) => {
|
||||||
const cell = line.cells.find((c) => c.vendorId === vid)
|
const cell = line.quotes.find((c) => c.vendorId === vid)
|
||||||
return (
|
return (
|
||||||
<TableCell key={vid} className="px-3 py-3.5">
|
<TableCell key={vid} className="px-3 py-3.5">
|
||||||
{cell ? (
|
{cell ? (
|
||||||
@@ -256,7 +268,7 @@ export default function RfqDetailPage() {
|
|||||||
<Label className="text-base">Vendor</Label>
|
<Label className="text-base">Vendor</Label>
|
||||||
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
|
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
<SelectValue placeholder="Select an invited vendor" />
|
<SelectValue placeholder="Select a vendor" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{pendingVendors.map((vid) => (
|
{pendingVendors.map((vid) => (
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ function NewRfqContent() {
|
|||||||
setHeaderError(null)
|
setHeaderError(null)
|
||||||
setSubmitError(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) {
|
if (vendorIds.size === 0) {
|
||||||
setHeaderError("Select at least one vendor to invite.")
|
setHeaderError("Select at least one vendor to invite.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import Link from "next/link"
|
|||||||
import { FileText, Plus } from "lucide-react"
|
import { FileText, Plus } from "lucide-react"
|
||||||
|
|
||||||
import { rfqsApi } from "@/lib/api/rfqs"
|
import { rfqsApi } from "@/lib/api/rfqs"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { RfqSummary } from "@/types/procurement"
|
import { RfqSummary } from "@/types/procurement"
|
||||||
import { Vendor } from "@/types/master-data"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
|
|
||||||
export default function RfqsListPage() {
|
export default function RfqsListPage() {
|
||||||
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
|
||||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
|
rfqsApi
|
||||||
.then(([r, v]) => {
|
.list()
|
||||||
setRfqs(r.items)
|
.then((r) => setRfqs(r.items))
|
||||||
setVendors(v.items)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function vendorNames(vendorIds: number[]) {
|
|
||||||
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -75,9 +68,11 @@ export default function RfqsListPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
|
<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">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">Status</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -89,11 +84,11 @@ export default function RfqsListPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</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">{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">
|
<TableCell className="px-3 py-3.5">
|
||||||
<RfqStatusBadge status={r.status} />
|
<RfqStatusBadge status={r.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
@@ -63,9 +63,13 @@ export default function ItemDetailPage() {
|
|||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
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 [baseUomId, setBaseUomId] = useState<number | null>(null)
|
||||||
const [defaultVendorId, setDefaultVendorId] = 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 [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
||||||
const [taxClass, setTaxClass] = useState("")
|
const [taxClass, setTaxClass] = useState("")
|
||||||
|
|
||||||
@@ -93,9 +97,11 @@ export default function ItemDetailPage() {
|
|||||||
setName(data.name)
|
setName(data.name)
|
||||||
setDescription(data.description ?? "")
|
setDescription(data.description ?? "")
|
||||||
setCategoryId(data.categoryId)
|
setCategoryId(data.categoryId)
|
||||||
|
setSubCategoryId(data.subCategoryId)
|
||||||
|
setBrandId(data.brandId)
|
||||||
setBaseUomId(data.baseUomId)
|
setBaseUomId(data.baseUomId)
|
||||||
setDefaultVendorId(data.defaultVendorId)
|
setDefaultVendorId(data.defaultVendorId)
|
||||||
setItemType(data.itemType)
|
setStockNature(data.stockNature)
|
||||||
setTrackingMode(data.trackingMode)
|
setTrackingMode(data.trackingMode)
|
||||||
setTaxClass(data.taxClass ?? "")
|
setTaxClass(data.taxClass ?? "")
|
||||||
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
|
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 {
|
try {
|
||||||
const result = await itemsApi.update(
|
const result = await itemsApi.update(
|
||||||
item.itemId,
|
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
|
etag
|
||||||
)
|
)
|
||||||
applyItem(result.data)
|
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} />
|
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Item type</Label>
|
{/* "Item type" now means a Color/Size dimension master — this field is the
|
||||||
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
|
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">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { brandsApi } from "@/lib/api/brands"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
|
|||||||
import { Brand } from "@/types/master-data"
|
import { Brand } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -54,7 +55,7 @@ export default function BrandsPage() {
|
|||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
brandsApi
|
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) => {
|
.then((res) => {
|
||||||
setBrands(res.items)
|
setBrands(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -87,10 +88,16 @@ export default function BrandsPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const brand = editing
|
let result
|
||||||
? await brandsApi.update(editing.brandId, { name })
|
if (editing) {
|
||||||
: await brandsApi.create({ name })
|
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||||
toast.success(editing ? "Brand updated" : "Brand created", brand.name)
|
// 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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
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)
|
setDeletingId(brand.brandId)
|
||||||
try {
|
try {
|
||||||
await brandsApi.remove(brand.brandId)
|
await brandsApi.updateStatus(brand.brandId, next)
|
||||||
toast.success("Brand deleted", brand.name)
|
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete brand", errorMessage(err))
|
toast.error("Could not update brand status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
@@ -206,6 +215,7 @@ export default function BrandsPage() {
|
|||||||
<TableRow className="hover: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">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">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">Created At</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -215,6 +225,9 @@ export default function BrandsPage() {
|
|||||||
<TableRow key={b.brandId}>
|
<TableRow key={b.brandId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
<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 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 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -227,26 +240,32 @@ export default function BrandsPage() {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Deactivate, not delete: the API has no DELETE for any master
|
||||||
|
(FR-MD-08) — records referenced by transactions must survive. */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`Delete ${b.name}`}
|
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
|
||||||
disabled={deletingId === b.brandId}
|
disabled={deletingId === b.brandId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant={b.status === "Active" ? "destructive" : "success"}
|
||||||
title={`Delete ${b.name}?`}
|
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
|
||||||
description="This permanently removes the brand."
|
description={
|
||||||
confirmLabel="Delete"
|
b.status === "Active"
|
||||||
onConfirm={() => handleDelete(b)}
|
? "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>
|
</AlertDialog>
|
||||||
</div>
|
</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 { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { categoriesApi } from "@/lib/api/categories"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
|
|||||||
import { Category } from "@/types/master-data"
|
import { Category } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
@@ -54,7 +55,7 @@ export default function CategoriesPage() {
|
|||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
categoriesApi
|
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) => {
|
.then((res) => {
|
||||||
setCategories(res.items)
|
setCategories(res.items)
|
||||||
setPagination(res.pagination)
|
setPagination(res.pagination)
|
||||||
@@ -87,10 +88,15 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const category = editing
|
let result
|
||||||
? await categoriesApi.update(editing.categoryId, { name })
|
if (editing) {
|
||||||
: await categoriesApi.create({ name })
|
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
|
||||||
toast.success(editing ? "Category updated" : "Category created", category.name)
|
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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
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)
|
setDeletingId(category.categoryId)
|
||||||
try {
|
try {
|
||||||
await categoriesApi.remove(category.categoryId)
|
await categoriesApi.updateStatus(category.categoryId, next)
|
||||||
toast.success("Category deleted", category.name)
|
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete category", errorMessage(err))
|
toast.error("Could not update category status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
@@ -206,6 +214,7 @@ export default function CategoriesPage() {
|
|||||||
<TableRow className="hover: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">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">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">Created At</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -215,9 +224,21 @@ export default function CategoriesPage() {
|
|||||||
<TableRow key={c.categoryId}>
|
<TableRow key={c.categoryId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
<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 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 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -227,26 +248,31 @@ export default function CategoriesPage() {
|
|||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger
|
<AlertDialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`Delete ${c.name}`}
|
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
|
||||||
disabled={deletingId === c.categoryId}
|
disabled={deletingId === c.categoryId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant={c.status === "Active" ? "destructive" : "success"}
|
||||||
title={`Delete ${c.name}?`}
|
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
|
||||||
description="This permanently removes the category."
|
description={
|
||||||
confirmLabel="Delete"
|
c.status === "Active"
|
||||||
onConfirm={() => handleDelete(c)}
|
? "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>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+77
-57
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { 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 { 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 { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<VariantCategory | null>(null)
|
const [editing, setEditing] = useState<ItemType | null>(null)
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
variantCategoriesApi
|
itemTypesApi
|
||||||
.list()
|
.list({ pageSize: 200 })
|
||||||
.then((res) => setCategories(res.items))
|
.then((res) => setItemTypes(res.items))
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,24 +56,28 @@ export default function VariantsPage() {
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(category: VariantCategory) {
|
function openEditDialog(itemType: ItemType) {
|
||||||
setEditing(category)
|
setEditing(itemType)
|
||||||
setName(category.name)
|
setName(itemType.name)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const nextErrors = validateVariantCategoryName(name)
|
const nextErrors = validateItemTypeName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const category = editing
|
if (editing) {
|
||||||
? await variantCategoriesApi.update(editing.variantCategoryId, { name })
|
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
|
||||||
: await variantCategoriesApi.create({ name })
|
const current = await itemTypesApi.get(editing.itemTypeId)
|
||||||
toast.success(editing ? "Variant category updated" : "Variant category created", category.name)
|
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)
|
setOpen(false)
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -72,22 +85,24 @@ export default function VariantsPage() {
|
|||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors({ name: errorMessage(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 {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(category: VariantCategory) {
|
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
|
||||||
setDeletingId(category.variantCategoryId)
|
async function handleToggleStatus(itemType: ItemType) {
|
||||||
|
const next = itemType.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setTogglingId(itemType.itemTypeId)
|
||||||
try {
|
try {
|
||||||
await variantCategoriesApi.remove(category.variantCategoryId)
|
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
|
||||||
toast.success("Variant category deleted", category.name)
|
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not delete category", errorMessage(err))
|
toast.error("Could not update status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setTogglingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,23 +114,25 @@ export default function VariantsPage() {
|
|||||||
<ArrowLeft className="size-5" />
|
<ArrowLeft className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Variants</h1>
|
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
|
||||||
<p className="text-base text-muted-foreground">Variant categories used by the item variant builder (e.g. Color, Size, Material).</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<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">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader className="items-center text-center">
|
<DialogHeader className="items-center text-center">
|
||||||
<DialogTitle>{editing ? "Edit variant category" : "New variant category"}</DialogTitle>
|
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
|
||||||
<DialogDescription>Give the category a name.</DialogDescription>
|
<DialogDescription>Give the item type a name.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field data-invalid={!!errors.name}>
|
<Field data-invalid={!!errors.name}>
|
||||||
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
|
<FieldLabel htmlFor="item-type-name">Name</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="variant-category-name"
|
id="item-type-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Material"
|
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>
|
<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">
|
<div className="flex flex-col gap-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
@@ -148,37 +165,36 @@ export default function VariantsPage() {
|
|||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && categories !== null && categories.length > 0 && (
|
{!error && itemTypes !== null && itemTypes.length > 0 && (
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader className="bg-indigo-50">
|
||||||
<TableRow className="hover: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">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">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">Created At</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{categories.map((c) => (
|
{itemTypes.map((t) => (
|
||||||
<TableRow key={c.variantCategoryId}>
|
<TableRow key={t.itemTypeId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.variantCategoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</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">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${c.name}`}
|
|
||||||
onClick={() => openEditDialog(c)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -188,20 +204,24 @@ export default function VariantsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
||||||
aria-label={`Delete ${c.name}`}
|
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
|
||||||
disabled={deletingId === c.variantCategoryId}
|
disabled={togglingId === t.itemTypeId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
variant="destructive"
|
variant={t.status === "Active" ? "destructive" : "success"}
|
||||||
title={`Delete ${c.name}?`}
|
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
|
||||||
description="This permanently removes the variant category."
|
description={
|
||||||
confirmLabel="Delete"
|
t.status === "Active"
|
||||||
onConfirm={() => handleDelete(c)}
|
? "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>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react"
|
|||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
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 { 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 { 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 { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
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("-")
|
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 {
|
function isColorCategory(categoryName: string): boolean {
|
||||||
return categoryName.trim().toLowerCase() === "color"
|
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
|
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() {
|
export default function NewItemPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||||
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
|
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||||
const [variantCategories, setVariantCategories] = useState<VariantCategory[] | 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 [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
|
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||||
const [brandId, setBrandId] = 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 [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
|
||||||
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
|
||||||
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
|
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
|
||||||
const [quantities, setQuantities] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCategoryName, setNewCategoryName] = useState("")
|
const [newCategoryName, setNewCategoryName] = useState("")
|
||||||
@@ -83,23 +94,40 @@ export default function NewItemPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
|
Promise.all([
|
||||||
.then(([cat, br, vc]) => {
|
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)
|
setCategories(cat.items)
|
||||||
setBrands(br.items)
|
setBrands(br.items)
|
||||||
setVariantCategories(vc.items)
|
setItemTypes(types.items)
|
||||||
|
setConfig(cfg)
|
||||||
|
setBaseUomId(uoms.items[0]?.uomId ?? null)
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(errorMessage(err)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories])
|
// Subcategories are their own resource now — fetched per category rather than filtered
|
||||||
const subCategoryOptions = useMemo(
|
// out of a flat list by parentId (that column no longer exists).
|
||||||
() => (categories ?? []).filter((c) => c.parentId === categoryId),
|
useEffect(() => {
|
||||||
[categories, categoryId]
|
if (categoryId === null || !config?.subcategoriesEnabled) {
|
||||||
)
|
setSubCategories([])
|
||||||
const effectiveCategoryId = subCategoryId ?? categoryId
|
return
|
||||||
const effectiveCategoryLabel =
|
}
|
||||||
(categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? ""
|
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 ?? ""
|
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
|
||||||
|
|
||||||
function handleCategoryChange(value: number | null) {
|
function handleCategoryChange(value: number | null) {
|
||||||
@@ -107,28 +135,27 @@ export default function NewItemPage() {
|
|||||||
setSubCategoryId(null)
|
setSubCategoryId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVariantCategory(variantCategoryId: number) {
|
function toggleItemType(itemTypeId: number) {
|
||||||
setCheckedVariantCategoryIds((prev) =>
|
setCheckedItemTypeIds((prev) =>
|
||||||
prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId]
|
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
|
||||||
)
|
)
|
||||||
setQuantities({})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddVariantCategory() {
|
async function handleAddItemType() {
|
||||||
const nextErrors = validateVariantCategoryName(newCategoryName)
|
const nextErrors = validateItemTypeName(newCategoryName)
|
||||||
if (nextErrors.name) {
|
if (nextErrors.name) {
|
||||||
setNewCategoryError(nextErrors.name)
|
setNewCategoryError(nextErrors.name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAddingCategorySubmitting(true)
|
setAddingCategorySubmitting(true)
|
||||||
try {
|
try {
|
||||||
const category = await variantCategoriesApi.create({ name: newCategoryName })
|
const created = await itemTypesApi.create({ name: newCategoryName })
|
||||||
setVariantCategories((prev) => [...(prev ?? []), category])
|
setItemTypes((prev) => [...(prev ?? []), created.data])
|
||||||
setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId])
|
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
|
||||||
setNewCategoryName("")
|
setNewCategoryName("")
|
||||||
setNewCategoryError(null)
|
setNewCategoryError(null)
|
||||||
setAddingCategory(false)
|
setAddingCategory(false)
|
||||||
toast.success("Variant category created", category.name)
|
toast.success("Item type created", created.data.name)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNewCategoryError(errorMessage(err))
|
setNewCategoryError(errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,34 +163,32 @@ export default function NewItemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addValue(variantCategoryId: number, overrideValue?: string) {
|
function addValue(itemTypeId: number, overrideValue?: string) {
|
||||||
const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim()
|
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
|
||||||
if (value) {
|
if (value) {
|
||||||
setValuesByCategory((prev) => {
|
setValuesByCategory((prev) => {
|
||||||
const existing = prev[variantCategoryId] ?? []
|
const existing = prev[itemTypeId] ?? []
|
||||||
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
|
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) => ({
|
setValuesByCategory((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value),
|
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
|
||||||
}))
|
}))
|
||||||
setQuantities({})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCategories = useMemo(
|
const activeCategories = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(variantCategories ?? [])
|
(itemTypes ?? [])
|
||||||
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
|
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
||||||
.map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] }))
|
.map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] }))
|
||||||
.filter((vc) => vc.values.length > 0),
|
.filter((t) => t.values.length > 0),
|
||||||
[variantCategories, checkedVariantCategoryIds, valuesByCategory]
|
[itemTypes, checkedItemTypeIds, valuesByCategory]
|
||||||
)
|
)
|
||||||
|
|
||||||
const variants = useMemo(() => {
|
const variants = useMemo(() => {
|
||||||
@@ -183,44 +208,58 @@ export default function NewItemPage() {
|
|||||||
}
|
}
|
||||||
return combinations.map((c) => ({
|
return combinations.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)),
|
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
|
||||||
}))
|
}))
|
||||||
}, [activeCategories, effectiveCategoryLabel])
|
}, [activeCategories, effectiveLabel])
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
|
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
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)
|
setSubmitting(true)
|
||||||
try {
|
|
||||||
let created = 0
|
let created = 0
|
||||||
|
try {
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const qty = Number(quantities[variant.key] || 0)
|
|
||||||
await itemsApi.create({
|
await itemsApi.create({
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
|
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
|
||||||
categoryId: effectiveCategoryId as number,
|
// 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,
|
brandId,
|
||||||
baseUomId: DEFAULT_BASE_UOM_ID,
|
baseUomId,
|
||||||
itemType: "Stocked",
|
stockNature: "Stocked",
|
||||||
trackingMode: "None",
|
trackingMode: "None",
|
||||||
initialQty: Number.isFinite(qty) ? qty : 0,
|
|
||||||
})
|
})
|
||||||
created += 1
|
created += 1
|
||||||
}
|
}
|
||||||
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
|
||||||
router.push("/dashboard/products")
|
router.push("/dashboard/products")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(errorMessage(err))
|
// Each row is its own POST with no transaction, so a failure partway (e.g. a
|
||||||
toast.error("Could not create variants", errorMessage(err))
|
// 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 {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = !categories || !brands || !variantCategories
|
const loading = !categories || !brands || !itemTypes || !config
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -230,7 +269,7 @@ export default function NewItemPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,6 +279,16 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
{loading && !loadError && <Skeleton className="h-64 w-full" />}
|
{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 && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@@ -250,7 +299,7 @@ export default function NewItemPage() {
|
|||||||
<SelectValue placeholder="Select category" />
|
<SelectValue placeholder="Select category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{topCategories.map((c) => (
|
{(categories ?? []).map((c) => (
|
||||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
||||||
{c.name}
|
{c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -259,21 +308,26 @@ export default function NewItemPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Subcategory (optional)</Label>
|
<Label className="text-base">Subcategory (optional)</Label>
|
||||||
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
|
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<SelectTrigger className="h-12! w-full text-base">
|
||||||
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
|
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{subCategoryOptions.map((c) => (
|
{subCategories.map((s) => (
|
||||||
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
|
<SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">
|
||||||
{c.name}
|
{s.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{config?.brandsEnabled && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-base">Brand (optional)</Label>
|
<Label className="text-base">Brand (optional)</Label>
|
||||||
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
<Select<number | null> value={brandId} onValueChange={setBrandId}>
|
||||||
@@ -289,24 +343,28 @@ export default function NewItemPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</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 className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{(variantCategories ?? []).map((vc) => (
|
{(itemTypes ?? []).map((t) => (
|
||||||
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={checkedVariantCategoryIds.includes(vc.variantCategoryId)}
|
checked={checkedItemTypeIds.includes(t.itemTypeId)}
|
||||||
onCheckedChange={() => toggleVariantCategory(vc.variantCategoryId)}
|
onCheckedChange={() => toggleItemType(t.itemTypeId)}
|
||||||
/>
|
/>
|
||||||
<span className="text-base font-medium">{vc.name}</span>
|
<span className="text-base font-medium">{t.name}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{!addingCategory && (
|
{!addingCategory && (
|
||||||
@@ -314,7 +372,7 @@ export default function NewItemPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="Add another variant category"
|
aria-label="Add another item type"
|
||||||
onClick={() => setAddingCategory(true)}
|
onClick={() => setAddingCategory(true)}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@@ -331,7 +389,7 @@ export default function NewItemPage() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleAddVariantCategory()
|
handleAddItemType()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Material"
|
placeholder="Material"
|
||||||
@@ -339,7 +397,7 @@ export default function NewItemPage() {
|
|||||||
aria-invalid={!!newCategoryError}
|
aria-invalid={!!newCategoryError}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
|
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -363,38 +421,38 @@ export default function NewItemPage() {
|
|||||||
|
|
||||||
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
|
<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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{(variantCategories ?? [])
|
{(itemTypes ?? [])
|
||||||
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
|
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
|
||||||
.map((vc) => {
|
.map((t) => {
|
||||||
const isColor = isColorCategory(vc.name)
|
const isColor = isColorCategory(t.name)
|
||||||
const currentInput = inputByCategory[vc.variantCategoryId] ?? ""
|
const currentInput = inputByCategory[t.itemTypeId] ?? ""
|
||||||
const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? ""
|
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
|
||||||
|
|
||||||
function addColor() {
|
function addColor() {
|
||||||
const name = currentColorName.trim()
|
const name = currentColorName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444"))
|
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
|
||||||
setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" }))
|
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
|
<div key={t.itemTypeId} className="flex flex-col gap-2">
|
||||||
<Label className="text-base">{vc.name} values</Label>
|
<Label className="text-base">{t.name} values</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isColor ? (
|
{isColor ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={currentInput || "#EF4444"}
|
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"
|
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
|
||||||
aria-label="Pick color"
|
aria-label="Pick color"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={currentColorName}
|
value={currentColorName}
|
||||||
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
|
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -408,24 +466,24 @@ export default function NewItemPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
value={currentInput}
|
value={currentInput}
|
||||||
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
|
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
addValue(vc.variantCategoryId)
|
addValue(t.itemTypeId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={vc.name}
|
placeholder={t.name}
|
||||||
className="h-11 text-base"
|
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" />
|
<Plus className="size-4" />
|
||||||
Add {vc.name}
|
Add {t.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
|
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
|
||||||
const decoded = isColor ? decodeColorValue(v) : null
|
const decoded = isColor ? decodeColorValue(v) : null
|
||||||
return (
|
return (
|
||||||
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
|
<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}
|
{decoded ? decoded.name : v}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeValue(vc.variantCategoryId, v)}
|
onClick={() => removeValue(t.itemTypeId, v)}
|
||||||
className="rounded-full p-0.5 hover:bg-muted"
|
className="rounded-full p-0.5 hover:bg-muted"
|
||||||
aria-label={`Remove ${decoded ? decoded.name : v}`}
|
aria-label={`Remove ${decoded ? decoded.name : v}`}
|
||||||
>
|
>
|
||||||
@@ -461,10 +519,13 @@ export default function NewItemPage() {
|
|||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader className="bg-indigo-50">
|
||||||
<TableRow className="hover:bg-indigo-50">
|
<TableRow className="hover:bg-indigo-50">
|
||||||
{activeCategories.map((cat) => (
|
{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">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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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 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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -505,6 +556,7 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
<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>
|
||||||
<TableCell className="px-3 py-3.5">{item.name}</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">{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">{item.trackingMode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge
|
<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(() => {
|
useEffect(() => {
|
||||||
if (!grn) return
|
if (!grn) return
|
||||||
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [grn?.warehouseId])
|
}, [grn?.warehouseId])
|
||||||
|
|
||||||
function itemFor(itemId: number) {
|
function itemFor(itemId: number) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function NewGrnPage() {
|
|||||||
setBins([])
|
setBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [warehouseId])
|
}, [warehouseId])
|
||||||
|
|
||||||
function switchMode(next: Mode) {
|
function switchMode(next: Mode) {
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { grnsApi } from "@/lib/api/grns"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||||
|
|
||||||
type StatusFilter = GrnStatus | "All"
|
type StatusFilter = GrnStatus | "All"
|
||||||
@@ -40,7 +38,6 @@ export default function GrnListPage() {
|
|||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
const [status, setStatus] = useState<StatusFilter>("All")
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
|
||||||
|
|
||||||
// Debounce the search box so typing doesn't refetch on every keystroke.
|
// Debounce the search box so typing doesn't refetch on every keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,19 +68,6 @@ export default function GrnListPage() {
|
|||||||
|
|
||||||
useEffect(load, [page, query, status])
|
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"
|
const hasFilters = query.length > 0 || status !== "All"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,7 +180,6 @@ export default function GrnListPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{grns.map((grn) => {
|
{grns.map((grn) => {
|
||||||
const isDraft = grn.status === "Draft"
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={grn.grnId}>
|
<TableRow key={grn.grnId}>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
@@ -224,47 +207,9 @@ export default function GrnListPage() {
|
|||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isDraft ? (
|
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
|
||||||
<Link
|
a GRN. A receipt is corrected with a reversing document, never
|
||||||
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
|
edited or erased (FR-X-05). */}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function StockEnquiryPage() {
|
|||||||
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
|
||||||
|
|
||||||
useEffect(() => {
|
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]) => {
|
.then(([r, it, wh]) => {
|
||||||
setRows(r)
|
setRows(r.items)
|
||||||
setItems(it.items)
|
setItems(it.items)
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export default function ReorderAlertsPage() {
|
|||||||
const key = `${alert.itemId}-${alert.warehouseId}`
|
const key = `${alert.itemId}-${alert.warehouseId}`
|
||||||
setRequesting(key)
|
setRequesting(key)
|
||||||
try {
|
try {
|
||||||
|
// The server returns the full requisition; the suggested qty is on its line.
|
||||||
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
|
||||||
setRequested((prev) => new Set(prev).add(key))
|
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) {
|
} catch (err) {
|
||||||
toast.error("Could not create requisition", errorMessage(err))
|
toast.error("Could not create requisition", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function NewTransferPage() {
|
|||||||
setSrcBins([])
|
setSrcBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
|
warehousesApi.listBins(srcWarehouseId).then(setSrcBins).catch(() => setSrcBins([]))
|
||||||
}, [srcWarehouseId])
|
}, [srcWarehouseId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,7 +79,7 @@ export default function NewTransferPage() {
|
|||||||
setDestBins([])
|
setDestBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
|
warehousesApi.listBins(destWarehouseId).then(setDestBins).catch(() => setDestBins([]))
|
||||||
}, [destWarehouseId])
|
}, [destWarehouseId])
|
||||||
|
|
||||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
|
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 { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
import { warehousesApi } from "@/lib/api/warehouses"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -42,10 +42,9 @@ export default function NewWastagePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
|
||||||
.then(([wh, it, rc]) => {
|
.then(([wh, it, rc]) => {
|
||||||
const wastageIds = new Set(wastageReasonCodeIds())
|
|
||||||
setWarehouses(wh.items)
|
setWarehouses(wh.items)
|
||||||
setItems(it.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)))
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -55,7 +54,7 @@ export default function NewWastagePage() {
|
|||||||
setBins([])
|
setBins([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
|
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
|
||||||
}, [warehouseId])
|
}, [warehouseId])
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user