Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 295ec5799f | |||
| fe9e8a780f | |||
| 92c4b14a6c | |||
| 80b130dffb | |||
| 62a5d857de | |||
| f72b24fcaa | |||
| 7c5faabc2d | |||
| 582782b0fe |
@@ -29,3 +29,10 @@ 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/
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the
|
||||
/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly
|
||||
/// Secure cookies (docs/02-SECURITY.md §B.2) via <see cref="AuthCookieWriter"/>
|
||||
/// — response bodies never carry raw tokens. Does not inherit
|
||||
/// <see cref="ApiControllerBase"/>: most actions here are pre-session and need
|
||||
/// <see cref="AllowAnonymousAttribute"/>, and the ETag/If-Match handling that
|
||||
/// base provides doesn't apply to auth flows.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Route("api/v1/auth")]
|
||||
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
|
||||
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)
|
||||
{
|
||||
_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) ------------------------------------
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthSessionResponse>> Register([FromBody] RegisterRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.RegisterAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthSessionResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.LoginAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
[HttpPost("login/otp/verify")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthSessionResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
|
||||
throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401);
|
||||
|
||||
var result = await _users.RefreshTokenAsync(refreshToken, request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
// ---- Profile / sessions (UserManager) ---------------------------------
|
||||
|
||||
[HttpGet("users/{userId:guid}")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GetUserDetailsResponse>> GetUserDetails(Guid userId, CancellationToken ct)
|
||||
=> Ok(await _users.GetUserDetailsAsync(userId, ct));
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[ProducesResponseType(typeof(List<SessionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<SessionDto>>> GetSessions(CancellationToken ct)
|
||||
=> Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("status")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("lock")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Lock([FromBody] LockUserAccountRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.LockUserAccountAsync(request, RequireBearerToken(), ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("verify-password")]
|
||||
[ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)]
|
||||
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)
|
||||
{
|
||||
var userId = request?.UserId ?? ResolveTokenUserId();
|
||||
if (userId is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
|
||||
}
|
||||
catch (DomainException)
|
||||
{
|
||||
// Upstream unreachable or already-revoked — fall through and clear anyway.
|
||||
}
|
||||
}
|
||||
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
|
||||
private Guid? ResolveTokenUserId()
|
||||
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
|
||||
|
||||
[HttpPut("me")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<UserSummaryDto?>> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
// ---- 2FA (UserManager) -------------------------------------------------
|
||||
|
||||
[HttpPost("2fa/initiate")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TwoFaSetupResponse>> InitiateTwoFa(CancellationToken ct)
|
||||
=> Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("2fa/complete")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CompleteTwoFaSetupResponse>> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
|
||||
|
||||
[HttpPost("2fa/verify")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
[ValidateCsrf]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("2fa/status")]
|
||||
[ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TwoFaStatusResponse>> GetTwoFaStatus(CancellationToken ct)
|
||||
=> Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
|
||||
|
||||
// ---- Recovery -----------------------------------------------------------
|
||||
|
||||
[HttpPost("recovery/forgot-password")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ForgotPasswordResponse>> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> Ok(await _recovery.ForgotPasswordAsync(request, ct));
|
||||
|
||||
[HttpPost("recovery/verify-otp")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<VerifyRecoveryOtpResponse>> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> Ok(await _recovery.VerifyOtpAsync(request, ct));
|
||||
|
||||
[HttpPost("recovery/reset-password")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
await _recovery.ResetPasswordAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("recovery/reset-password-token")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
await _recovery.ResetPasswordWithTokenAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ---- Availability / OTP (AltOptionManager) -----------------------------
|
||||
|
||||
[HttpPost("availability")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IsAvailableResponse>> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
|
||||
=> Ok(await _alt.IsAvailableAsync(request, ct));
|
||||
|
||||
[HttpPost("otp/send")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SendOtpResponse>> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
|
||||
=> Ok(await _alt.SendOtpAsync(request, ct));
|
||||
|
||||
[HttpPost("otp/verify")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _alt.VerifyOtpAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
/// <summary>The token that authenticated this request — Bearer header if present, else the session cookie.</summary>
|
||||
private string RequireBearerToken()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return header["Bearer ".Length..];
|
||||
|
||||
if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
|
||||
return cookieToken;
|
||||
|
||||
// [Authorize] already guaranteed one of the above was present to authenticate this request.
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
[Route("api/v1/brands")]
|
||||
public sealed class BrandsController : ApiControllerBase
|
||||
{
|
||||
private readonly IBrandService _brands;
|
||||
|
||||
public BrandsController(IBrandService brands) => _brands = brands;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BrandDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _brands.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{brandId:int}")]
|
||||
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BrandDto>> GetById(int brandId, CancellationToken ct)
|
||||
{
|
||||
var result = await _brands.GetAsync(brandId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<BrandDto>> Create([FromBody] CreateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _brands.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{brandId:int}")]
|
||||
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<BrandDto>> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _brands.UpdateAsync(brandId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{brandId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _brands.SetStatusAsync(brandId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
||||
/// <summary>
|
||||
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
|
||||
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
|
||||
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||
/// </summary>
|
||||
[Route("api/v1/categories")]
|
||||
public sealed class CategoriesController : ApiControllerBase
|
||||
{
|
||||
@@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase
|
||||
|
||||
public CategoriesController(ICategoryService categories) => _categories = categories;
|
||||
|
||||
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
|
||||
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _categories.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{categoryId:int}")]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.GetAsync(categoryId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _categories.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
|
||||
var result = await _categories.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{categoryId:int}")]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<CategoryDto>> Update(
|
||||
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{categoryId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _categories.SetStatusAsync(categoryId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Subcategories — nested under their parent category (docs/11 §2.3).
|
||||
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
|
||||
|
||||
[HttpGet("{categoryId:int}/subcategories")]
|
||||
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
|
||||
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
|
||||
|
||||
[HttpPost("{categoryId:int}/subcategories")]
|
||||
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
|
||||
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
|
||||
/// <summary>List GRNs, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{grnId:int}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
|
||||
/// dimension names. <c>GET</c> is the reason this master exists: it populates the item
|
||||
/// builder's dropdown. Items never reference an item type; the chosen values are encoded
|
||||
/// into the client-generated SKU (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
[Route("api/v1/item-types")]
|
||||
public sealed class ItemTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTypeService _itemTypes;
|
||||
|
||||
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
|
||||
|
||||
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _itemTypes.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{itemTypeId:int}")]
|
||||
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemTypeDto>> GetById(int itemTypeId, CancellationToken ct)
|
||||
{
|
||||
var result = await _itemTypes.GetAsync(itemTypeId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ItemTypeDto>> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _itemTypes.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{itemTypeId:int}")]
|
||||
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ItemTypeDto>> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{itemTypeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,11 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||
|
||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||
[HttpGet("{itemId:int}")]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
|
||||
/// feature gate for subcategories/brands/item-types.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
|
||||
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
|
||||
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
|
||||
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
|
||||
/// the accepted Phase-1 posture, consistent with every other endpoint.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/product-config")]
|
||||
public sealed class ProductConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductConfigService _config;
|
||||
|
||||
public ProductConfigController(IProductConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<ProductConfigDto>> Update(
|
||||
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
|
||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>List posted returns, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
|
||||
|
||||
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{returnId:int}")]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.GetAsync(returnId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -15,8 +16,9 @@ public sealed class RequisitionsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{requisitionId:int}")]
|
||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
|
||||
|
||||
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
|
||||
|
||||
/// <summary>List RFQs, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
|
||||
=> Ok(await _rfqs.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{rfqId:int}")]
|
||||
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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,3 +1,4 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
|
||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||
|
||||
/// <summary>List posted adjustments, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
||||
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
|
||||
|
||||
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{adjustmentId:int}")]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _adjustments.GetAsync(adjustmentId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
|
||||
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
|
||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
|
||||
|
||||
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
|
||||
[HttpGet("on-hand/list")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
|
||||
|
||||
/// <summary>
|
||||
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
|
||||
/// this document post?" — the ledger's document reference is polymorphic, so there is
|
||||
/// no FK to navigate instead (docs/10 C.9).
|
||||
/// </summary>
|
||||
[HttpGet("ledger")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||
|
||||
[HttpGet("valuation")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
|
||||
|
||||
public StockCountsController(ICountService counts) => _counts = counts;
|
||||
|
||||
/// <summary>List counts, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
|
||||
|
||||
[HttpGet("{countId:int}")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
|
||||
|
||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
||||
|
||||
/// <summary>List transfers, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
|
||||
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
|
||||
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
|
||||
|
||||
[HttpGet("{transferId:int}")]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
|
||||
/// since a subcategory only exists in the context of one.
|
||||
/// </summary>
|
||||
[Route("api/v1/subcategories")]
|
||||
public sealed class SubCategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
public SubCategoriesController(ICategoryService categories) => _categories = categories;
|
||||
|
||||
[HttpGet("{subCategoryId:int}")]
|
||||
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SubCategoryDto>> GetById(int subCategoryId, CancellationToken ct)
|
||||
{
|
||||
var result = await _categories.GetSubCategoryAsync(subCategoryId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
|
||||
[HttpPut("{subCategoryId:int}")]
|
||||
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SubCategoryDto>> Update(
|
||||
int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{subCategoryId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(
|
||||
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
|
||||
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Brand
|
||||
{
|
||||
public int BrandId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
|
||||
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
|
||||
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
|
||||
/// <c>parent_id</c> tree was replaced in migration #2).
|
||||
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Category
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public int? ParentId { get; set; }
|
||||
public Category? Parent { get; set; }
|
||||
public ICollection<Category> Children { get; set; } = new List<Category>();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
|
||||
}
|
||||
|
||||
@@ -17,13 +17,20 @@ public class Item
|
||||
public int CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
|
||||
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
|
||||
public int? SubCategoryId { get; set; }
|
||||
public SubCategory? SubCategory { get; set; }
|
||||
|
||||
public int? BrandId { get; set; }
|
||||
public Brand? Brand { get; set; }
|
||||
|
||||
public int BaseUomId { get; set; }
|
||||
public Uom? BaseUom { get; set; }
|
||||
|
||||
public int? DefaultVendorId { get; set; }
|
||||
public Vendor? DefaultVendor { get; set; }
|
||||
|
||||
public ItemType ItemType { get; set; }
|
||||
public StockNature StockNature { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public string? TaxClass { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
|
||||
/// Material.
|
||||
/// <para>
|
||||
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
|
||||
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||
/// </para>
|
||||
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||
/// which is what the old <c>ItemType</c> enum became.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class ItemType
|
||||
{
|
||||
public int ItemTypeId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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>();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
|
||||
/// gating optional product master-data features.
|
||||
/// <para>
|
||||
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
||||
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
|
||||
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
|
||||
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
|
||||
/// so there is nothing on a write to reject; the frontend honours it by hiding the
|
||||
/// builder's type section. Reads are never gated, so existing data stays visible after a
|
||||
/// flag is switched off.
|
||||
/// </para>
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class ProductConfig
|
||||
{
|
||||
/// <summary>Always 1 — the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
public bool SubcategoriesEnabled { get; set; } = true;
|
||||
public bool BrandsEnabled { get; set; } = true;
|
||||
public bool ItemTypesEnabled { get; set; } = true;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Subcategory — the single optional level below <see cref="Category"/> (FR-MD-04).
|
||||
/// Replaces the former self-referencing CATEGORY.parent_id tree: the hierarchy is
|
||||
/// exactly two levels deep and cannot nest further. Referenced optionally by
|
||||
/// <see cref="Item.SubCategoryId"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class SubCategory
|
||||
{
|
||||
public int SubCategoryId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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; }
|
||||
}
|
||||
@@ -22,4 +22,8 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Item classification (FR-MD-01). Values match the <c>itemType</c> enum in
|
||||
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
||||
/// </summary>
|
||||
public enum ItemType
|
||||
{
|
||||
Stocked,
|
||||
NonStocked,
|
||||
Service
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an item holds stock (FR-MD-01). Values match the <c>stockNature</c> enum in
|
||||
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
||||
/// Renamed from <c>ItemType</c> so that name could be taken by the ItemType master
|
||||
/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
public enum StockNature
|
||||
{
|
||||
Stocked,
|
||||
NonStocked,
|
||||
Service
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
public sealed class IsAvailableRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public string? Recovery { get; set; }
|
||||
}
|
||||
|
||||
public sealed class IsAvailableResponse
|
||||
{
|
||||
public bool? IsAvailable { get; set; }
|
||||
public string? Message { get; set; }
|
||||
/// <summary>Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog.</summary>
|
||||
public JsonElement? ExistingUsers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SendOtpRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public int NumberOfDigits { get; set; } = 6;
|
||||
public bool NewUser { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SendOtpResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? RecoveryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyAltOtpRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
public string? Identifier { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool NewUser { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
public sealed class ForgotPasswordRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public bool UseResetLink { get; set; }
|
||||
public int NumberOfDigits { get; set; } = 6;
|
||||
public bool Welcome { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForgotPasswordResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? RecoveryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyRecoveryOtpRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyRecoveryOtpResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordWithTokenRequest
|
||||
{
|
||||
[Required] public string ResetToken { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
/// <summary>Shared AuthHex user projection (API_REFERENCE.md §3). Field set is
|
||||
/// AuthHex's best-documented subset; unknown fields are ignored on deserialize.</summary>
|
||||
public sealed class UserSummaryDto
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? RoleId { get; set; }
|
||||
public Guid? UserTypeId { get; set; }
|
||||
public string? Fullname { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public bool? EmailVerified { get; set; }
|
||||
public bool? MobileNumberVerified { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public bool? IsLocked { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body returned by every session-issuing endpoint. Tokens never appear
|
||||
/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2).</summary>
|
||||
public sealed class AuthSessionResponse
|
||||
{
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||
public Guid? UserId { get; set; }
|
||||
[Required] public Guid RoleId { get; set; }
|
||||
[Required] public Guid UserTypeId { get; set; }
|
||||
public string? Fullname { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public bool? ChkUser { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
public Guid? UserTypeId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyOtpForLoginRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtpLoginVerifiedResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GetUserDetailsResponse
|
||||
{
|
||||
public UserSummaryDto? User { get; set; }
|
||||
/// <summary>Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog.</summary>
|
||||
public JsonElement? Role { get; set; }
|
||||
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; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Browser { get; set; }
|
||||
public string? OS { get; set; }
|
||||
public string? IPAddress { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChangeUserStatusRequest
|
||||
{
|
||||
[Required] public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LockUserAccountRequest
|
||||
{
|
||||
[Required] public bool IsLocked { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChangeUserPasswordRequest
|
||||
{
|
||||
[Required] public string CurrentPassword { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyPasswordRequest
|
||||
{
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyPasswordResponse
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserRequest
|
||||
{
|
||||
public string? FullName { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Optional1 { get; set; }
|
||||
public string? Optional2 { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public string? NewPassword { get; set; }
|
||||
public string? CurrentPassword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Passthrough — TOTP secret/QR payload shape is only loosely documented
|
||||
/// ("secret key, QR/otpauth URL ... from the third-party service").</summary>
|
||||
public sealed class TwoFaSetupResponse
|
||||
{
|
||||
public JsonElement Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CompleteTwoFaSetupRequest
|
||||
{
|
||||
[Required] public string SecretKey { get; set; } = string.Empty;
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CompleteTwoFaSetupResponse
|
||||
{
|
||||
public List<string> BackupCodes { get; set; } = new();
|
||||
public UserSummaryDto? User { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyTwoFaRequest
|
||||
{
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DisableTwoFaRequest
|
||||
{
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class TwoFaStatusResponse
|
||||
{
|
||||
public bool IsMfaEnabled { get; set; }
|
||||
public bool IsVerified { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
public bool HasBackupCodes { get; set; }
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Brands;
|
||||
|
||||
/// <summary>Brand resource (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
public sealed record BrandDto(
|
||||
int BrandId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
|
||||
public sealed class CreateBrandRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateBrandRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateBrandStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -1,15 +1,56 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Categories;
|
||||
|
||||
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
||||
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
|
||||
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
|
||||
// The hierarchy is exactly two levels: Category → SubCategory. The former
|
||||
// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in
|
||||
// migration #2 — see docs/10 Part C.1.
|
||||
|
||||
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
|
||||
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
|
||||
/// <summary>Category resource — the top level.</summary>
|
||||
public sealed record CategoryDto(
|
||||
int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>Subcategory resource — the single optional level below a category.</summary>
|
||||
public sealed record SubCategoryDto(
|
||||
int SubCategoryId, int CategoryId, string Name, EntityStatus Status,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
|
||||
public sealed class CreateCategoryRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
public int? ParentId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateCategoryRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateCategoryStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body for <c>POST /categories/{categoryId}/subcategories</c>; the parent comes from the route.</summary>
|
||||
public sealed class CreateSubCategoryRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body for <c>PUT /subcategories/{id}</c>. Name only — a subcategory cannot be reparented,
|
||||
/// since moving one would silently invalidate the category of every item referencing it.
|
||||
/// </summary>
|
||||
public sealed class UpdateSubCategoryRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateSubCategoryStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
|
||||
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
|
||||
/// </summary>
|
||||
public sealed record ProductConfigDto(
|
||||
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// <para>
|
||||
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
|
||||
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
|
||||
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
|
||||
/// actually bind — an omitted flag is a 400, not an accidental disable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UpdateProductConfigRequest
|
||||
{
|
||||
[Required] public bool? SubcategoriesEnabled { get; set; }
|
||||
[Required] public bool? BrandsEnabled { get; set; }
|
||||
[Required] public bool? ItemTypesEnabled { get; set; }
|
||||
}
|
||||
@@ -13,6 +13,11 @@ public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record GrnSummaryDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.ItemTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
|
||||
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
|
||||
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
|
||||
/// client-generated SKU rather than stored (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
public sealed record ItemTypeDto(
|
||||
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
|
||||
public sealed class CreateItemTypeRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateItemTypeRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateItemTypeStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -7,18 +7,28 @@ namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>Row shape for <c>GET /items</c>.</summary>
|
||||
public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
|
||||
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, EntityStatus Status);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
|
||||
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
|
||||
/// <summary>
|
||||
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
|
||||
/// <para>
|
||||
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
|
||||
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
|
||||
/// endpoint reads them back — so a detail screen could never show current state before
|
||||
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ItemDetailDto(
|
||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||
int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
IReadOnlyList<UomConversionDto> Conversions,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
||||
@@ -33,15 +43,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settin
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
|
||||
// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g.
|
||||
// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here
|
||||
// by design — items carry no item-type reference (docs/10 Part C.9).
|
||||
|
||||
public sealed class CreateItemRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(1000)] public string? Description { get; set; }
|
||||
[Required] public int CategoryId { get; set; }
|
||||
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
|
||||
public int? SubCategoryId { get; set; }
|
||||
/// <summary>Optional. Rejected when brands are disabled.</summary>
|
||||
public int? BrandId { get; set; }
|
||||
[Required] public int BaseUomId { get; set; }
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
}
|
||||
@@ -52,9 +70,13 @@ public sealed class UpdateItemRequest
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(1000)] public string? Description { get; set; }
|
||||
[Required] public int CategoryId { get; set; }
|
||||
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
|
||||
public int? SubCategoryId { get; set; }
|
||||
/// <summary>Optional. Rejected when brands are disabled.</summary>
|
||||
public int? BrandId { get; set; }
|
||||
[Required] public int BaseUomId { get; set; }
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
|
||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record PurchaseReturnSummaryDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
|
||||
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
||||
|
||||
public sealed record RequisitionSummaryDto(
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
|
||||
public sealed record RfqDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
|
||||
public sealed record RfqSummaryDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
|
||||
|
||||
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
|
||||
|
||||
public sealed record VendorQuotationDto(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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);
|
||||
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
@@ -0,0 +1,34 @@
|
||||
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,6 +11,11 @@ public sealed record AdjustmentDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
|
||||
public sealed record AdjustmentSummaryDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateAdjustmentLineInput
|
||||
|
||||
@@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock;
|
||||
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
|
||||
|
||||
public sealed record CountDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record CountSummaryDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
|
||||
|
||||
public sealed record TransferDto(
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
|
||||
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
|
||||
public sealed record TransferSummaryDto(
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
|
||||
|
||||
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Writes/clears the httpOnly session cookies + non-httpOnly CSRF cookie
|
||||
/// AuthController issues on every session-establishing call (docs/02-SECURITY.md
|
||||
/// §B.2). SameSite=Strict assumes frontend and ERPCore share a registrable
|
||||
/// domain (e.g. both on `localhost`, different ports) — a cross-domain
|
||||
/// deployment would need SameSite=None (+ Secure, which is already set).
|
||||
/// </summary>
|
||||
public static class AuthCookieWriter
|
||||
{
|
||||
public static void WriteSession(HttpResponse response, string accessToken, string refreshToken, int expiresInSeconds)
|
||||
{
|
||||
response.Cookies.Append(JwtAuthExtensions.AccessTokenCookie, accessToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
|
||||
});
|
||||
|
||||
response.Cookies.Append(JwtAuthExtensions.RefreshTokenCookie, refreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/v1/auth/refresh-token",
|
||||
MaxAge = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
response.Cookies.Append(JwtAuthExtensions.CsrfCookie, GenerateCsrfToken(), new CookieOptions
|
||||
{
|
||||
HttpOnly = false,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
|
||||
});
|
||||
}
|
||||
|
||||
public static void ClearSession(HttpResponse response)
|
||||
{
|
||||
response.Cookies.Delete(JwtAuthExtensions.AccessTokenCookie, new CookieOptions { Path = "/" });
|
||||
response.Cookies.Delete(JwtAuthExtensions.RefreshTokenCookie, new CookieOptions { Path = "/api/v1/auth/refresh-token" });
|
||||
response.Cookies.Delete(JwtAuthExtensions.CsrfCookie, new CookieOptions { Path = "/" });
|
||||
}
|
||||
|
||||
private static string GenerateCsrfToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IAuthHexClient"/>. Registered as a typed
|
||||
/// client (`AddHttpClient<IAuthHexClient, AuthHexClient>`) with its
|
||||
/// `BaseAddress` bound from `AuthHex:BaseUrl`. Every call POSTs AuthHex's
|
||||
/// `{ functionName, payload, reference }` envelope to the matching manager
|
||||
/// route and unwraps the `{ statusCode, success, message, data }` response,
|
||||
/// translating upstream failures into <see cref="DomainException"/>.
|
||||
/// </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
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public AuthHexClient(HttpClient http) => _http = http;
|
||||
|
||||
// ---- UserManager --------------------------------------------------
|
||||
|
||||
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "VerifyOtpForLogin", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "refreshToken", new { refreshToken, deviceName }, null, ct);
|
||||
|
||||
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);
|
||||
|
||||
public Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "ChangeUserStatus", new { isActive }, bearerToken, ct);
|
||||
|
||||
public Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "LockUserAccount", new { isLocked }, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "ChangeUserPassword", request, bearerToken, ct);
|
||||
|
||||
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<VerifyPasswordResponse>("user", "VerifyPassword", request, bearerToken, ct);
|
||||
|
||||
public Task LogoutUserAsync(Guid userId, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "LogoutUser", new { userId }, null, ct);
|
||||
|
||||
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<UserSummaryDto?>("user", "UpdateUser", request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<TwoFaSetupResponse>("user", "initiateTwoFASetup", new { }, bearerToken, ct);
|
||||
|
||||
public Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<CompleteTwoFaSetupResponse>("user", "completeTwoFASetup", request, bearerToken, ct);
|
||||
|
||||
public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "verifyTwoFA", request, bearerToken, ct);
|
||||
|
||||
public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "disableTwoFA", request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
||||
|
||||
// ---- RecoveryManager ------------------------------------------------
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
|
||||
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
|
||||
|
||||
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPasswordWithToken", request, null, ct);
|
||||
|
||||
// ---- AltOptionManager -------------------------------------------------
|
||||
|
||||
public Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct)
|
||||
=> CallAsync<IsAvailableResponse>("alt", "IsAvailable", request, null, ct);
|
||||
|
||||
public Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<SendOtpResponse>("alt", "sendOtp", request, null, ct);
|
||||
|
||||
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)
|
||||
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
|
||||
|
||||
private async Task<T> CallAsync<T>(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}");
|
||||
if (!string.IsNullOrEmpty(bearerToken))
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
|
||||
httpRequest.Content = JsonContent.Create(
|
||||
new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty },
|
||||
options: JsonOptions);
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _http.SendAsync(httpRequest, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
|
||||
}
|
||||
|
||||
AuthHexEnvelope<T>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = await httpResponse.Content.ReadFromJsonAsync<AuthHexEnvelope<T>>(JsonOptions, ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an unreadable response.", 502);
|
||||
}
|
||||
|
||||
if (envelope is null)
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an empty response.", 502);
|
||||
|
||||
// Trust the outer HTTP status over envelope.Success — AuthHex has been observed
|
||||
// returning HTTP 500 with `success:true, data:null` on business failures (e.g.
|
||||
// invalid credentials), which would otherwise slip through as a "success" and
|
||||
// hand a null payload to the caller.
|
||||
if (!httpResponse.IsSuccessStatusCode || !envelope.Success)
|
||||
{
|
||||
var statusCode = !httpResponse.IsSuccessStatusCode
|
||||
? (int)httpResponse.StatusCode
|
||||
: envelope.StatusCode is >= 400 and < 600 ? envelope.StatusCode : 400;
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, envelope.Message ?? "Authentication request failed.", statusCode);
|
||||
}
|
||||
|
||||
return envelope.Data!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1).</summary>
|
||||
public sealed class AuthHexEnvelope<T>
|
||||
{
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1).</summary>
|
||||
public sealed class AuthHexRequestBody
|
||||
{
|
||||
public string FunctionName { get; set; } = string.Empty;
|
||||
public object Payload { get; set; } = new { };
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape shared by every session-issuing AuthHex function (register, login,
|
||||
/// OTP login verify, refresh, alt OTP verify). Carries the raw tokens — kept
|
||||
/// internal so they never leak into a public Dtos/Auth response; AuthController
|
||||
/// extracts them into httpOnly cookies and returns only <see cref="AuthSessionResponse"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthHexSessionResult
|
||||
{
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>
|
||||
/// Typed client for the external AuthHex identity service (API_REFERENCE.md).
|
||||
/// Hides AuthHex's `functionName` dispatcher entirely — callers get one method
|
||||
/// per function. Internal: only Services/Auth consumes this; the controller
|
||||
/// boundary only ever sees Dtos/Auth types.
|
||||
/// </summary>
|
||||
public interface IAuthHexClient
|
||||
{
|
||||
// UserManager (POST /api/user)
|
||||
Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct);
|
||||
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);
|
||||
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct);
|
||||
Task LogoutUserAsync(Guid userId, CancellationToken ct);
|
||||
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct);
|
||||
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct);
|
||||
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct);
|
||||
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
|
||||
|
||||
// RecoveryManager (POST /api/recovery)
|
||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
||||
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
||||
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct);
|
||||
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct);
|
||||
|
||||
// AltOptionManager (POST /api/alt)
|
||||
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);
|
||||
}
|
||||
@@ -18,6 +18,18 @@ public static class JwtAuthExtensions
|
||||
/// <summary>Authorization policy applied to every v1 controller (via ApiControllerBase).</summary>
|
||||
public const string ErpAccessPolicy = "ErpAccess";
|
||||
|
||||
/// <summary>httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2).</summary>
|
||||
public const string AccessTokenCookie = "erp_at";
|
||||
|
||||
/// <summary>httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route.</summary>
|
||||
public const string RefreshTokenCookie = "erp_rt";
|
||||
|
||||
/// <summary>Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations.</summary>
|
||||
public const string CsrfCookie = "XSRF-TOKEN";
|
||||
|
||||
/// <summary>Header the frontend echoes the CSRF cookie value back through.</summary>
|
||||
public const string CsrfHeader = "X-XSRF-TOKEN";
|
||||
|
||||
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
var issuer = config["Auth:Issuer"];
|
||||
@@ -49,6 +61,23 @@ public static class JwtAuthExtensions
|
||||
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
// BFF cookie fallback: browsers hitting ERPCore through AuthController's
|
||||
// httpOnly cookie session carry no Authorization header. Only used when
|
||||
// that header is absent, so Bearer callers (Swagger, service-to-service,
|
||||
// AuthHexClient forwarding) are unaffected.
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(context.Token) &&
|
||||
context.Request.Cookies.TryGetValue(AccessTokenCookie, out var cookieToken) &&
|
||||
!string.IsNullOrEmpty(cookieToken))
|
||||
{
|
||||
context.Token = cookieToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
|
||||
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
|
||||
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
|
||||
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
|
||||
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
|
||||
/// issues alongside the session cookies.
|
||||
/// </summary>
|
||||
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
|
||||
|
||||
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
|
||||
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class BrandConfiguration : IEntityTypeConfiguration<Brand>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Brand> builder)
|
||||
{
|
||||
builder.ToTable("brands");
|
||||
builder.HasKey(b => b.BrandId);
|
||||
|
||||
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
|
||||
builder.HasIndex(b => b.Name).IsUnique();
|
||||
|
||||
builder.Property(b => b.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(b => b.CreatedAt).IsRequired();
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(b => b.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasIndex(b => b.Status);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
|
||||
builder.HasKey(c => c.CategoryId);
|
||||
|
||||
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||
builder.HasIndex(c => c.Name).IsUnique();
|
||||
|
||||
builder.HasOne(c => c.Parent)
|
||||
.WithMany(c => c.Children)
|
||||
.HasForeignKey(c => c.ParentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Property(c => c.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.HasIndex(c => c.ParentId);
|
||||
builder.Property(c => c.CreatedAt).IsRequired();
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasIndex(c => c.Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
builder.Property(i => i.Description).HasMaxLength(1000);
|
||||
builder.Property(i => i.TaxClass).HasMaxLength(20);
|
||||
|
||||
builder.Property(i => i.ItemType)
|
||||
builder.Property(i => i.StockNature)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(i => i.TrackingMode)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
@@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
.HasForeignKey(i => i.CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(i => i.SubCategory)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.SubCategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(i => i.Brand)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.BrandId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(i => i.BaseUom)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.BaseUomId)
|
||||
@@ -49,5 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
||||
|
||||
builder.HasIndex(i => i.Status);
|
||||
builder.HasIndex(i => i.CategoryId);
|
||||
builder.HasIndex(i => i.BrandId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no
|
||||
/// relationships here — nothing references this table (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ItemType> builder)
|
||||
{
|
||||
builder.ToTable("item_types");
|
||||
builder.HasKey(t => t.ItemTypeId);
|
||||
|
||||
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
builder.Property(t => t.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(t => t.CreatedAt).IsRequired();
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(t => t.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasIndex(t => t.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the singleton product-configuration row (FR-MD-11). The check constraint
|
||||
/// is what makes "singleton" a database guarantee rather than a convention.
|
||||
/// </summary>
|
||||
public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<ProductConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProductConfig> builder)
|
||||
{
|
||||
// The column is created as quoted PascalCase ("ConfigId"), so the constraint must
|
||||
// quote it too — an unquoted config_id would fold to a column that does not exist.
|
||||
builder.ToTable("product_config", t =>
|
||||
t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
|
||||
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class SubCategoryConfiguration : IEntityTypeConfiguration<SubCategory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SubCategory> builder)
|
||||
{
|
||||
builder.ToTable("subcategories");
|
||||
builder.HasKey(s => s.SubCategoryId);
|
||||
|
||||
builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(s => s.CreatedAt).IsRequired();
|
||||
|
||||
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||
builder.Property(s => s.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(s => s.Category)
|
||||
.WithMany(c => c.SubCategories)
|
||||
.HasForeignKey(s => s.CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Names need only be unique within their parent category.
|
||||
builder.HasIndex(s => new { s.CategoryId, s.Name }).IsUnique();
|
||||
builder.HasIndex(s => s.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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,6 +23,10 @@ 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,6 +12,13 @@ namespace ERPCore.Infra.Persistence;
|
||||
/// </summary>
|
||||
public static class DataSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Item type names the frontend builder has always assumed exist (they were hardcoded
|
||||
/// while it ran on mock data). Seeded so the dropdown is not empty on a fresh database;
|
||||
/// users add their own (e.g. Material) from the admin screen.
|
||||
/// </summary>
|
||||
private static readonly string[] StandardItemTypes = ["Color", "Size"];
|
||||
|
||||
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
|
||||
[
|
||||
("DMG", "Damage", ReasonContext.Adjustment),
|
||||
@@ -26,6 +33,15 @@ public static class DataSeeder
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||
{
|
||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||
dirty |= await SeedItemTypesAsync(db, ct);
|
||||
dirty |= await SeedProductConfigAsync(db, ct);
|
||||
|
||||
if (dirty) await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.ReasonCodes
|
||||
.Select(r => new { r.Context, r.Code })
|
||||
@@ -37,9 +53,44 @@ public static class DataSeeder
|
||||
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
|
||||
.ToList();
|
||||
|
||||
if (toAdd.Count == 0) return;
|
||||
if (toAdd.Count == 0) return false;
|
||||
|
||||
db.ReasonCodes.AddRange(toAdd);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedItemTypesAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
var have = await db.ItemTypes.Select(t => t.Name).ToListAsync(ct);
|
||||
|
||||
var toAdd = StandardItemTypes
|
||||
.Where(name => !have.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(name => new ItemType { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow })
|
||||
.ToList();
|
||||
|
||||
if (toAdd.Count == 0) return false;
|
||||
|
||||
db.ItemTypes.AddRange(toAdd);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the singleton product-config row exists (FR-MD-11). Migration #2 inserts it,
|
||||
/// so this only fires for a database built some other way — but without it every Item
|
||||
/// write would 404 on the missing config, so it is worth the one query at startup.
|
||||
/// New deployments start with all features on.
|
||||
/// </summary>
|
||||
private static async Task<bool> SeedProductConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.ProductConfig.AnyAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.ProductConfig.Add(new ProductConfig
|
||||
{
|
||||
ConfigId = ProductConfig.SingletonId,
|
||||
SubcategoriesEnabled = true,
|
||||
BrandsEnabled = true,
|
||||
ItemTypesEnabled = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ERPCore.Infra.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core context for the ERP database. The 38 Phase 1 entities and their
|
||||
/// EF Core context for the ERP database. The 42 Phase 1 entities and their
|
||||
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
||||
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
|
||||
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
|
||||
@@ -23,6 +23,10 @@ public class ErpDbContext : DbContext
|
||||
|
||||
// --- Master Data (docs/10 Part C.1) ---
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
|
||||
public DbSet<Brand> Brands => Set<Brand>();
|
||||
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
|
||||
public DbSet<ItemType> ItemTypes => Set<ItemType>();
|
||||
public DbSet<Uom> Uoms => Set<Uom>();
|
||||
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
|
||||
public DbSet<Item> Items => Set<Item>();
|
||||
@@ -30,11 +34,20 @@ 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>();
|
||||
|
||||
+2454
File diff suppressed because it is too large
Load Diff
+442
@@ -0,0 +1,442 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Brand / SubCategory / ItemType masters and the singleton product config,
|
||||
/// and converts CATEGORY from a self-nesting tree into a fixed two-level
|
||||
/// Category → SubCategory hierarchy (docs/10 Part C.1).
|
||||
/// <para>
|
||||
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
|
||||
/// <c>categories.ParentId</c> outright, which would have silently flattened every
|
||||
/// child category into a root and left items pointing at what is now a top-level
|
||||
/// category — losing the parent entirely. The hand-written steps below (marked
|
||||
/// "data migration") move child categories into <c>subcategories</c> and repoint items
|
||||
/// onto the correct (category, subcategory) pair before the column goes away.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// NOTE: the ParentId drop is deliberately deferred to the bottom of this method —
|
||||
// the data migration reads it. Order here is load-bearing.
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "ItemType",
|
||||
table: "items",
|
||||
newName: "StockNature");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BrandId",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SubCategoryId",
|
||||
table: "items",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "categories",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Status",
|
||||
table: "categories",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Active");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "UpdatedAt",
|
||||
table: "categories",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<uint>(
|
||||
name: "xmin",
|
||||
table: "categories",
|
||||
type: "xid",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: 0u);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "brands",
|
||||
columns: table => new
|
||||
{
|
||||
BrandId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_brands", x => x.BrandId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item_types",
|
||||
columns: table => new
|
||||
{
|
||||
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "product_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_product_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_product_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "subcategories",
|
||||
columns: table => new
|
||||
{
|
||||
SubCategoryId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_subcategories", x => x.SubCategoryId);
|
||||
table.ForeignKey(
|
||||
name: "FK_subcategories_categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "categories",
|
||||
principalColumn: "CategoryId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DATA MIGRATION — must run before ParentId is dropped.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Existing categories predate CreatedAt; the added column defaulted them to
|
||||
// 0001-01-01. Stamp them with the migration time instead of a sentinel date.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc';
|
||||
");
|
||||
|
||||
// Carry the old category id alongside each new subcategory so items can be
|
||||
// repointed by join below. Dropped again once the repoint is done.
|
||||
migrationBuilder.Sql(@"
|
||||
ALTER TABLE subcategories ADD COLUMN legacy_category_id integer;
|
||||
");
|
||||
|
||||
// Walk the old tree to its roots. The previous model allowed unlimited nesting,
|
||||
// but the new one is exactly two levels — so a category at any depth below the
|
||||
// root collapses into a subcategory of its ROOT ancestor (a grandchild cannot
|
||||
// become a subcategory of its immediate parent, since that parent is itself
|
||||
// ceasing to be a category).
|
||||
migrationBuilder.Sql(@"
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id
|
||||
FROM categories
|
||||
WHERE ""ParentId"" IS NULL
|
||||
UNION ALL
|
||||
SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id
|
||||
FROM categories c
|
||||
JOIN tree t ON c.""ParentId"" = t.""CategoryId""
|
||||
)
|
||||
INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id)
|
||||
SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId""
|
||||
FROM tree t
|
||||
WHERE t.""ParentId"" IS NOT NULL;
|
||||
");
|
||||
|
||||
// Repoint items: an item that pointed at a child category now carries the root
|
||||
// category plus the subcategory it actually meant.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE items i
|
||||
SET ""SubCategoryId"" = s.""SubCategoryId"",
|
||||
""CategoryId"" = s.""CategoryId""
|
||||
FROM subcategories s
|
||||
WHERE s.legacy_category_id = i.""CategoryId"";
|
||||
");
|
||||
|
||||
// The self-FK must go before the delete, or RESTRICT rejects removing a parent
|
||||
// whose own child row is still present.
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_categories_categories_ParentId",
|
||||
table: "categories");
|
||||
|
||||
// Every non-root category now lives in `subcategories`, and no item references
|
||||
// one any more (repointed above), so the rows can go.
|
||||
migrationBuilder.Sql(@"
|
||||
DELETE FROM categories WHERE ""ParentId"" IS NOT NULL;
|
||||
ALTER TABLE subcategories DROP COLUMN legacy_category_id;
|
||||
");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_categories_ParentId",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ParentId",
|
||||
table: "categories");
|
||||
|
||||
// Seed the singleton config (FR-MD-11) — all features on. Item writes read this
|
||||
// row, so it must exist before the app serves a single request.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"")
|
||||
VALUES (1, TRUE, TRUE, TRUE)
|
||||
ON CONFLICT (""ConfigId"") DO NOTHING;
|
||||
");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_items_BrandId",
|
||||
table: "items",
|
||||
column: "BrandId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_items_SubCategoryId",
|
||||
table: "items",
|
||||
column: "SubCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_categories_Name",
|
||||
table: "categories",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_categories_Status",
|
||||
table: "categories",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_brands_Name",
|
||||
table: "brands",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_brands_Status",
|
||||
table: "brands",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_types_Name",
|
||||
table: "item_types",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_item_types_Status",
|
||||
table: "item_types",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_product_config_UpdatedBy",
|
||||
table: "product_config",
|
||||
column: "UpdatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_subcategories_CategoryId_Name",
|
||||
table: "subcategories",
|
||||
columns: new[] { "CategoryId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_subcategories_Status",
|
||||
table: "subcategories",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_items_brands_BrandId",
|
||||
table: "items",
|
||||
column: "BrandId",
|
||||
principalTable: "brands",
|
||||
principalColumn: "BrandId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_items_subcategories_SubCategoryId",
|
||||
table: "items",
|
||||
column: "SubCategoryId",
|
||||
principalTable: "subcategories",
|
||||
principalColumn: "SubCategoryId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the schema change and puts the subcategory data back where it came from.
|
||||
/// <para>
|
||||
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
|
||||
/// discarded exactly what <see cref="Up"/> preserved. Instead each subcategory is
|
||||
/// restored as a child category and its items are repointed back onto it. This is
|
||||
/// not perfectly lossless: the old tree's depth is gone (a former grandchild comes
|
||||
/// back as a direct child of its root), and Brand data cannot survive a schema that
|
||||
/// has nowhere to put it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_items_brands_BrandId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_items_subcategories_SubCategoryId",
|
||||
table: "items");
|
||||
|
||||
// Restore the parent column + self-FK first so subcategories have somewhere to
|
||||
// land, then move them back before the table is dropped.
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ParentId",
|
||||
table: "categories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DATA MIGRATION (reverse) — must run before `subcategories` is dropped.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer;
|
||||
");
|
||||
|
||||
// Each subcategory becomes a child category again under the same parent.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id)
|
||||
SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId""
|
||||
FROM subcategories s;
|
||||
");
|
||||
|
||||
// Items that carried a subcategory point back at the restored child category.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE items i
|
||||
SET ""CategoryId"" = c.""CategoryId""
|
||||
FROM categories c
|
||||
WHERE c.legacy_subcategory_id = i.""SubCategoryId"";
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
ALTER TABLE categories DROP COLUMN legacy_subcategory_id;
|
||||
");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "brands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "item_types");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "product_config");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "subcategories");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_items_BrandId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_items_SubCategoryId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_categories_Name",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_categories_Status",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BrandId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubCategoryId",
|
||||
table: "items");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Status",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UpdatedAt",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "xmin",
|
||||
table: "categories");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "StockNature",
|
||||
table: "items",
|
||||
newName: "ItemType");
|
||||
|
||||
// ParentId itself was re-added at the top of this method, ahead of the reverse
|
||||
// data migration that populates it.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_categories_ParentId",
|
||||
table: "categories",
|
||||
column: "ParentId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_categories_categories_ParentId",
|
||||
table: "categories",
|
||||
column: "ParentId",
|
||||
principalTable: "categories",
|
||||
principalColumn: "CategoryId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2454
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3001
File diff suppressed because it is too large
Load Diff
+303
@@ -0,0 +1,303 @@
|
||||
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,6 +119,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("bins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
|
||||
{
|
||||
b.Property<int>("BrandId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BrandId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("BrandId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("brands", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
@@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("ParentId")
|
||||
.HasColumnType("integer");
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("categories", (string)null);
|
||||
});
|
||||
@@ -273,6 +334,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Property<int>("BaseUomId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("BrandId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -286,11 +350,6 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("ItemType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -314,6 +373,14 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<string>("StockNature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int?>("SubCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TaxClass")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
@@ -330,6 +397,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
|
||||
b.HasIndex("BaseUomId");
|
||||
|
||||
b.HasIndex("BrandId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("DefaultVendorId");
|
||||
@@ -339,6 +408,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("SubCategoryId");
|
||||
|
||||
b.ToTable("items", (string)null);
|
||||
});
|
||||
|
||||
@@ -374,6 +445,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("item_reorders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b =>
|
||||
{
|
||||
b.Property<int>("ItemTypeId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ItemTypeId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ItemTypeId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("item_types", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b =>
|
||||
{
|
||||
b.Property<int>("JournalId")
|
||||
@@ -411,6 +524,142 @@ 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")
|
||||
@@ -441,6 +690,147 @@ 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")
|
||||
@@ -490,6 +880,48 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.ToTable("po_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("BrandsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("ItemTypesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.Property<bool>("SubcategoriesEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("product_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||
{
|
||||
b.Property<int>("PoId")
|
||||
@@ -787,6 +1219,78 @@ 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")
|
||||
@@ -1239,6 +1743,182 @@ 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")
|
||||
@@ -1310,6 +1990,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -1325,6 +2008,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.HasIndex("AuthUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
@@ -1516,16 +2201,6 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Category", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -1616,6 +2291,11 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Brand", "Brand")
|
||||
.WithMany()
|
||||
.HasForeignKey("BrandId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
@@ -1627,11 +2307,20 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
.HasForeignKey("DefaultVendorId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("SubCategoryId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("BaseUom");
|
||||
|
||||
b.Navigation("Brand");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("DefaultVendor");
|
||||
|
||||
b.Navigation("SubCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
|
||||
@@ -1653,6 +2342,23 @@ 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")
|
||||
@@ -1688,6 +2394,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -1835,6 +2551,25 @@ 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")
|
||||
@@ -2092,6 +2827,28 @@ 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")
|
||||
@@ -2119,6 +2876,16 @@ 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")
|
||||
@@ -2159,7 +2926,7 @@ namespace ERPCore.Infra.Persistence.Migrations
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
b.Navigation("SubCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||
@@ -2174,6 +2941,11 @@ 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");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
@@ -35,6 +37,17 @@ builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
|
||||
builder.Services.AddErpJwtAuth(builder.Configuration);
|
||||
|
||||
// AuthController proxy → AuthHex (docs/11 §2.0)
|
||||
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
|
||||
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||
c.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
||||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||||
|
||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -49,9 +62,16 @@ 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>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IStockMutator _mutator;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
|
||||
public AdjustmentService(
|
||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
|
||||
ICurrentUser currentUser, IUnitOfWork uow)
|
||||
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_ledger = ledger;
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _adjustments.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
|
||||
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(a => a.AdjustmentId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(a => new AdjustmentSummaryDto(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
|
||||
a.CreatedBy, a.CreatedAt, a.Lines.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default)
|
||||
{
|
||||
var adjustment = await _adjustments.Query().AsNoTracking()
|
||||
.Include(a => a.Lines)
|
||||
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
|
||||
if (adjustment is null) return null;
|
||||
|
||||
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
|
||||
// so the refs this adjustment posted are recovered by source-doc lookup.
|
||||
var ledgerRefs = await _ledger.Query().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
|
||||
.OrderBy(l => l.LedgerId)
|
||||
.Select(l => l.LedgerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return ToDto(adjustment, ledgerRefs);
|
||||
}
|
||||
|
||||
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request.ReasonCodeId is null)
|
||||
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
return (entity, refs);
|
||||
}, ct);
|
||||
|
||||
return new AdjustmentDto(
|
||||
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
|
||||
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
|
||||
adjustment.Lines.OrderBy(l => l.AdjLineId)
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||
}
|
||||
|
||||
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||
a.Lines.OrderBy(l => l.AdjLineId)
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||
ledgerRefs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthAltService"/>
|
||||
public sealed class AuthAltService : IAuthAltService
|
||||
{
|
||||
private readonly IAuthHexClient _authHex;
|
||||
|
||||
public AuthAltService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default)
|
||||
=> _authHex.IsAvailableAsync(request, ct);
|
||||
|
||||
public Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct = default)
|
||||
=> _authHex.SendOtpAsync(request, ct);
|
||||
|
||||
public async Task<OtpAuthSessionResult> VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.VerifyAltOtpAsync(request, ct);
|
||||
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
|
||||
|
||||
return new OtpAuthSessionResult
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
Body = new OtpLoginVerifiedResponse
|
||||
{
|
||||
ReferenceNumber = result.ReferenceNumber,
|
||||
UserId = result.UserId,
|
||||
Verified = result.Verified ?? true,
|
||||
User = result.User,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthRecoveryService"/>
|
||||
public sealed class AuthRecoveryService : IAuthRecoveryService
|
||||
{
|
||||
private readonly IAuthHexClient _authHex;
|
||||
|
||||
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ForgotPasswordAsync(request, ct);
|
||||
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
|
||||
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordAsync(request, ct);
|
||||
|
||||
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
|
||||
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Carries a freshly issued AuthHex session from a Services/Auth method back to
|
||||
/// AuthController. Never serialized directly — the controller pulls
|
||||
/// AccessToken/RefreshToken into httpOnly cookies (see AuthCookieWriter) and
|
||||
/// returns only <see cref="Body"/> in the response.
|
||||
/// </summary>
|
||||
public sealed class AuthSessionResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required AuthSessionResponse Body { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Same purpose as <see cref="AuthSessionResult"/>, for the two OTP-verify
|
||||
/// flows whose body also carries ReferenceNumber/Verified alongside the user/session.</summary>
|
||||
public sealed class OtpAuthSessionResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required OtpLoginVerifiedResponse Body { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services.Auth;
|
||||
|
||||
/// <inheritdoc cref="IAuthUserService"/>
|
||||
public sealed class AuthUserService : IAuthUserService
|
||||
{
|
||||
private readonly IAuthHexClient _authHex;
|
||||
|
||||
public AuthUserService(IAuthHexClient authHex) => _authHex = authHex;
|
||||
|
||||
public async Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
request.UserId ??= Guid.NewGuid();
|
||||
var result = await _authHex.RegisterAsync(request, ct);
|
||||
return ToSessionResult(result);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResult> LoginAsync(LoginRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.LoginAsync(request, ct);
|
||||
return ToSessionResult(result);
|
||||
}
|
||||
|
||||
public async Task<OtpAuthSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.VerifyOtpForLoginAsync(request, ct);
|
||||
return ToOtpSessionResult(result);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResult> RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct);
|
||||
return ToSessionResult(result);
|
||||
}
|
||||
|
||||
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default)
|
||||
=> _authHex.GetUserDetailsAsync(userId, ct);
|
||||
|
||||
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.GetUserSessionsAsync(bearerToken, ct);
|
||||
|
||||
public Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.ChangeUserStatusAsync(request.IsActive, bearerToken, ct);
|
||||
|
||||
public Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.LockUserAccountAsync(request.IsLocked, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.ChangeUserPasswordAsync(request, bearerToken, ct);
|
||||
|
||||
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);
|
||||
|
||||
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.InitiateTwoFaSetupAsync(bearerToken, ct);
|
||||
|
||||
public Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.CompleteTwoFaSetupAsync(request, bearerToken, ct);
|
||||
|
||||
public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.VerifyTwoFaAsync(request, bearerToken, ct);
|
||||
|
||||
public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.DisableTwoFaAsync(request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default)
|
||||
=> _authHex.GetTwoFaStatusAsync(bearerToken, ct);
|
||||
|
||||
private static AuthSessionResult ToSessionResult(AuthHexSessionResult? result)
|
||||
{
|
||||
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
|
||||
|
||||
return new AuthSessionResult
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
Body = new AuthSessionResponse { User = result.User, ExpiresIn = result.ExpiresIn }
|
||||
};
|
||||
}
|
||||
|
||||
private static OtpAuthSessionResult ToOtpSessionResult(AuthHexSessionResult? result)
|
||||
{
|
||||
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
|
||||
|
||||
return new OtpAuthSessionResult
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
Body = new OtpLoginVerifiedResponse
|
||||
{
|
||||
ReferenceNumber = result.ReferenceNumber,
|
||||
UserId = result.UserId,
|
||||
Verified = result.Verified ?? true,
|
||||
User = result.User,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency
|
||||
/// per docs/11-BACKEND-PHASE1.md §2.6.
|
||||
/// </summary>
|
||||
public sealed class BrandService : IBrandService
|
||||
{
|
||||
private readonly IRepository<Brand> _brands;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BrandService(IRepository<Brand> brands, IUnitOfWork uow)
|
||||
{
|
||||
_brands = brands;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _brands.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(b => b.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(b => b.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<BrandDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
|
||||
return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
var brand = new Brand
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _brands.AddAsync(brand, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BrandDto>> UpdateAsync(
|
||||
int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||
|
||||
if (brand.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
|
||||
&& await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct))
|
||||
throw new ConflictException($"A brand named '{name}' already exists.");
|
||||
|
||||
brand.Name = name;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
|
||||
}
|
||||
|
||||
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var brand = await _brands.GetByIdAsync(brandId, ct)
|
||||
?? throw new NotFoundException($"Brand {brandId} was not found.");
|
||||
|
||||
brand.Status = status;
|
||||
brand.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels:
|
||||
/// categories no longer self-nest, so there is no cycle to detect and no tree to build
|
||||
/// (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// </summary>
|
||||
public sealed class CategoryService : ICategoryService
|
||||
{
|
||||
private readonly IRepository<Category> _categories;
|
||||
private readonly IRepository<SubCategory> _subCategories;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
|
||||
public CategoryService(
|
||||
IRepository<Category> categories,
|
||||
IRepository<SubCategory> subCategories,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_categories = categories;
|
||||
_subCategories = subCategories;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
// Categories ---------------------------------------------------------------
|
||||
|
||||
public async Task<PagedResponse<CategoryDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
var q = _categories.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(c => c.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
|
||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
|
||||
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
|
||||
{
|
||||
var all = await _categories.Query().AsNoTracking()
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var byParent = all.ToLookup(c => c.ParentId);
|
||||
|
||||
List<CategoryTreeDto> Build(int? parentId) =>
|
||||
byParent[parentId]
|
||||
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
|
||||
.ToList();
|
||||
|
||||
return Build(null);
|
||||
var category = await _categories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
|
||||
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
||||
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request.ParentId is not null
|
||||
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
|
||||
var name = request.Name.Trim();
|
||||
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
|
||||
await _categories.AddAsync(category, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
|
||||
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<CategoryDto>> UpdateAsync(
|
||||
int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
if (category.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(category.Name, name, StringComparison.Ordinal)
|
||||
&& await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct))
|
||||
throw new ConflictException($"A category named '{name}' already exists.");
|
||||
|
||||
category.Name = name;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync("category", ct);
|
||||
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.GetByIdAsync(categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
category.Status = status;
|
||||
category.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Subcategories ------------------------------------------------------------
|
||||
|
||||
public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(s => s.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(s => s.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(s => new SubCategoryDto(
|
||||
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _subCategories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
|
||||
return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(
|
||||
int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _categories.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct)
|
||||
?? throw new NotFoundException($"Category {categoryId} was not found.");
|
||||
|
||||
if (category.Status != EntityStatus.Active)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (await _subCategories.Query().AnyAsync(
|
||||
s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
|
||||
|
||||
var sub = new SubCategory
|
||||
{
|
||||
CategoryId = categoryId,
|
||||
Name = name,
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _subCategories.AddAsync(sub, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(
|
||||
int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||
|
||||
if (sub.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
|
||||
&& await _subCategories.Query().AnyAsync(
|
||||
s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct))
|
||||
throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}.");
|
||||
|
||||
// Name only — reparenting is not offered, since it would silently invalidate the
|
||||
// category of every item pointing at this subcategory.
|
||||
sub.Name = name;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await SaveGuardingConcurrencyAsync("subcategory", ct);
|
||||
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
|
||||
}
|
||||
|
||||
public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
|
||||
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
|
||||
|
||||
sub.Status = status;
|
||||
sub.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, $"The {label} was modified by another request.", 412);
|
||||
}
|
||||
}
|
||||
|
||||
private static CategoryDto Map(Category c) => new(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt);
|
||||
|
||||
private static SubCategoryDto MapSub(SubCategory s) => new(
|
||||
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _counts.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(c => c.CountId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(c => new CountSummaryDto(
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
|
||||
c.CreatedBy, c.CreatedAt, c.Lines.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
|
||||
@@ -175,7 +200,7 @@ public sealed class CountService : ICountService
|
||||
}
|
||||
|
||||
private static CountDto Map(StockCount c) => new(
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
|
||||
c.Lines.OrderBy(l => l.CountLineId)
|
||||
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _grns.Query().AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(g => g.Status == status);
|
||||
if (poId is not null) q = q.Where(g => g.PoId == poId);
|
||||
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
|
||||
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(g => g.GrnId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
|
||||
public interface IAdjustmentService
|
||||
{
|
||||
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
|
||||
|
||||
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
|
||||
|
||||
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Services.Auth;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex.</summary>
|
||||
public interface IAuthAltService
|
||||
{
|
||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default);
|
||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct = default);
|
||||
Task<OtpAuthSessionResult> VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex.</summary>
|
||||
public interface IAuthRecoveryService
|
||||
{
|
||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default);
|
||||
Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default);
|
||||
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default);
|
||||
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Services.Auth;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex.</summary>
|
||||
public interface IAuthUserService
|
||||
{
|
||||
Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default);
|
||||
Task<AuthSessionResult> LoginAsync(LoginRequest request, CancellationToken ct = default);
|
||||
Task<OtpAuthSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default);
|
||||
Task<AuthSessionResult> RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default);
|
||||
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default);
|
||||
Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default);
|
||||
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default);
|
||||
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default);
|
||||
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
public interface IBrandService
|
||||
{
|
||||
Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default);
|
||||
Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<BrandDto>> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Category master business logic (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
||||
/// <summary>
|
||||
/// Category + subcategory master business logic (docs/11-BACKEND-PHASE1.md §2.3).
|
||||
/// The hierarchy is exactly two levels deep; there is no tree endpoint any more.
|
||||
/// </summary>
|
||||
public interface ICategoryService
|
||||
{
|
||||
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default);
|
||||
Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
||||
Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default);
|
||||
Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CategoryDto>> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Subcategories of one category. 404s when the category itself does not exist.</summary>
|
||||
Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
|
||||
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default);
|
||||
Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
|
||||
public interface ICountService
|
||||
{
|
||||
Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
|
||||
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
||||
Task<CountDto> EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
@@ -5,6 +7,9 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
|
||||
public interface IGrnService
|
||||
{
|
||||
Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface IItemService
|
||||
{
|
||||
Task<PagedResponse<ItemListItemDto>> ListAsync(
|
||||
PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default);
|
||||
PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId,
|
||||
TrackingMode? trackingMode, CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<ItemDetailDto>?> GetAsync(int itemId, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Item type master business logic (docs/11-BACKEND-PHASE1.md §2.7). Plain CRUD over an
|
||||
/// unlinked list — no item ever references an item type (docs/10 Part C.9), so there is
|
||||
/// nothing here beyond maintaining the names the builder's dropdown reads.
|
||||
/// </summary>
|
||||
public interface IItemTypeService
|
||||
{
|
||||
Task<PagedResponse<ItemTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<ETagged<ItemTypeDto>?> GetAsync(int itemTypeId, CancellationToken ct = default);
|
||||
Task<ETagged<ItemTypeDto>> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<ItemTypeDto>> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user