Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka 47683ddd0f toggle screen for product configurations 2026-07-17 11:34:46 +05:30
192 changed files with 4330 additions and 16499 deletions
-7
View File
@@ -29,10 +29,3 @@ yarn-error.log*
.DS_Store
Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# New EF Core migrations are not committed. Note the 4 migrations already in
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
# not apply to tracked files — so edits to those still get committed as normal.
# Untracking them too takes `git rm --cached`.
**/Migrations/
+3 -48
View File
@@ -1,5 +1,4 @@
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
@@ -26,27 +25,12 @@ public sealed class AuthController : ControllerBase
private readonly IAuthUserService _users;
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
}
/// <summary>
/// Authoritative current-session info for the frontend: role + the sidebar nav
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
/// </summary>
[HttpGet("me")]
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
@@ -141,45 +125,16 @@ public sealed class AuthController : ControllerBase
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
/// <summary>
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
/// <para>
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
/// </para>
/// <para>
/// The cookies are cleared even if the upstream revoke fails or no user can be
/// resolved: a logout that leaves the caller holding a live session cookie is worse
/// than one that leaves a stale session server-side (which lapses on its own).
/// </para>
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
{
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.
}
}
await _users.LogoutUserAsync(request, ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
private Guid? ResolveTokenUserId()
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
[HttpPut("me")]
[ValidateCsrf]
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
@@ -1,67 +0,0 @@
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,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
@@ -6,11 +5,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <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>
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
[Route("api/v1/categories")]
public sealed class CategoriesController : ApiControllerBase
{
@@ -18,79 +13,19 @@ public sealed class CategoriesController : ApiControllerBase
public CategoriesController(ICategoryService categories) => _categories = categories;
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListAsync(query, status, ct));
[HttpGet("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
{
var result = await _categories.GetAsync(categoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
}
[HttpPut("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CategoryDto>> Update(
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{categoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetStatusAsync(categoryId, request.Status, ct);
return NoContent();
}
// Subcategories — nested under their parent category (docs/11 §2.3).
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
[HttpGet("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
[HttpPost("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
var dto = await _categories.CreateAsync(request, ct);
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
}
}
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,14 +12,6 @@ public sealed class GrnsController : ApiControllerBase
public GrnsController(IGrnService grns) => _grns = grns;
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,73 +0,0 @@
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,11 +21,9 @@ public sealed class ItemsController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:int}")]
@@ -1,39 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Rbac;
using ERPCore.Repositories.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Controllers;
/// <summary>
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
/// </summary>
[Route("api/v1/nav")]
public sealed class NavController : ApiControllerBase
{
private readonly IRepository<NavItem> _navItems;
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
[HttpGet]
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
{
var items = await _navItems.Query().AsNoTracking()
.Include(n => n.Children)
.OrderBy(n => n.SortOrder)
.ToListAsync(ct);
var dto = items.Select(n => new NavItemDto(
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
n.Children.OrderBy(c => c.SortOrder)
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
.ToList())).ToList();
return Ok(dto);
}
}
@@ -1,46 +0,0 @@
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,4 +1,3 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -13,23 +12,6 @@ public sealed class PurchaseReturnsController : ApiControllerBase
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -16,9 +15,8 @@ public sealed class RequisitionsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, status, ct));
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, ct));
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,13 +12,6 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,88 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
[Route("api/v1/roles")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
public RolesController(IRoleService roles) => _roles = roles;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _roles.ListAsync(query, status, ct));
[HttpGet("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
{
var result = await _roles.GetAsync(roleId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
{
var result = await _roles.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
}
[HttpPut("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{roleId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
{
await _roles.SetStatusAsync(roleId, request.Status, ct);
return NoContent();
}
[HttpDelete("{roleId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
{
await _roles.DeleteAsync(roleId, ct);
return NoContent();
}
[HttpGet("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
[HttpPut("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
}
@@ -1,4 +1,3 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -13,23 +12,6 @@ public sealed class StockAdjustmentsController : ApiControllerBase
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
{
var dto = await _adjustments.GetAsync(adjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
+2 -16
View File
@@ -24,26 +24,12 @@ public sealed class StockController : ApiControllerBase
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,13 +12,6 @@ public sealed class StockCountsController : ApiControllerBase
public StockCountsController(ICountService counts) => _counts = counts;
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,14 +12,6 @@ public sealed class StockTransfersController : ApiControllerBase
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,56 +0,0 @@
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();
}
}
@@ -1,53 +0,0 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// User management: local shadow `User` list/detail + role assignment, and
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
/// </summary>
[Route("api/v1/users")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
public UsersController(IUserManagementService users) => _users = users;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
=> Ok(await _users.ListUserTypesAsync(ct));
[HttpGet("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
{
var result = await _users.GetAsync(userId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
[HttpPut("{userId:int}/role")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
}
-21
View File
@@ -1,21 +0,0 @@
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; }
}
+5 -15
View File
@@ -1,25 +1,15 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
/// <c>parent_id</c> tree was replaced in migration #2).
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Category
{
public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
public int? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
}
+1 -8
View File
@@ -17,20 +17,13 @@ public class Item
public int CategoryId { get; set; }
public Category? Category { get; set; }
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
public int? SubCategoryId { get; set; }
public SubCategory? SubCategory { get; set; }
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public StockNature StockNature { get; set; }
public ItemType ItemType { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -1,31 +0,0 @@
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; }
}
@@ -1,22 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
/// not by editing these rows through the UI.
/// </summary>
public class NavItem
{
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
}
@@ -1,18 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A grantable sidebar-visibility unit — exactly one of <see cref="NavItemId"/> /
/// <see cref="SubNavItemId"/> is set (enforced in <c>NavSeedService</c>/service layer,
/// not by a DB constraint). One row is seeded per <see cref="NavItem"/>/<see cref="SubNavItem"/>;
/// <see cref="RolePermission"/> grants it to a role.
/// </summary>
public class Permission
{
public int PermissionId { get; set; }
public string Code { get; set; } = string.Empty;
public int? NavItemId { get; set; }
public int? SubNavItemId { get; set; }
public NavItem? NavItem { get; set; }
public SubNavItem? SubNavItem { get; set; }
}
@@ -1,35 +0,0 @@
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; }
}
-27
View File
@@ -1,27 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
/// </summary>
public class Role
{
public int RoleId { get; set; }
public Guid AuthRoleId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystemRole { 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,11 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
public class RolePermission
{
public int RoleId { get; set; }
public int PermissionId { get; set; }
public Role? Role { get; set; }
public Permission? Permission { get; set; }
}
@@ -1,26 +0,0 @@
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,18 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>A child sidebar entry under a <see cref="NavItem"/> (e.g. Products' children).</summary>
public class SubNavItem
{
public int SubNavItemId { get; set; }
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public NavItem? NavItem { get; set; }
}
-4
View File
@@ -22,8 +22,4 @@ public class User
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
public Guid? AuthUserId { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; }
public Role? Role { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
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
}
@@ -1,14 +0,0 @@
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
}
-28
View File
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Auth;
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
public sealed class AuthHexRoleDto
{
public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class CreateAuthHexRoleRequest
{
[Required] public string Code { get; set; } = string.Empty;
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
public sealed class UpdateAuthHexRoleRequest
{
[Required] public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
+1 -13
View File
@@ -82,14 +82,6 @@ public sealed class GetUserDetailsResponse
public JsonElement? UserType { get; set; }
}
/// <summary>AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes).</summary>
public sealed class UserTypeDto
{
public Guid UserTypeId { get; set; }
public string? Code { get; set; }
public string? Description { get; set; }
}
public sealed class SessionDto
{
public string? SessionId { get; set; }
@@ -183,9 +175,5 @@ public sealed class TwoFaStatusResponse
public sealed class LogoutRequest
{
/// <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; }
[Required] public Guid UserId { get; set; }
}
-26
View File
@@ -1,26 +0,0 @@
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,56 +1,15 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Categories;
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
// The hierarchy is exactly two levels: Category → SubCategory. The former
// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in
// migration #2 — see docs/10 Part C.1.
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
/// <summary>Category resource — the top level.</summary>
public sealed record CategoryDto(
int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>Subcategory resource — the single optional level below a category.</summary>
public sealed record SubCategoryDto(
int SubCategoryId, int CategoryId, string Name, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
public sealed class CreateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
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; }
public int? ParentId { get; set; }
}
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
/// </summary>
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
/// actually bind — an omitted flag is a 400, not an accidental disable.
/// </para>
/// </summary>
public sealed class UpdateProductConfigRequest
{
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
}
-5
View File
@@ -13,11 +13,6 @@ public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
@@ -1,31 +0,0 @@
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; }
}
+6 -28
View File
@@ -7,28 +7,18 @@ namespace ERPCore.Dtos.Items;
/// <summary>Row shape for <c>GET /items</c>.</summary>
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <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>
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
@@ -43,23 +33,15 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settin
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g.
// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here
// by design — items carry no item-type reference (docs/10 Part C.9).
public sealed class CreateItemRequest
{
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
[Required] public int CategoryId { get; set; }
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
public int? SubCategoryId { get; set; }
/// <summary>Optional. Rejected when brands are disabled.</summary>
public int? BrandId { get; set; }
[Required] public int BaseUomId { get; set; }
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
@@ -70,13 +52,9 @@ public sealed class UpdateItemRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
[Required] public int CategoryId { get; set; }
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
public int? SubCategoryId { get; set; }
/// <summary>Optional. Rejected when brands are disabled.</summary>
public int? BrandId { get; set; }
[Required] public int BaseUomId { get; set; }
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
@@ -9,12 +9,7 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, 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);
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
@@ -10,10 +10,6 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
public sealed record RfqSummaryDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
-6
View File
@@ -1,6 +0,0 @@
namespace ERPCore.Dtos.Rbac;
/// <summary>Response for `GET /api/v1/auth/me` — the frontend's authoritative source
/// for the current user's role and permitted sidebar nav codes (replaces trusting
/// the stale, client-only `roleId` cached in localStorage).</summary>
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes);
-7
View File
@@ -1,7 +0,0 @@
namespace ERPCore.Dtos.Rbac;
public sealed record SubNavItemDto(int SubNavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder);
public sealed record NavItemDto(
int NavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder,
IReadOnlyList<SubNavItemDto> Children);
-34
View File
@@ -1,34 +0,0 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Rbac;
public sealed record RoleDto(
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateRoleRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateRoleRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateRoleStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>Replaces a role's full permission set (checkbox-tree save from the frontend).</summary>
public sealed class AssignRolePermissionsRequest
{
public List<int> NavItemIds { get; set; } = new();
public List<int> SubNavItemIds { get; set; } = new();
}
public sealed record RolePermissionsDto(int RoleId, List<int> NavItemIds, List<int> SubNavItemIds);
@@ -11,11 +11,6 @@ public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
+1 -7
View File
@@ -8,13 +8,7 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
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);
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
+1 -6
View File
@@ -10,12 +10,7 @@ public sealed record TransferLineDto(
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
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);
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
-35
View File
@@ -1,35 +0,0 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Users;
public sealed record ManagedUserDto(
int UserId, string Username, string DisplayName, EntityStatus Status,
int? RoleId, string? RoleCode, string? RoleName);
/// <summary>
/// Creates a user in both backends: forwards to AuthHex's `registerUser` (source
/// of truth for credentials), then mirrors the account into ERPCore's local
/// shadow `User` row immediately (rather than waiting for next-login JIT
/// provisioning). AuthHex emails the generated/supplied password to `Email`.
/// </summary>
public sealed class CreateUserRequest
{
[Required, StringLength(100)] public string Username { get; set; } = string.Empty;
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
[Required] public int RoleId { get; set; }
[Required] public Guid UserTypeId { get; set; }
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
public string? Nic { get; set; }
public string? MobileNumber { get; set; }
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
public string? Password { get; set; }
}
public sealed class UpdateUserRoleRequest
{
[Required] public int RoleId { get; set; }
}
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -1,7 +1,6 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ERPCore.Dtos.Auth;
using ERPCore.System.Errors;
@@ -17,14 +16,9 @@ namespace ERPCore.Infra.Auth.AuthHex;
/// </summary>
public sealed class AuthHexClient : IAuthHexClient
{
// WhenWritingNull: AuthHex's dispatcher reads payload fields as raw JsonElements and some
// (e.g. RoleManager's isSystemRole) call type-specific getters like GetBoolean() that throw
// on an explicit JSON null rather than treating it as "absent" — omit null properties instead
// of serializing them, so unset nullable request fields behave as ContainsKey == false upstream.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _http;
@@ -48,9 +42,6 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct)
=> CallAsync<GetUserDetailsResponse>("user", "getUserDetails", new { userId }, null, ct);
public Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct)
=> CallAsync<List<UserTypeDto>>("user", "listUserTypes", new { }, null, ct);
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
=> CallAsync<List<SessionDto>>("user", "getUserSessions", new { }, bearerToken, ct);
@@ -112,23 +103,6 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- RoleManager --------------------------------------------------
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
// ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -16,7 +16,6 @@ public interface IAuthHexClient
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct);
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
@@ -40,12 +39,4 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
}
@@ -1,29 +0,0 @@
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,5 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -13,17 +12,12 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
builder.HasKey(c => c.CategoryId);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(c => c.Name).IsUnique();
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
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);
builder.HasIndex(c => c.ParentId);
}
}
@@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
builder.Property(i => i.StockNature)
builder.Property(i => i.ItemType)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
.HasConversion<string>().HasMaxLength(20).IsRequired();
@@ -37,16 +37,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
.HasForeignKey(i => i.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.SubCategory)
.WithMany()
.HasForeignKey(i => i.SubCategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Brand)
.WithMany()
.HasForeignKey(i => i.BrandId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.BaseUom)
.WithMany()
.HasForeignKey(i => i.BaseUomId)
@@ -59,6 +49,5 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.HasIndex(i => i.Status);
builder.HasIndex(i => i.CategoryId);
builder.HasIndex(i => i.BrandId);
}
}
@@ -1,33 +0,0 @@
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);
}
}
@@ -1,42 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Seeded to mirror the frontend's hardcoded sidebar
/// (ERP-core/Frontend/erp-system/components/Layouts/AppSidebar.tsx). Codes here
/// must match the <c>code</c> given to each frontend nav entry.
/// </summary>
public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
{
public void Configure(EntityTypeBuilder<NavItem> builder)
{
builder.ToTable("nav_items");
builder.HasKey(n => n.NavItemId);
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(n => n.Code).IsUnique();
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
builder.Property(n => n.Icon).HasMaxLength(50);
builder.Property(n => n.Href).HasMaxLength(200);
builder.Property(n => n.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
);
}
}
@@ -1,47 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// One row per <see cref="NavItem"/>/<see cref="SubNavItem"/>, seeded in lockstep
/// with <see cref="NavItemConfiguration"/>/<see cref="SubNavItemConfiguration"/>.
/// </summary>
public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permission>
{
public void Configure(EntityTypeBuilder<Permission> builder)
{
builder.ToTable("permissions");
builder.HasKey(p => p.PermissionId);
builder.Property(p => p.Code).IsRequired().HasMaxLength(80);
builder.HasIndex(p => p.Code).IsUnique();
builder.HasOne(p => p.NavItem).WithMany()
.HasForeignKey(p => p.NavItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(p => p.SubNavItem).WithMany()
.HasForeignKey(p => p.SubNavItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasData(
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
new Permission { PermissionId = 7, Code = "NAV:warehouses", NavItemId = 7 },
new Permission { PermissionId = 8, Code = "NAV:orders", NavItemId = 8 },
new Permission { PermissionId = 9, Code = "NAV:settings", NavItemId = 9 },
new Permission { PermissionId = 10, Code = "NAV:help", NavItemId = 10 },
new Permission { PermissionId = 11, Code = "NAV:products.item", SubNavItemId = 1 },
new Permission { PermissionId = 12, Code = "NAV:products.category", SubNavItemId = 2 },
new Permission { PermissionId = 13, Code = "NAV:products.brand", SubNavItemId = 3 },
new Permission { PermissionId = 14, Code = "NAV:products.item-type", SubNavItemId = 4 },
new Permission { PermissionId = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 }
);
}
}
@@ -1,37 +0,0 @@
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);
}
}
@@ -1,33 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.ToTable("roles");
builder.HasKey(r => r.RoleId);
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired();
builder.HasIndex(r => r.AuthRoleId).IsUnique();
builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(r => r.Code).IsUnique();
builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(r => r.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(r => r.RowVersion).IsRowVersion();
}
}
@@ -1,19 +0,0 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RolePermissionConfiguration : IEntityTypeConfiguration<RolePermission>
{
public void Configure(EntityTypeBuilder<RolePermission> builder)
{
builder.ToTable("role_permissions");
builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });
builder.HasOne(rp => rp.Role).WithMany()
.HasForeignKey(rp => rp.RoleId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(rp => rp.Permission).WithMany()
.HasForeignKey(rp => rp.PermissionId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -1,35 +0,0 @@
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);
}
}
@@ -1,38 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavItem>
{
public void Configure(EntityTypeBuilder<SubNavItem> builder)
{
builder.ToTable("sub_nav_items");
builder.HasKey(n => n.SubNavItemId);
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(n => n.Code).IsUnique();
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
builder.Property(n => n.Icon).HasMaxLength(50);
builder.Property(n => n.Href).HasMaxLength(200);
builder.Property(n => n.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasOne(n => n.NavItem).WithMany(n => n.Children)
.HasForeignKey(n => n.NavItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasData(
new SubNavItem { SubNavItemId = 1, NavItemId = 2, Code = "products.item", Label = "Item", Href = "/dashboard/products", SortOrder = 1 },
new SubNavItem { SubNavItemId = 2, NavItemId = 2, Code = "products.category", Label = "Category", Href = "/dashboard/products/categories", SortOrder = 2 },
new SubNavItem { SubNavItemId = 3, NavItemId = 2, Code = "products.brand", Label = "Brand", Href = "/dashboard/products/brands", SortOrder = 3 },
new SubNavItem { SubNavItemId = 4, NavItemId = 2, Code = "products.item-type", Label = "Item Type", Href = "/dashboard/products/item-types", SortOrder = 4 },
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 }
);
}
}
@@ -23,10 +23,6 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
builder.HasIndex(u => u.AuthUserId).IsUnique();
// Local shadow Role assignment (nullable — unset until an admin assigns one).
builder.HasOne(u => u.Role).WithMany()
.HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict);
// Seeded fallback audit actor while auth is deferred (§6).
builder.HasData(new User
{
@@ -12,13 +12,6 @@ namespace ERPCore.Infra.Persistence;
/// </summary>
public static class DataSeeder
{
/// <summary>
/// Item type names the frontend builder has always assumed exist (they were hardcoded
/// while it ran on mock data). Seeded so the dropdown is not empty on a fresh database;
/// users add their own (e.g. Material) from the admin screen.
/// </summary>
private static readonly string[] StandardItemTypes = ["Color", "Size"];
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
[
("DMG", "Damage", ReasonContext.Adjustment),
@@ -33,15 +26,6 @@ public static class DataSeeder
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
{
var existing = await db.ReasonCodes
.Select(r => new { r.Context, r.Code })
@@ -53,44 +37,9 @@ public static class DataSeeder
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
.ToList();
if (toAdd.Count == 0) return false;
if (toAdd.Count == 0) return;
db.ReasonCodes.AddRange(toAdd);
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;
await db.SaveChangesAsync(ct);
}
}
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// EF Core context for the ERP database. The 42 Phase 1 entities and their
/// EF Core context for the ERP database. The 38 Phase 1 entities and their
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
@@ -23,10 +23,6 @@ public class ErpDbContext : DbContext
// --- Master Data (docs/10 Part C.1) ---
public DbSet<Category> Categories => Set<Category>();
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
public DbSet<Brand> Brands => Set<Brand>();
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
public DbSet<ItemType> ItemTypes => Set<ItemType>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
@@ -34,20 +30,11 @@ public class ErpDbContext : DbContext
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
public DbSet<NumberSequence> NumberSequences => Set<NumberSequence>();
// --- RBAC / sidebar (docs/10 Part C.8) ---
public DbSet<Role> Roles => Set<Role>();
public DbSet<NavItem> NavItems => Set<NavItem>();
public DbSet<SubNavItem> SubNavItems => Set<SubNavItem>();
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
// --- Procurement (docs/10 Part C.2) ---
public DbSet<Requisition> Requisitions => Set<Requisition>();
public DbSet<RequisitionLine> RequisitionLines => Set<RequisitionLine>();
@@ -1,442 +0,0 @@
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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class ini2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,303 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRolesNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RoleId",
table: "users",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "nav_items",
columns: table => new
{
NavItemId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
},
constraints: table =>
{
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
});
migrationBuilder.CreateTable(
name: "roles",
columns: table => new
{
RoleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: 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_roles", x => x.RoleId);
});
migrationBuilder.CreateTable(
name: "sub_nav_items",
columns: table => new
{
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NavItemId = table.Column<int>(type: "integer", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
},
constraints: table =>
{
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
table.ForeignKey(
name: "FK_sub_nav_items_nav_items_NavItemId",
column: x => x.NavItemId,
principalTable: "nav_items",
principalColumn: "NavItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "permissions",
columns: table => new
{
PermissionId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
NavItemId = table.Column<int>(type: "integer", nullable: true),
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_permissions", x => x.PermissionId);
table.ForeignKey(
name: "FK_permissions_nav_items_NavItemId",
column: x => x.NavItemId,
principalTable: "nav_items",
principalColumn: "NavItemId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_permissions_sub_nav_items_SubNavItemId",
column: x => x.SubNavItemId,
principalTable: "sub_nav_items",
principalColumn: "SubNavItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "role_permissions",
columns: table => new
{
RoleId = table.Column<int>(type: "integer", nullable: false),
PermissionId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
table.ForeignKey(
name: "FK_role_permissions_permissions_PermissionId",
column: x => x.PermissionId,
principalTable: "permissions",
principalColumn: "PermissionId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_role_permissions_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[,]
{
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
{ 2, "products", "/dashboard/products", null, "Products", 2 },
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
{ 10, "help", "/dashboard/help", null, "Help", 10 }
});
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1,
column: "RoleId",
value: null);
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 1, "NAV:dashboard", 1, null },
{ 2, "NAV:products", 2, null },
{ 3, "NAV:vendors", 3, null },
{ 4, "NAV:procurement", 4, null },
{ 5, "NAV:receiving", 5, null },
{ 6, "NAV:stock", 6, null },
{ 7, "NAV:warehouses", 7, null },
{ 8, "NAV:orders", 8, null },
{ 9, "NAV:settings", 9, null },
{ 10, "NAV:help", 10, null }
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 11, "NAV:products.item", null, 1 },
{ 12, "NAV:products.category", null, 2 },
{ 13, "NAV:products.brand", null, 3 },
{ 14, "NAV:products.item-type", null, 4 },
{ 15, "NAV:products.uom", null, 5 },
{ 16, "NAV:products.configuration", null, 6 },
{ 17, "NAV:settings.roles", null, 7 },
{ 18, "NAV:settings.users", null, 8 }
});
migrationBuilder.CreateIndex(
name: "IX_users_RoleId",
table: "users",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_nav_items_Code",
table: "nav_items",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_permissions_Code",
table: "permissions",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_permissions_NavItemId",
table: "permissions",
column: "NavItemId");
migrationBuilder.CreateIndex(
name: "IX_permissions_SubNavItemId",
table: "permissions",
column: "SubNavItemId");
migrationBuilder.CreateIndex(
name: "IX_role_permissions_PermissionId",
table: "role_permissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_roles_auth_role_id",
table: "roles",
column: "auth_role_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_roles_Code",
table: "roles",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sub_nav_items_Code",
table: "sub_nav_items",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sub_nav_items_NavItemId",
table: "sub_nav_items",
column: "NavItemId");
migrationBuilder.AddForeignKey(
name: "FK_users_roles_RoleId",
table: "users",
column: "RoleId",
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_users_roles_RoleId",
table: "users");
migrationBuilder.DropTable(
name: "role_permissions");
migrationBuilder.DropTable(
name: "permissions");
migrationBuilder.DropTable(
name: "roles");
migrationBuilder.DropTable(
name: "sub_nav_items");
migrationBuilder.DropTable(
name: "nav_items");
migrationBuilder.DropIndex(
name: "IX_users_RoleId",
table: "users");
migrationBuilder.DropColumn(
name: "RoleId",
table: "users");
}
}
}
@@ -119,48 +119,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("bins", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
{
b.Property<int>("BrandId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BrandId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("BrandId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("brands", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Property<int>("CategoryId")
@@ -169,36 +127,17 @@ namespace ERPCore.Infra.Persistence.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<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.Property<int?>("ParentId")
.HasColumnType("integer");
b.HasKey("CategoryId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.HasIndex("ParentId");
b.ToTable("categories", (string)null);
});
@@ -334,9 +273,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Property<int>("BaseUomId")
.HasColumnType("integer");
b.Property<int?>("BrandId")
.HasColumnType("integer");
b.Property<int>("CategoryId")
.HasColumnType("integer");
@@ -350,6 +286,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ItemType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -373,14 +314,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<string>("StockNature")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int?>("SubCategoryId")
.HasColumnType("integer");
b.Property<string>("TaxClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
@@ -397,8 +330,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("BaseUomId");
b.HasIndex("BrandId");
b.HasIndex("CategoryId");
b.HasIndex("DefaultVendorId");
@@ -408,8 +339,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("Status");
b.HasIndex("SubCategoryId");
b.ToTable("items", (string)null);
});
@@ -445,48 +374,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("item_reorders", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b =>
{
b.Property<int>("ItemTypeId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemTypeId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ItemTypeId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Status");
b.ToTable("item_types", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b =>
{
b.Property<int>("JournalId")
@@ -524,142 +411,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("journal_entry_stubs", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
{
b.Property<int>("NavItemId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("NavItemId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Href")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Icon")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.HasKey("NavItemId");
b.HasIndex("Code")
.IsUnique();
b.ToTable("nav_items", (string)null);
b.HasData(
new
{
NavItemId = 1,
Code = "dashboard",
Href = "/dashboard",
Label = "Dashboard",
SortOrder = 1,
Status = "Active"
},
new
{
NavItemId = 2,
Code = "products",
Href = "/dashboard/products",
Label = "Products",
SortOrder = 2,
Status = "Active"
},
new
{
NavItemId = 3,
Code = "vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
SortOrder = 3,
Status = "Active"
},
new
{
NavItemId = 4,
Code = "procurement",
Href = "/dashboard/procurement",
Label = "Procurement",
SortOrder = 4,
Status = "Active"
},
new
{
NavItemId = 5,
Code = "receiving",
Href = "/dashboard/receiving/grn",
Label = "Receiving",
SortOrder = 5,
Status = "Active"
},
new
{
NavItemId = 6,
Code = "stock",
Href = "/dashboard/stock",
Label = "Stock",
SortOrder = 6,
Status = "Active"
},
new
{
NavItemId = 7,
Code = "warehouses",
Href = "/dashboard/warehouse",
Label = "Warehouses",
SortOrder = 7,
Status = "Active"
},
new
{
NavItemId = 8,
Code = "orders",
Href = "/dashboard/orders",
Label = "Orders",
SortOrder = 8,
Status = "Active"
},
new
{
NavItemId = 9,
Code = "settings",
Href = "/dashboard/settings",
Label = "Settings",
SortOrder = 9,
Status = "Active"
},
new
{
NavItemId = 10,
Code = "help",
Href = "/dashboard/help",
Label = "Help",
SortOrder = 10,
Status = "Active"
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
{
b.Property<int>("SequenceId")
@@ -690,147 +441,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("number_sequences", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b =>
{
b.Property<int>("PermissionId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PermissionId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<int?>("NavItemId")
.HasColumnType("integer");
b.Property<int?>("SubNavItemId")
.HasColumnType("integer");
b.HasKey("PermissionId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("NavItemId");
b.HasIndex("SubNavItemId");
b.ToTable("permissions", (string)null);
b.HasData(
new
{
PermissionId = 1,
Code = "NAV:dashboard",
NavItemId = 1
},
new
{
PermissionId = 2,
Code = "NAV:products",
NavItemId = 2
},
new
{
PermissionId = 3,
Code = "NAV:vendors",
NavItemId = 3
},
new
{
PermissionId = 4,
Code = "NAV:procurement",
NavItemId = 4
},
new
{
PermissionId = 5,
Code = "NAV:receiving",
NavItemId = 5
},
new
{
PermissionId = 6,
Code = "NAV:stock",
NavItemId = 6
},
new
{
PermissionId = 7,
Code = "NAV:warehouses",
NavItemId = 7
},
new
{
PermissionId = 8,
Code = "NAV:orders",
NavItemId = 8
},
new
{
PermissionId = 9,
Code = "NAV:settings",
NavItemId = 9
},
new
{
PermissionId = 10,
Code = "NAV:help",
NavItemId = 10
},
new
{
PermissionId = 11,
Code = "NAV:products.item",
SubNavItemId = 1
},
new
{
PermissionId = 12,
Code = "NAV:products.category",
SubNavItemId = 2
},
new
{
PermissionId = 13,
Code = "NAV:products.brand",
SubNavItemId = 3
},
new
{
PermissionId = 14,
Code = "NAV:products.item-type",
SubNavItemId = 4
},
new
{
PermissionId = 15,
Code = "NAV:products.uom",
SubNavItemId = 5
},
new
{
PermissionId = 16,
Code = "NAV:products.configuration",
SubNavItemId = 6
},
new
{
PermissionId = 17,
Code = "NAV:settings.roles",
SubNavItemId = 7
},
new
{
PermissionId = 18,
Code = "NAV:settings.users",
SubNavItemId = 8
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
{
b.Property<int>("PoLineId")
@@ -880,48 +490,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("po_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("BrandsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("ItemTypesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<bool>("SubcategoriesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("product_config", null, t =>
{
t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Property<int>("PoId")
@@ -1219,78 +787,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("rfq_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Role", b =>
{
b.Property<int>("RoleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RoleId"));
b.Property<Guid>("AuthRoleId")
.HasColumnType("uuid")
.HasColumnName("auth_role_id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystemRole")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
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("RoleId");
b.HasIndex("AuthRoleId")
.IsUnique();
b.HasIndex("Code")
.IsUnique();
b.ToTable("roles", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b =>
{
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<int>("PermissionId")
.HasColumnType("integer");
b.HasKey("RoleId", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.Property<int>("SerialId")
@@ -1743,182 +1239,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.ToTable("stock_transfer_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{
b.Property<int>("SubCategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SubCategoryId"));
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("SubCategoryId");
b.HasIndex("Status");
b.HasIndex("CategoryId", "Name")
.IsUnique();
b.ToTable("subcategories", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b =>
{
b.Property<int>("SubNavItemId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SubNavItemId"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Href")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Icon")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("NavItemId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.HasKey("SubNavItemId");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("NavItemId");
b.ToTable("sub_nav_items", (string)null);
b.HasData(
new
{
SubNavItemId = 1,
Code = "products.item",
Href = "/dashboard/products",
Label = "Item",
NavItemId = 2,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 2,
Code = "products.category",
Href = "/dashboard/products/categories",
Label = "Category",
NavItemId = 2,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 3,
Code = "products.brand",
Href = "/dashboard/products/brands",
Label = "Brand",
NavItemId = 2,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 4,
Code = "products.item-type",
Href = "/dashboard/products/item-types",
Label = "Item Type",
NavItemId = 2,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 5,
Code = "products.uom",
Href = "/dashboard/products/uoms",
Label = "UOM",
NavItemId = 2,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 6,
Code = "products.configuration",
Href = "/dashboard/products/settings",
Label = "Configuration",
NavItemId = 2,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 7,
Code = "settings.roles",
Href = "/dashboard/settings/roles",
Label = "Roles",
NavItemId = 9,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 8,
Code = "settings.users",
Href = "/dashboard/settings/users",
Label = "Users",
NavItemId = 9,
SortOrder = 2,
Status = "Active"
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
{
b.Property<int>("UomId")
@@ -1990,9 +1310,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("RoleId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
@@ -2008,8 +1325,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasIndex("AuthUserId")
.IsUnique();
b.HasIndex("RoleId");
b.HasIndex("Username")
.IsUnique();
@@ -2201,6 +1516,16 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -2291,11 +1616,6 @@ namespace ERPCore.Infra.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Brand", "Brand")
.WithMany()
.HasForeignKey("BrandId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
@@ -2307,20 +1627,11 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasForeignKey("DefaultVendorId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory")
.WithMany()
.HasForeignKey("SubCategoryId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("BaseUom");
b.Navigation("Brand");
b.Navigation("Category");
b.Navigation("DefaultVendor");
b.Navigation("SubCategory");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
@@ -2342,23 +1653,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b =>
{
b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem")
.WithMany()
.HasForeignKey("NavItemId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem")
.WithMany()
.HasForeignKey("SubNavItemId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("NavItem");
b.Navigation("SubNavItem");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -2394,16 +1688,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -2551,25 +1835,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Rfq");
});
modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b =>
{
b.HasOne("ERPCore.Domain.Entities.Permission", "Permission")
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Permission");
b.Navigation("Role");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
@@ -2827,28 +2092,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("Transfer");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
.WithMany("SubCategories")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Category");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b =>
{
b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem")
.WithMany("Children")
.HasForeignKey("NavItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NavItem");
});
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
{
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
@@ -2876,16 +2119,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("ToUom");
});
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
{
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Role");
});
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
{
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
@@ -2926,7 +2159,7 @@ namespace ERPCore.Infra.Persistence.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
{
b.Navigation("SubCategories");
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
@@ -2941,11 +2174,6 @@ namespace ERPCore.Infra.Persistence.Migrations
b.Navigation("UomConversions");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
{
b.Navigation("Lines");
-7
View File
@@ -62,16 +62,9 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management
builder.Services.AddScoped<IRoleService, RoleService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
// Cross-cutting + procurement services (docs/11 §3)
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
+8 -54
View File
@@ -1,7 +1,6 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -24,7 +23,6 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -32,62 +30,19 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null;
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -139,12 +94,11 @@ public sealed class AdjustmentService : IAdjustmentService
return (entity, refs);
}, ct);
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
}
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,11 +55,8 @@ public sealed class AuthUserService : IAuthUserService
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
/// <summary>The controller resolves the id (from body or token claim) before calling here.</summary>
public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
=> request.UserId is null
? Task.CompletedTask
: _authHex.LogoutUserAsync(request.UserId.Value, ct);
=> _authHex.LogoutUserAsync(request.UserId, ct);
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
-114
View File
@@ -1,114 +0,0 @@
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);
}
+23 -181
View File
@@ -1,6 +1,4 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Infra.UoW;
@@ -11,31 +9,18 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels:
/// categories no longer self-nest, so there is no cycle to detect and no tree to build
/// (docs/11-BACKEND-PHASE1.md §2.3).
/// </summary>
public sealed class CategoryService : ICategoryService
{
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IUnitOfWork _uow;
public CategoryService(
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IUnitOfWork uow)
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
{
_categories = categories;
_subCategories = subCategories;
_uow = uow;
}
// Categories ---------------------------------------------------------------
public async Task<PagedResponse<CategoryDto>> ListAsync(
PageQuery query, EntityStatus? status, CancellationToken ct = default)
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
var q = _categories.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -43,186 +28,43 @@ public sealed class CategoryService : ICategoryService
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
{
var category = await _categories.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
var all = await _categories.Query().AsNoTracking()
.OrderBy(c => c.Name)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
var byParent = all.ToLookup(c => c.ParentId);
List<CategoryTreeDto> Build(int? parentId) =>
byParent[parentId]
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
.ToList();
return Build(null);
}
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
{
var name = request.Name.Trim();
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
throw new ConflictException($"A category named '{name}' already exists.");
var category = new Category
{
Name = name,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
if (request.ParentId is not null
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
await _categories.AddAsync(category, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
}
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 -26
View File
@@ -1,7 +1,6 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -50,30 +49,6 @@ public sealed class CountService : ICountService
_uow = uow;
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
@@ -200,7 +175,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
-27
View File
@@ -1,7 +1,6 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -66,32 +65,6 @@ public sealed class GrnService : IGrnService
_uow = uow;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
@@ -1,4 +1,3 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -6,10 +5,5 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -1,16 +0,0 @@
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,28 +1,12 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <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>
/// <summary>Category master business logic (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public interface ICategoryService
{
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default);
Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
Task<ETagged<CategoryDto>> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default);
/// <summary>Subcategories of one category. 404s when the category itself does not exist.</summary>
Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default);
Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default);
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
}
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -7,9 +5,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
public interface ICountService
{
Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default);
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
@@ -7,9 +5,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -12,8 +12,7 @@ namespace ERPCore.Services.Interfaces;
public interface IItemService
{
Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default);
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default);
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
@@ -1,20 +0,0 @@
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);
}
@@ -1,13 +0,0 @@
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,4 +1,3 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -6,10 +5,5 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
public interface IPurchaseReturnService
{
Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default);
Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default);
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
}
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -7,8 +6,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-requisition business logic (docs/11 §3.1).</summary>
public interface IRequisitionService
{
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(
PageQuery query, RequisitionStatus? status, CancellationToken ct = default);
Task<PagedResponse<RequisitionSummaryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<RequisitionDto?> GetAsync(int requisitionId, CancellationToken ct = default);
Task<RequisitionDto> CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default);
Task<RequisitionDto> SubmitAsync(int requisitionId, CancellationToken ct = default);
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
@@ -7,8 +5,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>RFQ &amp; vendor-quotation business logic (docs/11 §3.2).</summary>
public interface IRfqService
{
Task<PagedResponse<RfqSummaryDto>> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default);
Task<RfqDto?> GetAsync(int rfqId, CancellationToken ct = default);
Task<RfqDto> CreateAsync(CreateRfqRequest request, CancellationToken ct = default);
Task<VendorQuotationDto> AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default);
@@ -1,28 +0,0 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Role CRUD + permission assignment. AuthHex is the source of truth for Role
/// identity (docs/10 C.9 "shadow user" pattern, applied to Role): every write is
/// forwarded to AuthHex first, then mirrored into the local shadow <c>Role</c> row.
/// Permission assignment is purely local (ERPCore/UI concern, not an AuthHex one).
/// </summary>
public interface IRoleService
{
Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default);
Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default);
Task<ETagged<RoleDto>> UpdateAsync(int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int roleId, CancellationToken ct = default);
Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default);
Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default);
/// <summary>Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`.</summary>
Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default);
}
@@ -8,13 +8,8 @@ public interface IStockService
{
Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
}
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -7,9 +5,6 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
public interface ITransferService
{
Task<PagedResponse<TransferSummaryDto>> ListAsync(
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
@@ -1,20 +0,0 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Admin-facing user management: list/create/reassign-role against the local
/// shadow `User` table, orchestrating account creation in AuthHex too (see
/// <see cref="CreateUserRequest"/>).
/// </summary>
public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
}
+12 -91
View File
@@ -13,52 +13,36 @@ namespace ERPCore.Services;
/// <summary>
/// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference
/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic
/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.12.2
/// and 02-SECURITY C.1.
/// <para>
/// The SKU arrives generated by the client (it encodes the chosen item-type values,
/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no
/// item-type reference at all — see docs/10 Part C.9.
/// </para>
/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per
/// docs/11-BACKEND-PHASE1.md §2.12.2 and 02-SECURITY C.1.
/// </summary>
public sealed class ItemService : IItemService
{
private readonly IRepository<Item> _items;
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IRepository<Brand> _brands;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IProductConfigService _config;
private readonly IUnitOfWork _uow;
public ItemService(
IRepository<Item> items,
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IRepository<Brand> brands,
IRepository<Uom> uoms,
IRepository<Vendor> vendors,
IRepository<Warehouse> warehouses,
IProductConfigService config,
IUnitOfWork uow)
{
_items = items;
_categories = categories;
_subCategories = subCategories;
_brands = brands;
_uoms = uoms;
_vendors = vendors;
_warehouses = warehouses;
_config = config;
_uow = uow;
}
public async Task<PagedResponse<ItemListItemDto>> ListAsync(
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
TrackingMode? trackingMode, CancellationToken ct = default)
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default)
{
var q = _items.Query().AsNoTracking();
@@ -69,17 +53,14 @@ public sealed class ItemService : IItemService
}
if (status is not null) q = q.Where(i => i.Status == status);
if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId);
if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId);
if (brandId is not null) q = q.Where(i => i.BrandId == brandId);
if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(i => i.Sku)
.Skip(query.Skip).Take(query.PageSize)
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.Status))
i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
@@ -89,7 +70,6 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query().AsNoTracking()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct);
return item is null ? null : new ETagged<ItemDetailDto>(ToDetail(item), item.RowVersion);
@@ -100,9 +80,7 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
var item = new Item
{
@@ -110,11 +88,9 @@ public sealed class ItemService : IItemService
Name = request.Name.Trim(),
Description = request.Description,
CategoryId = request.CategoryId,
SubCategoryId = request.SubCategoryId,
BrandId = request.BrandId,
BaseUomId = request.BaseUomId,
DefaultVendorId = request.DefaultVendorId,
StockNature = request.StockNature,
ItemType = request.ItemType,
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
Status = EntityStatus.Active,
@@ -132,7 +108,6 @@ public sealed class ItemService : IItemService
{
var item = await _items.Query()
.Include(i => i.ReorderSettings)
.Include(i => i.UomConversions)
.FirstOrDefaultAsync(i => i.ItemId == itemId, ct)
?? throw new NotFoundException($"Item {itemId} was not found.");
@@ -143,19 +118,15 @@ public sealed class ItemService : IItemService
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct);
item.Sku = request.Sku.Trim();
item.Name = request.Name.Trim();
item.Description = request.Description;
item.CategoryId = request.CategoryId;
item.SubCategoryId = request.SubCategoryId;
item.BrandId = request.BrandId;
item.BaseUomId = request.BaseUomId;
item.DefaultVendorId = request.DefaultVendorId;
item.StockNature = request.StockNature;
item.ItemType = request.ItemType;
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.UpdatedAt = DateTime.UtcNow;
@@ -269,56 +240,11 @@ public sealed class ItemService : IItemService
return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions);
}
/// <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)
private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct)
{
var config = (await _config.GetAsync(ct)).Value;
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
if (subCategoryId is not null)
{
if (!config.SubcategoriesEnabled)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Subcategories are disabled in the product configuration; subCategoryId must be null.", 422);
var sub = await _subCategories.Query().AsNoTracking()
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
if (sub is null)
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422);
if (sub.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422);
// The two FKs must agree, or the item would claim a category its subcategory
// does not belong to.
if (sub.CategoryId != categoryId)
throw new DomainException(
ErrorCodes.Validation,
$"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422);
}
if (brandId is not null)
{
if (!config.BrandsEnabled)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Brands are disabled in the product configuration; brandId must be null.", 422);
var brand = await _brands.Query().AsNoTracking()
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
if (brand is null)
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422);
if (brand.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422);
}
if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422);
@@ -346,16 +272,11 @@ public sealed class ItemService : IItemService
}
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.Status,
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId,
i.ItemType, i.TrackingMode, i.TaxClass, i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
.ToList(),
i.UomConversions
.OrderBy(c => c.ConversionId)
.Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor))
.ToList(),
i.CreatedAt, i.UpdatedAt);
}
-118
View File
@@ -1,118 +0,0 @@
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);
}
@@ -1,71 +0,0 @@
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,7 +1,6 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -26,7 +25,6 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -35,8 +33,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLedger> ledger, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
@@ -44,54 +41,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _returns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%"));
}
if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.ReturnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new PurchaseReturnSummaryDto(
r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status,
r.CreatedBy, r.CreatedAt, r.Lines.Count))
.ToListAsync(ct);
return PagedResponse<PurchaseReturnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<PurchaseReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{
var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null;
// Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(ret, ledgerRefs);
}
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -150,12 +105,11 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
}, ct);
// Map ledger ids after commit so they are populated.
return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList());
return new PurchaseReturnDto(
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
entity.CreatedBy,
entity.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerEntries.Select(r => r.LedgerId).ToList());
}
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);
}

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