diff --git a/.gitignore b/.gitignore index e4f2245..6b1868c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs new file mode 100644 index 0000000..0b7dd25 --- /dev/null +++ b/Backend/ERPCore/Controllers/AuthController.cs @@ -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; + +/// +/// 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 +/// — response bodies never carry raw tokens. Does not inherit +/// : most actions here are pre-session and need +/// , and the ETag/If-Match handling that +/// base provides doesn't apply to auth flows. +/// +[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; + } + + /// + /// 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. + /// + [HttpGet("me")] + [ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)] + public async Task> 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> 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> 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> 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> 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> GetUserDetails(Guid userId, CancellationToken ct) + => Ok(await _users.GetUserDetailsAsync(userId, ct)); + + [HttpGet("sessions")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetSessions(CancellationToken ct) + => Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct)); + + [HttpPost("status")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct) + { + await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpPost("lock")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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 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> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct) + => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct)); + + /// + /// Ends the session: revokes it upstream where possible, and always clears our cookies. + /// + /// userId is optional because callers usually cannot supply it — AuthHex returns + /// user.userId: null in its own login/register response, so a browser has no id + /// to send. It is resolved from the session token's UserId claim instead. + /// + /// + /// 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). + /// + /// + [HttpPost("logout")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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(); + } + + /// AuthHex's identity claim, present when the request carried a valid session. + 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> 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> InitiateTwoFa(CancellationToken ct) + => Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct)); + + [HttpPost("2fa/complete")] + [ValidateCsrf] + [ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)] + public async Task> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct) + => Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct)); + + [HttpPost("2fa/verify")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct) + { + await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct); + return NoContent(); + } + + [HttpPost("2fa/disable")] + [ValidateCsrf] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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> GetTwoFaStatus(CancellationToken ct) + => Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct)); + + // ---- Recovery ----------------------------------------------------------- + + [HttpPost("recovery/forgot-password")] + [AllowAnonymous] + [ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)] + public async Task> 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> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct) + => Ok(await _recovery.VerifyOtpAsync(request, ct)); + + [HttpPost("recovery/reset-password")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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 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> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct) + => Ok(await _alt.IsAvailableAsync(request, ct)); + + [HttpPost("otp/send")] + [AllowAnonymous] + [ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)] + public async Task> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct) + => Ok(await _alt.SendOtpAsync(request, ct)); + + [HttpPost("otp/verify")] + [AllowAnonymous] + [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)] + public async Task> 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 -------------------------------------------------------------- + + /// The token that authenticated this request — Bearer header if present, else the session cookie. + 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); + } +} diff --git a/Backend/ERPCore/Controllers/BrandsController.cs b/Backend/ERPCore/Controllers/BrandsController.cs new file mode 100644 index 0000000..7d4c5b3 --- /dev/null +++ b/Backend/ERPCore/Controllers/BrandsController.cs @@ -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; + +/// Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6). +[Route("api/v1/brands")] +public sealed class BrandsController : ApiControllerBase +{ + private readonly IBrandService _brands; + + public BrandsController(IBrandService brands) => _brands = brands; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> 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> 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); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{brandId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct) + { + await _brands.SetStatusAsync(brandId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/CategoriesController.cs b/Backend/ERPCore/Controllers/CategoriesController.cs index dab7c60..3fcd0e2 100644 --- a/Backend/ERPCore/Controllers/CategoriesController.cs +++ b/Backend/ERPCore/Controllers/CategoriesController.cs @@ -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; -/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3). +/// +/// 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 +/// ?tree=true parameter is gone along with the self-nesting model. +/// [Route("api/v1/categories")] public sealed class CategoriesController : ApiControllerBase { @@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase public CategoriesController(ICategoryService categories) => _categories = categories; - /// Flat paged list, or a nested tree when tree=true. [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - public async Task List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct) - => tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct)); + public async Task>> 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> 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> 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> 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); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{categoryId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> 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> 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); } } diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs index 20ba722..3ad1f5a 100644 --- a/Backend/ERPCore/Controllers/GrnsController.cs +++ b/Backend/ERPCore/Controllers/GrnsController.cs @@ -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; + /// List GRNs, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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)] diff --git a/Backend/ERPCore/Controllers/ItemTypesController.cs b/Backend/ERPCore/Controllers/ItemTypesController.cs new file mode 100644 index 0000000..efbc4f7 --- /dev/null +++ b/Backend/ERPCore/Controllers/ItemTypesController.cs @@ -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; + +/// +/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material +/// dimension names. GET 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). +/// +[Route("api/v1/item-types")] +public sealed class ItemTypesController : ApiControllerBase +{ + private readonly IItemTypeService _itemTypes; + + public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes; + + /// Feeds the frontend item-builder dropdown; filter status=Active for selectable rows. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> 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> 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); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{itemTypeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct) + { + await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs index c98493a..fe4ed11 100644 --- a/Backend/ERPCore/Controllers/ItemsController.cs +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -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)); /// Get a single item; returns an ETag for optimistic concurrency. [HttpGet("{itemId:int}")] diff --git a/Backend/ERPCore/Controllers/NavController.cs b/Backend/ERPCore/Controllers/NavController.cs new file mode 100644 index 0000000..4ebcef8 --- /dev/null +++ b/Backend/ERPCore/Controllers/NavController.cs @@ -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; + +/// +/// 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. +/// +[Route("api/v1/nav")] +public sealed class NavController : ApiControllerBase +{ + private readonly IRepository _navItems; + + public NavController(IRepository navItems) => _navItems = navItems; + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> 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); + } +} diff --git a/Backend/ERPCore/Controllers/ProductConfigController.cs b/Backend/ERPCore/Controllers/ProductConfigController.cs new file mode 100644 index 0000000..300f446 --- /dev/null +++ b/Backend/ERPCore/Controllers/ProductConfigController.cs @@ -0,0 +1,46 @@ +using ERPCore.Dtos.Config; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton +/// feature gate for subcategories/brands/item-types. +/// +/// Authorization: writes are admitted by the inherited ERP door policy only. +/// A dedicated CONFIG_MANAGE 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. +/// +/// +[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> 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> Update( + [FromBody] UpdateProductConfigRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _config.UpdateAsync(request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } +} diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs index a1bf9b9..ff9dc71 100644 --- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs +++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs @@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase return Ok(result.Value); } + /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. + [HttpPost("{poId:int}/submit")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Submit(int poId, CancellationToken ct) + => Ok(await _pos.SubmitAsync(poId, ct)); + + /// Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise). + [HttpDelete("{poId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int poId, CancellationToken ct) + { + await _pos.DeleteAsync(poId, ct); + return NoContent(); + } + /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled. [HttpPost("{poId:int}/approve")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs index 9f59a56..40ce9a2 100644 --- a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs +++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs @@ -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; + /// List posted returns, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct) + => Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct)); + + /// Get one return with its lines and the ledger entries it posted. + [HttpGet("{returnId:int}")] + [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int returnId, CancellationToken ct) + { + var dto = await _returns.GetAsync(returnId, ct); + return dto is null ? NotFound() : Ok(dto); + } + /// Create + auto-post a return (outbound movement). 409 if return exceeds available stock. [HttpPost] [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)] diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs index ab849b9..e16d2e1 100644 --- a/Backend/ERPCore/Controllers/RequisitionsController.cs +++ b/Backend/ERPCore/Controllers/RequisitionsController.cs @@ -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), StatusCodes.Status200OK)] - public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) - => Ok(await _requisitions.ListAsync(query, ct)); + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct) + => Ok(await _requisitions.ListAsync(query, status, ct)); [HttpGet("{requisitionId:int}")] [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs index 7cc0e1f..c917479 100644 --- a/Backend/ERPCore/Controllers/RfqsController.cs +++ b/Backend/ERPCore/Controllers/RfqsController.cs @@ -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; + /// List RFQs, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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)] diff --git a/Backend/ERPCore/Controllers/RolesController.cs b/Backend/ERPCore/Controllers/RolesController.cs new file mode 100644 index 0000000..69db895 --- /dev/null +++ b/Backend/ERPCore/Controllers/RolesController.cs @@ -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; + +/// Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9). +[Route("api/v1/roles")] +public sealed class RolesController : ApiControllerBase +{ + private readonly IRoleService _roles; + + public RolesController(IRoleService roles) => _roles = roles; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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> 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> 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> 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 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 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> 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> AssignPermissions( + int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct) + => Ok(await _roles.AssignPermissionsAsync(roleId, request, ct)); +} diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs index 7952ba6..51b2544 100644 --- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs +++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs @@ -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; + /// List posted adjustments, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct) + => Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct)); + + /// Get one adjustment with its lines and the ledger entries it posted. + [HttpGet("{adjustmentId:int}")] + [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int adjustmentId, CancellationToken ct) + { + var dto = await _adjustments.GetAsync(adjustmentId, ct); + return dto is null ? NotFound() : Ok(dto); + } + /// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes). [HttpPost] [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)] diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs index 652942a..3189268 100644 --- a/Backend/ERPCore/Controllers/StockController.cs +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) => Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct)); + /// On-hand across every stocked (item, warehouse) pair; both filters optional. + [HttpGet("on-hand/list")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> OnHandList( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct)); + + /// + /// Immutable movement history. sourceDocType/sourceDocId 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). + /// [HttpGet("ledger")] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> 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)] diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs index 008ca79..aa8b477 100644 --- a/Backend/ERPCore/Controllers/StockCountsController.cs +++ b/Backend/ERPCore/Controllers/StockCountsController.cs @@ -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; + /// List counts, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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)] diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs index fd5f52b..49d1a3f 100644 --- a/Backend/ERPCore/Controllers/StockTransfersController.cs +++ b/Backend/ERPCore/Controllers/StockTransfersController.cs @@ -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; + /// List transfers, newest first. + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> 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)] diff --git a/Backend/ERPCore/Controllers/SubCategoriesController.cs b/Backend/ERPCore/Controllers/SubCategoriesController.cs new file mode 100644 index 0000000..162a89e --- /dev/null +++ b/Backend/ERPCore/Controllers/SubCategoriesController.cs @@ -0,0 +1,56 @@ +using ERPCore.Dtos.Categories; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3). +/// Listing and creation live under the parent category on , +/// since a subcategory only exists in the context of one. +/// +[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> 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); + } + + /// Renames a subcategory. It cannot be moved to another category — see the request DTO. + [HttpPut("{subCategoryId:int}")] + [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> 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); + } + + /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08). + [HttpPatch("{subCategoryId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus( + int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct) + { + await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs new file mode 100644 index 0000000..60e2fe3 --- /dev/null +++ b/Backend/ERPCore/Controllers/UsersController.cs @@ -0,0 +1,53 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Users; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// User management: local shadow `User` list/detail + role assignment, and +/// account creation orchestrated against AuthHex (see ). +/// +[Route("api/v1/users")] +public sealed class UsersController : ApiControllerBase +{ + private readonly IUserManagementService _users; + + public UsersController(IUserManagementService users) => _users = users; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _users.ListAsync(query, ct)); + + /// AuthHex UserType options for the create-user form's select. + [HttpGet("user-types")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListUserTypes(CancellationToken ct) + => Ok(await _users.ListUserTypesAsync(ct)); + + [HttpGet("{userId:int}")] + [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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> 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> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct) + => Ok(await _users.UpdateRoleAsync(userId, request, ct)); +} diff --git a/Backend/ERPCore/Domain/Entities/Brand.cs b/Backend/ERPCore/Domain/Entities/Brand.cs new file mode 100644 index 0000000..1544349 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Brand.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Brand master (FR-MD-09). Referenced optionally by . +/// Mutable aggregate with a ETag token. Deactivated, not +/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs index 6c34b87..7d04a6d 100644 --- a/Backend/ERPCore/Domain/Entities/Category.cs +++ b/Backend/ERPCore/Domain/Entities/Category.cs @@ -1,15 +1,25 @@ +using ERPCore.Domain.Enums; + namespace ERPCore.Domain.Entities; /// -/// Hierarchical item category (FR-MD-04). A null 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 ; categories no longer self-nest (the former +/// parent_id tree was replaced in migration #2). +/// Mutable aggregate with a ETag token. Deactivated, not +/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1. /// 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 Children { get; set; } = new List(); + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } + + public ICollection SubCategories { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index 5805127..f6981d4 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -3,9 +3,13 @@ using ERPCore.Domain.Enums; namespace ERPCore.Domain.Entities; /// -/// GRN line (FR-GRN-04..08). is the PO-derived cost for -/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost -/// for direct receipts. = qty × unitCost. +/// GRN line (FR-GRN-04..08). is the gross cost received at: +/// entered on the line, defaulting to the PO price when omitted (a per-receipt price +/// override is now permitted — see docs/02-SECURITY C.3, revised). +/// snapshots the PO price at receipt so the variance survives later PO edits. +/// = unitCost after trade discount — this is what the FIFO layer +/// costs at (VAT never enters stock value; it is recoverable input tax). +/// = qty × netUnitCost (after discount, before VAT). /// gates issuability. Model: docs/10 Part C.3. /// public class GrnLine @@ -31,7 +35,30 @@ public class GrnLine public Batch? Batch { get; set; } public decimal Qty { get; set; } + + /// Gross unit cost received at (entered, or PO price when omitted). public decimal UnitCost { get; set; } + + /// Snapshot of the PO line price at receipt; null for direct receipts. + public decimal? PoUnitPrice { get; set; } + + /// Trade discount percentage (0–100), entered. + public decimal DiscountPct { get; set; } + + /// UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost. + public decimal NetUnitCost { get; set; } + + /// VAT percentage (0–100), entered. Recoverable — does not affect stock value. + public decimal VatPct { get; set; } + + /// Qty × NetUnitCost × VatPct/100. + public decimal VatAmount { get; set; } + + /// Qty × NetUnitCost (after discount, before VAT). public decimal ReceivedValue { get; set; } + + /// Qty × NetUnitCost + VatAmount — payable to the vendor. + public decimal LineTotal { get; set; } + public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; } diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index f308dd3..ba9ed6c 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -17,13 +17,20 @@ public class Item public int CategoryId { get; set; } public Category? Category { get; set; } + /// Optional second level below ; must belong to it. + 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; diff --git a/Backend/ERPCore/Domain/Entities/ItemType.cs b/Backend/ERPCore/Domain/Entities/ItemType.cs new file mode 100644 index 0000000..668b1b7 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ItemType.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or +/// Material. +/// +/// Deliberately unlinked. Nothing references this entity and it references +/// nothing: there is no value table and no join to . Its only job is +/// to feed the frontend's item-builder dropdown via GET /item-types. The chosen +/// values (Red, S, M) are encoded by the client into the generated SKU +/// (e.g. BL-100-0003) 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. +/// +/// Not to be confused with (Stocked/NonStocked/Service), +/// which is what the old ItemType enum became. +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/NavItem.cs b/Backend/ERPCore/Domain/Entities/NavItem.cs new file mode 100644 index 0000000..7fca8c0 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/NavItem.cs @@ -0,0 +1,22 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// 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 /, +/// not by editing these rows through the UI. +/// +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 Children { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/Permission.cs b/Backend/ERPCore/Domain/Entities/Permission.cs new file mode 100644 index 0000000..f1d0cac --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Permission.cs @@ -0,0 +1,18 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A grantable sidebar-visibility unit — exactly one of / +/// is set (enforced in NavSeedService/service layer, +/// not by a DB constraint). One row is seeded per /; +/// grants it to a role. +/// +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; } +} diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs index 1f38169..0a5f087 100644 --- a/Backend/ERPCore/Domain/Entities/PoLine.cs +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -22,7 +22,7 @@ public class PoLine public Warehouse? Warehouse { get; set; } public decimal Qty { get; set; } - public decimal UnitPrice { get; set; } + public decimal UnitPrice { get; set; }// public decimal Tax { get; set; } public decimal QtyReceived { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs new file mode 100644 index 0000000..4120cdc --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Product configuration (FR-MD-11) — a singleton row (single-tenant, docs/00-CORE §1) +/// gating optional product master-data features. +/// +/// and are enforced +/// server-side: an Item write carrying a subcategory/brand while the flag is off is +/// rejected with CONFIG_DISABLED. is +/// advisory only — items carry no item-type reference (see ), +/// 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. +/// +/// Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +public class ProductConfig +{ + /// Always 1 — the singleton row's id. + 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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Role.cs b/Backend/ERPCore/Domain/Entities/Role.cs new file mode 100644 index 0000000..8ae9f43 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Role.cs @@ -0,0 +1,27 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Local shadow/projection of an AuthHex Role row, mirroring the same +/// pattern uses for AuthHex identities: +/// maps to AuthHex's Guid RoleId, while the local (int) +/// is what // +/// FKs reference. AuthHex remains the source of truth; writes are forwarded there +/// first (IAuthHexClient) and mirrored here on success. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/RolePermission.cs b/Backend/ERPCore/Domain/Entities/RolePermission.cs new file mode 100644 index 0000000..75734d4 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/RolePermission.cs @@ -0,0 +1,11 @@ +namespace ERPCore.Domain.Entities; + +/// Join row granting a visibility of a (nav node). +public class RolePermission +{ + public int RoleId { get; set; } + public int PermissionId { get; set; } + + public Role? Role { get; set; } + public Permission? Permission { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SubCategory.cs b/Backend/ERPCore/Domain/Entities/SubCategory.cs new file mode 100644 index 0000000..a270f41 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SubCategory.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Subcategory — the single optional level below (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 +/// . Model: docs/10-BACKEND-PHASE1.md Part C.1. +/// +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; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SubNavItem.cs b/Backend/ERPCore/Domain/Entities/SubNavItem.cs new file mode 100644 index 0000000..a40b58c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SubNavItem.cs @@ -0,0 +1,18 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// A child sidebar entry under a (e.g. Products' children). +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; } +} diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs index ddd0048..45ad625 100644 --- a/Backend/ERPCore/Domain/Entities/User.cs +++ b/Backend/ERPCore/Domain/Entities/User.cs @@ -22,4 +22,8 @@ public class User /// AuthHex identity (token UserId GUID); null for the seeded system user. public Guid? AuthUserId { get; set; } + + /// Local shadow assignment; null until an admin assigns one. + public int? RoleId { get; set; } + public Role? Role { get; set; } } diff --git a/Backend/ERPCore/Domain/Enums/ItemType.cs b/Backend/ERPCore/Domain/Enums/ItemType.cs deleted file mode 100644 index c81a1d6..0000000 --- a/Backend/ERPCore/Domain/Enums/ItemType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ERPCore.Domain.Enums; - -/// -/// Item classification (FR-MD-01). Values match the itemType enum in -/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. -/// -public enum ItemType -{ - Stocked, - NonStocked, - Service -} diff --git a/Backend/ERPCore/Domain/Enums/StockNature.cs b/Backend/ERPCore/Domain/Enums/StockNature.cs new file mode 100644 index 0000000..7e75517 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/StockNature.cs @@ -0,0 +1,14 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Whether an item holds stock (FR-MD-01). Values match the stockNature enum in +/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database. +/// Renamed from ItemType so that name could be taken by the ItemType master +/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9). +/// +public enum StockNature +{ + Stocked, + NonStocked, + Service +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs new file mode 100644 index 0000000..26df8d5 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs @@ -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; } + /// Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog. + 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; } +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs new file mode 100644 index 0000000..7aa7ba8 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs @@ -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; +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs new file mode 100644 index 0000000..6222a6b --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Auth; + +/// AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section). +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; } +} diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs new file mode 100644 index 0000000..a8ca585 --- /dev/null +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -0,0 +1,191 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace ERPCore.Dtos.Auth; + +/// Shared AuthHex user projection (API_REFERENCE.md §3). Field set is +/// AuthHex's best-documented subset; unknown fields are ignored on deserialize. +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; } +} + +/// Body returned by every session-issuing endpoint. Tokens never appear +/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2). +public sealed class AuthSessionResponse +{ + public UserSummaryDto? User { get; set; } + public int ExpiresIn { get; set; } +} + +public sealed class RegisterRequest +{ + /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted. + 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; } + /// Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog. + public JsonElement? Role { get; set; } + public JsonElement? UserType { get; set; } +} + +/// AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes). +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; } +} + +/// Passthrough — TOTP secret/QR payload shape is only loosely documented +/// ("secret key, QR/otpauth URL ... from the third-party service"). +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 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 +{ + /// + /// Optional: AuthHex returns no userId on login, so browsers cannot supply one. + /// When omitted, the controller resolves it from the session token's UserId claim. + /// + public Guid? UserId { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Brands/BrandDtos.cs b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs new file mode 100644 index 0000000..b5f6c80 --- /dev/null +++ b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Brands; + +/// Brand resource (docs/11-BACKEND-PHASE1.md §2.6). +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; } +} diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs index 744026e..ed27120 100644 --- a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs +++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs @@ -1,15 +1,56 @@ using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Categories; -/// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3). -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. -/// Nested category node for GET /categories?tree=true. -public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList Children); +/// Category resource — the top level. +public sealed record CategoryDto( + int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +/// Subcategory resource — the single optional level below a category. +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; } +} + +/// Body for POST /categories/{categoryId}/subcategories; the parent comes from the route. +public sealed class CreateSubCategoryRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +/// +/// Body for PUT /subcategories/{id}. Name only — a subcategory cannot be reparented, +/// since moving one would silently invalidate the category of every item referencing it. +/// +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; } } diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs new file mode 100644 index 0000000..f4e2e71 --- /dev/null +++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Config; + +/// +/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton. +/// is advisory (frontend-honoured) — see the entity docs. +/// +public sealed record ProductConfigDto( + bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled, + DateTime? UpdatedAt, int? UpdatedBy); + +/// +/// Full replacement of the flags. UpdatedBy is derived from the token, never posted. +/// +/// The flags are ? deliberately: [Required] on a non-nullable bool +/// is a no-op (it always has a value), so a body of {} would bind every flag to +/// false and silently switch all three features off. Nullable makes the requirement +/// actually bind — an omitted flag is a 400, not an accidental disable. +/// +/// +public sealed class UpdateProductConfigRequest +{ + [Required] public bool? SubcategoriesEnabled { get; set; } + [Required] public bool? BrandsEnabled { get; set; } + [Required] public bool? ItemTypesEnabled { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index bdcff2b..aec88c2 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -7,12 +7,20 @@ namespace ERPCore.Dtos.Grn; public sealed record GrnLineDto( int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId, - decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId); + decimal Qty, decimal UnitCost, decimal? PoUnitPrice, + decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, + decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, + HoldStatus HoldStatus, int? BatchId); public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines); +/// Row shape for GET /grns — line count instead of the lines themselves. +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); @@ -39,8 +47,16 @@ public sealed class CreateGrnLineInput [Required] public int UomId { get; set; } public int? BinId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } - /// Used only for direct (no-PO) receipts; ignored when is set. + /// + /// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional + /// per-receipt price override — when 0/omitted the PO line price is used; when supplied it + /// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised). + /// [Range(0, double.MaxValue)] public decimal UnitCost { get; set; } + /// Trade discount percentage (0–100). Reduces the inventory cost. + [Range(0, 100)] public decimal DiscountPct { get; set; } + /// VAT percentage (0–100). Recoverable — does not affect stock value. + [Range(0, 100)] public decimal VatPct { get; set; } [EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; public BatchInput? Batch { get; set; } } diff --git a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs new file mode 100644 index 0000000..35d2e92 --- /dev/null +++ b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.ItemTypes; + +/// +/// 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: GET /item-types 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). +/// +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; } +} diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index 7bb11e7..adddcfa 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -7,18 +7,28 @@ namespace ERPCore.Dtos.Items; /// Row shape for GET /items. 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); /// A single per-warehouse reorder policy row. public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty); -/// Full item resource for GET /items/{id} and create/update responses. +/// +/// Full item resource for GET /items/{id} and create/update responses. +/// +/// is embedded because they are otherwise unreadable: they can +/// only be written via PUT /items/{id}/uom-conversions, which returns them, but no +/// endpoint reads them back — so a detail screen could never show current state before +/// editing. Mirrors how is already inlined. +/// +/// 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 Reorder, + IReadOnlyList Conversions, DateTime CreatedAt, DateTime? UpdatedAt); /// UOM conversion row (docs/11 §2.2). @@ -33,15 +43,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList 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; } + /// Optional; must belong to . Rejected when subcategories are disabled. + public int? SubCategoryId { get; set; } + /// Optional. Rejected when brands are disabled. + 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; } + /// Optional; must belong to . Rejected when subcategories are disabled. + public int? SubCategoryId { get; set; } + /// Optional. Rejected when brands are disabled. + 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; } } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs index c70e9a7..d062179 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest [Required] public int VendorId { get; set; } public int? RequisitionId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); + + /// + /// When true the PO is created in Draft (editable/deletable, not yet issued). + /// When false (default) it auto-approves on creation, preserving the Requisition→PO + /// and RFQ→PO flows unchanged (docs/11 §3.3). + /// + public bool SaveAsDraft { get; set; } } public sealed class UpdatePurchaseOrderRequest diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs index f9deb75..5854fbb 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs @@ -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 Lines, IReadOnlyList LedgerRefs); + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +/// Row shape for GET /purchase-returns — no lines/ledgerRefs (those need a per-row query). +public sealed record PurchaseReturnSummaryDto( + int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, + int CreatedBy, DateTime CreatedAt, int LineCount); // Requests ---------------------------------------------------------------------- diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs index 63b5e0a..ef8cae1 100644 --- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs @@ -12,7 +12,7 @@ public sealed record RequisitionDto( DateTime CreatedAt, IReadOnlyList 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 ---- diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs index d54ab90..f9a5635 100644 --- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs @@ -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 Lines); +/// Row shape for GET /rfqs — line/quotation counts instead of the lines themselves. +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( diff --git a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs new file mode 100644 index 0000000..9396bcf --- /dev/null +++ b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs @@ -0,0 +1,6 @@ +namespace ERPCore.Dtos.Rbac; + +/// 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). +public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList NavCodes); diff --git a/Backend/ERPCore/Dtos/Rbac/NavDtos.cs b/Backend/ERPCore/Dtos/Rbac/NavDtos.cs new file mode 100644 index 0000000..76e5e30 --- /dev/null +++ b/Backend/ERPCore/Dtos/Rbac/NavDtos.cs @@ -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 Children); diff --git a/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs b/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs new file mode 100644 index 0000000..2c0d26b --- /dev/null +++ b/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs @@ -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; } +} + +/// Replaces a role's full permission set (checkbox-tree save from the frontend). +public sealed class AssignRolePermissionsRequest +{ + public List NavItemIds { get; set; } = new(); + public List SubNavItemIds { get; set; } = new(); +} + +public sealed record RolePermissionsDto(int RoleId, List NavItemIds, List SubNavItemIds); diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs index a8e9696..d7b97ce 100644 --- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs @@ -11,6 +11,11 @@ public sealed record AdjustmentDto( int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); +/// Row shape for GET /stock-adjustments — no lines/ledgerRefs (those need a per-row query). +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 diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs index 44a67ec..a75a262 100644 --- a/Backend/ERPCore/Dtos/Stock/CountDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs @@ -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 Lines); + int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines); + +/// Row shape for GET /stock-counts — line count instead of the lines themselves. +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 LedgerRefs); diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs index dd91237..5d4d1d7 100644 --- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs @@ -10,7 +10,12 @@ public sealed record TransferLineDto( public sealed record TransferDto( int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, - TransferStatus Status, IReadOnlyList Lines); + TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines); + +/// Row shape for GET /stock-transfers — line count instead of the lines themselves. +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); diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs new file mode 100644 index 0000000..0d4c5ce --- /dev/null +++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs @@ -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); + +/// +/// 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`. +/// +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; } + /// Left empty to auto-generate (AuthHex emails it to ). + public string? Password { get; set; } +} + +public sealed class UpdateUserRoleRequest +{ + [Required] public int RoleId { get; set; } +} + +/// AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough). +public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description); diff --git a/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs new file mode 100644 index 0000000..bc1ba9f --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; + +namespace ERPCore.Infra.Auth; + +/// +/// 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). +/// +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)); +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs new file mode 100644 index 0000000..ef6b616 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs @@ -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; + +/// +/// HTTP implementation of . 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 . +/// +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 RegisterAsync(RegisterRequest request, CancellationToken ct) + => CallAsync("user", "registerUser", request, null, ct); + + public Task LoginAsync(LoginRequest request, CancellationToken ct) + => CallAsync("user", "loginUser", request, null, ct); + + public Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct) + => CallAsync("user", "VerifyOtpForLogin", request, null, ct); + + public Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct) + => CallAsync("user", "refreshToken", new { refreshToken, deviceName }, null, ct); + + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct) + => CallAsync("user", "getUserDetails", new { userId }, null, ct); + + public Task> ListUserTypesAsync(CancellationToken ct) + => CallAsync>("user", "listUserTypes", new { }, null, ct); + + public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct) + => CallAsync>("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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "VerifyPassword", request, bearerToken, ct); + + public Task LogoutUserAsync(Guid userId, CancellationToken ct) + => CallVoidAsync("user", "LogoutUser", new { userId }, null, ct); + + public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct) + => CallAsync("user", "UpdateUser", request, bearerToken, ct); + + public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct) + => CallAsync("user", "initiateTwoFASetup", new { }, bearerToken, ct); + + public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct) + => CallAsync("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 GetTwoFaStatusAsync(string bearerToken, CancellationToken ct) + => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct); + + // ---- RecoveryManager ------------------------------------------------ + + public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct) + => CallAsync("recovery", "forgotPassword", request, null, ct); + + public Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct) + => CallAsync("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 IsAvailableAsync(IsAvailableRequest request, CancellationToken ct) + => CallAsync("alt", "IsAvailable", request, null, ct); + + public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct) + => CallAsync("alt", "sendOtp", request, null, ct); + + public Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct) + => CallAsync("alt", "VerifyOTP", request, null, ct); + + // ---- RoleManager -------------------------------------------------- + + public Task CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct) + => CallAsync("role", "createRole", request, null, ct); + + public Task> ListRolesAsync(CancellationToken ct) + => CallAsync>("role", "listRoles", new { }, null, ct); + + public Task GetRoleAsync(Guid roleId, CancellationToken ct) + => CallAsync("role", "getRole", new { roleId }, null, ct); + + public Task UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct) + => CallAsync("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(routeGroup, functionName, payload, bearerToken, ct); + + private async Task CallAsync(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? envelope; + try + { + envelope = await httpResponse.Content.ReadFromJsonAsync>(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!; + } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs new file mode 100644 index 0000000..02f7794 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs @@ -0,0 +1,37 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1). +public sealed class AuthHexEnvelope +{ + public int StatusCode { get; set; } + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } +} + +/// Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1). +public sealed class AuthHexRequestBody +{ + public string FunctionName { get; set; } = string.Empty; + public object Payload { get; set; } = new { }; + public string Reference { get; set; } = string.Empty; +} + +/// +/// 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 . +/// +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; } +} diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs new file mode 100644 index 0000000..cd0f614 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs @@ -0,0 +1,51 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Infra.Auth.AuthHex; + +/// +/// 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. +/// +public interface IAuthHexClient +{ + // UserManager (POST /api/user) + Task RegisterAsync(RegisterRequest request, CancellationToken ct); + Task LoginAsync(LoginRequest request, CancellationToken ct); + Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct); + Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct); + Task GetUserDetailsAsync(Guid userId, CancellationToken ct); + Task> ListUserTypesAsync(CancellationToken ct); + Task> 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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct); + Task LogoutUserAsync(Guid userId, CancellationToken ct); + Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct); + Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct); + Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct); + Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct); + Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct); + Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct); + + // RecoveryManager (POST /api/recovery) + Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); + Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); + Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct); + Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct); + + // AltOptionManager (POST /api/alt) + Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct); + Task SendOtpAsync(SendOtpRequest request, CancellationToken ct); + Task 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 CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct); + Task> ListRolesAsync(CancellationToken ct); + Task GetRoleAsync(Guid roleId, CancellationToken ct); + Task UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct); + Task DeleteRoleAsync(Guid roleId, CancellationToken ct); +} diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs index ea879ca..658433f 100644 --- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs +++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs @@ -18,6 +18,18 @@ public static class JwtAuthExtensions /// Authorization policy applied to every v1 controller (via ApiControllerBase). public const string ErpAccessPolicy = "ErpAccess"; + /// httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2). + public const string AccessTokenCookie = "erp_at"; + + /// httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route. + public const string RefreshTokenCookie = "erp_rt"; + + /// Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations. + public const string CsrfCookie = "XSRF-TOKEN"; + + /// Header the frontend echoes the CSRF cookie value back through. + 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 => diff --git a/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs new file mode 100644 index 0000000..cf4a631 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs @@ -0,0 +1,35 @@ +using ERPCore.System.Errors; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ERPCore.Infra.Auth; + +/// +/// 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 X-XSRF-TOKEN +/// header to match the non-httpOnly XSRF-TOKEN cookie AuthController +/// issues alongside the session cookies. +/// +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(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs new file mode 100644 index 0000000..52724a8 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs index a0565cb..8cc8732 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs @@ -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 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().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); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index 087ea19..0c47198 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration builder.Property(l => l.Qty).HasPrecision(18, 4); builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6); + builder.Property(l => l.DiscountPct).HasPrecision(9, 4); + builder.Property(l => l.NetUnitCost).HasPrecision(18, 6); + builder.Property(l => l.VatPct).HasPrecision(9, 4); + builder.Property(l => l.VatAmount).HasPrecision(18, 4); builder.Property(l => l.ReceivedValue).HasPrecision(18, 4); + builder.Property(l => l.LineTotal).HasPrecision(18, 4); builder.Property(l => l.HoldStatus).HasConversion().HasMaxLength(20).IsRequired(); builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index 4a74f03..dab0f1f 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration 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().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) .HasConversion().HasMaxLength(20).IsRequired(); @@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration .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 builder.HasIndex(i => i.Status); builder.HasIndex(i => i.CategoryId); + builder.HasIndex(i => i.BrandId); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs new file mode 100644 index 0000000..c26f97c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs @@ -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; + +/// +/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no +/// relationships here — nothing references this table (docs/10 Part C.9). +/// +public sealed class ItemTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs new file mode 100644 index 0000000..0a89d0a --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs @@ -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; + +/// +/// Seeded to mirror the frontend's hardcoded sidebar +/// (ERP-core/Frontend/erp-system/components/Layouts/AppSidebar.tsx). Codes here +/// must match the code given to each frontend nav entry. +/// +public sealed class NavItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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().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 } + ); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs new file mode 100644 index 0000000..065f5b8 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs @@ -0,0 +1,51 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// One row per /, seeded in lockstep +/// with /. +/// +public sealed class PermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 }, + new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 }, + new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 }, + new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 }, + new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 } + ); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs new file mode 100644 index 0000000..d916d09 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs @@ -0,0 +1,37 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// Configures the singleton product-configuration row (FR-MD-11). The check constraint +/// is what makes "singleton" a database guarantee rather than a convention. +/// +public sealed class ProductConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RoleConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RoleConfiguration.cs new file mode 100644 index 0000000..dcf2712 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/RoleConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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().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(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs new file mode 100644 index 0000000..62c903f --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs new file mode 100644 index 0000000..9fadd72 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subcategories"); + builder.HasKey(s => s.SubCategoryId); + + builder.Property(s => s.Name).IsRequired().HasMaxLength(200); + + builder.Property(s => s.Status) + .HasConversion().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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs new file mode 100644 index 0000000..eb4f156 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs @@ -0,0 +1,43 @@ +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 +{ + public void Configure(EntityTypeBuilder 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().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 }, + // Procurement (NavItemId 4) children — mirror the hub page order. + new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 }, + new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 }, + new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 }, + new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 } + ); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs index eee8827..685a9aa 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs @@ -23,6 +23,10 @@ public sealed class UserConfiguration : IEntityTypeConfiguration 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 { diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 59a8803..79a2352 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence; /// public static class DataSeeder { + /// + /// 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. + /// + 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 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 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; + } + + /// + /// 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. + /// + private static async Task 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; } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 6a7d7e6..dfcdc6c 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace ERPCore.Infra.Persistence; /// -/// 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 /// 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 Categories => Set(); + public DbSet SubCategories => Set(); + public DbSet Brands => Set(); + /// Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9). + public DbSet ItemTypes => Set(); public DbSet Uoms => Set(); public DbSet UomConversions => Set(); public DbSet Items => Set(); @@ -30,11 +34,20 @@ public class ErpDbContext : DbContext public DbSet Vendors => Set(); public DbSet Warehouses => Set(); public DbSet Bins => Set(); + /// Singleton row (FR-MD-11). + public DbSet ProductConfig => Set(); // --- Cross-cutting (docs/10 Part C.7) --- public DbSet Users => Set(); public DbSet NumberSequences => Set(); + // --- RBAC / sidebar (docs/10 Part C.8) --- + public DbSet Roles => Set(); + public DbSet NavItems => Set(); + public DbSet SubNavItems => Set(); + public DbSet Permissions => Set(); + public DbSet RolePermissions => Set(); + // --- Procurement (docs/10 Part C.2) --- public DbSet Requisitions => Set(); public DbSet RequisitionLines => Set(); diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs new file mode 100644 index 0000000..829393d --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.Designer.cs @@ -0,0 +1,2454 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig")] + partial class AddBrandsSubcategoriesItemTypesAndProductConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CategoryId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("integer") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .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") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .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 => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + 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") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs new file mode 100644 index 0000000..a7e0ac5 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260716134938_AddBrandsSubcategoriesItemTypesAndProductConfig.cs @@ -0,0 +1,442 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + /// 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). + /// + /// This migration carries data, not just DDL. The scaffolded version dropped + /// categories.ParentId 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 subcategories and repoint items + /// onto the correct (category, subcategory) pair before the column goes away. + /// + /// + public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration + { + /// + 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( + name: "BrandId", + table: "items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SubCategoryId", + table: "items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + 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( + name: "Status", + table: "categories", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Active"); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "categories", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "xmin", + table: "categories", + type: "xid", + rowVersion: true, + nullable: false, + defaultValue: 0u); + + migrationBuilder.CreateTable( + name: "brands", + columns: table => new + { + BrandId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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(type: "integer", nullable: false), + SubcategoriesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + BrandsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + ItemTypesEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "integer", nullable: true), + xmin = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CategoryId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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); + } + + /// + /// Reverses the schema change and puts the subcategory data back where it came from. + /// + /// The scaffolded version simply dropped subcategories, which would have + /// discarded exactly what 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. + /// + /// + 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( + 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); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs new file mode 100644 index 0000000..184ab18 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs @@ -0,0 +1,2454 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260718081219_ini2")] + partial class ini2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CategoryId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("integer") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .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") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .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 => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + 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") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs new file mode 100644 index 0000000..4c89398 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class ini2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs new file mode 100644 index 0000000..ca80bfc --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs @@ -0,0 +1,3001 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260718092655_AddRolesNavPermissions")] + partial class AddRolesNavPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CategoryId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("BrandId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("integer") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("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("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("uuid") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("RoleId") + .HasColumnType("integer"); + + b.Property("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("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .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") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .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 => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + 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") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + 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") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + 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") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + 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") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + 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") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs new file mode 100644 index 0000000..07ace7f --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs @@ -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 +{ + /// + public partial class AddRolesNavPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RoleId", + table: "users", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "nav_items", + columns: table => new + { + NavItemId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Icon = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Href = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + Status = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + auth_role_id = table.Column(type: "uuid", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsSystemRole = table.Column(type: "boolean", nullable: false, defaultValue: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + NavItemId = table.Column(type: "integer", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Icon = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Href = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + Status = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + NavItemId = table.Column(type: "integer", nullable: true), + SubNavItemId = table.Column(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(type: "integer", nullable: false), + PermissionId = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index 92abc54..fa5a7fb 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -119,6 +119,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("bins", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("CategoryId") @@ -127,17 +169,36 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("ParentId") - .HasColumnType("integer"); + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); b.HasKey("CategoryId"); - b.HasIndex("ParentId"); + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); b.ToTable("categories", (string)null); }); @@ -216,6 +277,10 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("BinId") .HasColumnType("integer"); + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + b.Property("GrnId") .HasColumnType("integer"); @@ -227,9 +292,21 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("ItemId") .HasColumnType("integer"); + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("NetUnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + b.Property("PoLineId") .HasColumnType("integer"); + b.Property("PoUnitPrice") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + b.Property("Qty") .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); @@ -245,6 +322,14 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("UomId") .HasColumnType("integer"); + b.Property("VatAmount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("VatPct") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + b.HasKey("GrnLineId"); b.HasIndex("BatchId"); @@ -273,6 +358,9 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("BaseUomId") .HasColumnType("integer"); + b.Property("BrandId") + .HasColumnType("integer"); + b.Property("CategoryId") .HasColumnType("integer"); @@ -286,11 +374,6 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(1000) .HasColumnType("character varying(1000)"); - b.Property("ItemType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -314,6 +397,14 @@ namespace ERPCore.Infra.Persistence.Migrations .HasColumnType("character varying(20)") .HasDefaultValue("Active"); + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + b.Property("TaxClass") .HasMaxLength(20) .HasColumnType("character varying(20)"); @@ -330,6 +421,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("BaseUomId"); + b.HasIndex("BrandId"); + b.HasIndex("CategoryId"); b.HasIndex("DefaultVendorId"); @@ -339,6 +432,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("Status"); + b.HasIndex("SubCategoryId"); + b.ToTable("items", (string)null); }); @@ -374,6 +469,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("item_reorders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("JournalId") @@ -411,6 +548,142 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("journal_entry_stubs", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("SequenceId") @@ -441,6 +714,171 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("number_sequences", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("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 + }, + new + { + PermissionId = 19, + Code = "NAV:procurement.requisitions", + SubNavItemId = 9 + }, + new + { + PermissionId = 20, + Code = "NAV:procurement.rfqs", + SubNavItemId = 10 + }, + new + { + PermissionId = 21, + Code = "NAV:procurement.purchase-orders", + SubNavItemId = 11 + }, + new + { + PermissionId = 22, + Code = "NAV:procurement.purchase-returns", + SubNavItemId = 12 + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => { b.Property("PoLineId") @@ -490,6 +928,48 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("po_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("integer"); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("PoId") @@ -787,6 +1267,78 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("rfq_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("uuid") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("RoleId") + .HasColumnType("integer"); + + b.Property("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("SerialId") @@ -1239,6 +1791,222 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("stock_transfer_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("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("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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" + }, + new + { + SubNavItemId = 9, + Code = "procurement.requisitions", + Href = "/dashboard/procurement/requisitions", + Label = "Requisitions", + NavItemId = 4, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 10, + Code = "procurement.rfqs", + Href = "/dashboard/procurement/rfqs", + Label = "RFQs", + NavItemId = 4, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 11, + Code = "procurement.purchase-orders", + Href = "/dashboard/procurement/purchase-orders", + Label = "Purchase Orders", + NavItemId = 4, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 12, + Code = "procurement.purchase-returns", + Href = "/dashboard/procurement/purchase-returns", + Label = "Purchase Returns", + NavItemId = 4, + SortOrder = 4, + Status = "Active" + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -1310,6 +2078,9 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("RoleId") + .HasColumnType("integer"); + b.Property("Status") .IsRequired() .HasMaxLength(20) @@ -1325,6 +2096,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("AuthUserId") .IsUnique(); + b.HasIndex("RoleId"); + b.HasIndex("Username") .IsUnique(); @@ -1516,16 +2289,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 +2379,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 +2395,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 +2430,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 +2482,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 +2639,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 +2915,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 +2964,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 +3014,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 +3029,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"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 635ec52..5fcf63c 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -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(); // 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(c => +{ + var baseUrl = builder.Configuration["AuthHex:BaseUrl"] + ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured."); + c.BaseAddress = new Uri(baseUrl); +}); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // 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(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Cross-cutting + procurement services (docs/11 §3) builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 53604f7..8983eec 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -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 _warehouses; private readonly IRepository _items; private readonly IRepository _reasonCodes; + private readonly IRepository _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 adjustments, IRepository warehouses, IRepository items, - IRepository reasonCodes, IStockMutator mutator, INumberSequenceService numbers, - ICurrentUser currentUser, IUnitOfWork uow) + IRepository reasonCodes, IRepository 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> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task 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 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 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); } diff --git a/Backend/ERPCore/Services/Auth/AuthAltService.cs b/Backend/ERPCore/Services/Auth/AuthAltService.cs new file mode 100644 index 0000000..15cb7ec --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthAltService.cs @@ -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; + +/// +public sealed class AuthAltService : IAuthAltService +{ + private readonly IAuthHexClient _authHex; + + public AuthAltService(IAuthHexClient authHex) => _authHex = authHex; + + public Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default) + => _authHex.IsAvailableAsync(request, ct); + + public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default) + => _authHex.SendOtpAsync(request, ct); + + public async Task 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 + } + }; + } +} diff --git a/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs new file mode 100644 index 0000000..863b69f --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthRecoveryService.cs @@ -0,0 +1,25 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services.Auth; + +/// +public sealed class AuthRecoveryService : IAuthRecoveryService +{ + private readonly IAuthHexClient _authHex; + + public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex; + + public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default) + => _authHex.ForgotPasswordAsync(request, ct); + + public Task 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); +} diff --git a/Backend/ERPCore/Services/Auth/AuthSessionResult.cs b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs new file mode 100644 index 0000000..0622e4e --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthSessionResult.cs @@ -0,0 +1,25 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Services.Auth; + +/// +/// 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 in the response. +/// +public sealed class AuthSessionResult +{ + public required string AccessToken { get; init; } + public required string RefreshToken { get; init; } + public required AuthSessionResponse Body { get; init; } +} + +/// Same purpose as , for the two OTP-verify +/// flows whose body also carries ReferenceNumber/Verified alongside the user/session. +public sealed class OtpAuthSessionResult +{ + public required string AccessToken { get; init; } + public required string RefreshToken { get; init; } + public required OtpLoginVerifiedResponse Body { get; init; } +} diff --git a/Backend/ERPCore/Services/Auth/AuthUserService.cs b/Backend/ERPCore/Services/Auth/AuthUserService.cs new file mode 100644 index 0000000..a6ce0de --- /dev/null +++ b/Backend/ERPCore/Services/Auth/AuthUserService.cs @@ -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; + +/// +public sealed class AuthUserService : IAuthUserService +{ + private readonly IAuthHexClient _authHex; + + public AuthUserService(IAuthHexClient authHex) => _authHex = authHex; + + public async Task RegisterAsync(RegisterRequest request, CancellationToken ct = default) + { + request.UserId ??= Guid.NewGuid(); + var result = await _authHex.RegisterAsync(request, ct); + return ToSessionResult(result); + } + + public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) + { + var result = await _authHex.LoginAsync(request, ct); + return ToSessionResult(result); + } + + public async Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default) + { + var result = await _authHex.VerifyOtpForLoginAsync(request, ct); + return ToOtpSessionResult(result); + } + + public async Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default) + { + var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct); + return ToSessionResult(result); + } + + public Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default) + => _authHex.GetUserDetailsAsync(userId, ct); + + public Task> 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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.VerifyPasswordAsync(request, bearerToken, ct); + + /// The controller resolves the id (from body or token claim) before calling here. + public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default) + => request.UserId is null + ? Task.CompletedTask + : _authHex.LogoutUserAsync(request.UserId.Value, ct); + + public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default) + => _authHex.UpdateUserAsync(request, bearerToken, ct); + + public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default) + => _authHex.InitiateTwoFaSetupAsync(bearerToken, ct); + + public Task 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 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 + } + }; + } +} diff --git a/Backend/ERPCore/Services/BrandService.cs b/Backend/ERPCore/Services/BrandService.cs new file mode 100644 index 0000000..dce5fd3 --- /dev/null +++ b/Backend/ERPCore/Services/BrandService.cs @@ -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; + +/// +/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency +/// per docs/11-BACKEND-PHASE1.md §2.6. +/// +public sealed class BrandService : IBrandService +{ + private readonly IRepository _brands; + private readonly IUnitOfWork _uow; + + public BrandService(IRepository brands, IUnitOfWork uow) + { + _brands = brands; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> 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(Map(brand), brand.RowVersion); + } + + public async Task> 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(Map(brand), brand.RowVersion); + } + + public async Task> 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(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); +} diff --git a/Backend/ERPCore/Services/CategoryService.cs b/Backend/ERPCore/Services/CategoryService.cs index ca0f05d..a84e40a 100644 --- a/Backend/ERPCore/Services/CategoryService.cs +++ b/Backend/ERPCore/Services/CategoryService.cs @@ -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; +/// +/// 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). +/// public sealed class CategoryService : ICategoryService { private readonly IRepository _categories; + private readonly IRepository _subCategories; private readonly IUnitOfWork _uow; - public CategoryService(IRepository categories, IUnitOfWork uow) + public CategoryService( + IRepository categories, + IRepository subCategories, + IUnitOfWork uow) { _categories = categories; + _subCategories = subCategories; _uow = uow; } - public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + // Categories --------------------------------------------------------------- + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); } - public async Task> GetTreeAsync(CancellationToken ct = default) + public async Task?> 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 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(Map(category), category.RowVersion); } - public async Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default) + public async Task> 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(Map(category), category.RowVersion); } + + public async Task> 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(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> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> 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(MapSub(sub), sub.RowVersion); + } + + public async Task> 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(MapSub(sub), sub.RowVersion); + } + + public async Task> 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(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); } diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 1c62ecb..6b26bcf 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -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> 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.Create(rows, query.Page, query.PageSize, total); + } + public async Task 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()); } diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 891163f..3da76b1 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -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> 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.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int grnId, CancellationToken ct = default) { var grn = await _grns.Query().AsNoTracking() @@ -111,8 +138,11 @@ public sealed class GrnService : IGrnService if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct)) throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422); - // Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct. + // Cost: for a PO line, the PO price is used unless an override is entered (then it + // wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3, + // revised). Direct receipts always use the entered cost. decimal unitCost; + decimal? poUnitPrice = null; if (input.PoLineId is not null) { var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId) @@ -125,13 +155,19 @@ public sealed class GrnService : IGrnService throw new DomainException(ErrorCodes.OverReceiptTolerance, $"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422); - unitCost = poLine.UnitPrice; + poUnitPrice = poLine.UnitPrice; + unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice; } else { unitCost = input.UnitCost; } + // Derived figures are always computed server-side, never accepted from the client. + var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero); + var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero); + var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero); + var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct); lines.Add(new GrnLine @@ -143,7 +179,13 @@ public sealed class GrnService : IGrnService Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted Qty = input.Qty, UnitCost = unitCost, - ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero), + PoUnitPrice = poUnitPrice, + DiscountPct = input.DiscountPct, + NetUnitCost = netUnitCost, + VatPct = input.VatPct, + VatAmount = vatAmount, + ReceivedValue = receivedValue, + LineTotal = receivedValue + vatAmount, HoldStatus = input.HoldStatus }); } @@ -193,7 +235,9 @@ public sealed class GrnService : IGrnService foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) { var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); - var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token); + // FIFO layer costs at the after-discount net price; VAT is recoverable and never + // enters stock value (docs/10 FR-GRN-06, revised). + var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token); var layer = await _fifo.CreateInboundLayerAsync( line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, @@ -353,5 +397,8 @@ public sealed class GrnService : IGrnService private static GrnDto Map(Grn g) => new( g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( - l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList()); + l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, + l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, + l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), + l.HoldStatus, l.BatchId)).ToList()); } diff --git a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs index c2f3c29..0b535e1 100644 --- a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs +++ b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; namespace ERPCore.Services.Interfaces; @@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces; /// Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5). public interface IAdjustmentService { + Task> ListAsync( + PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default); + + Task GetAsync(int adjustmentId, CancellationToken ct = default); + Task CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs new file mode 100644 index 0000000..10c5c8d --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthAltService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Services.Auth; + +namespace ERPCore.Services.Interfaces; + +/// AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex. +public interface IAuthAltService +{ + Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default); + Task SendOtpAsync(SendOtpRequest request, CancellationToken ct = default); + Task VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs new file mode 100644 index 0000000..cb807c8 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthRecoveryService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Auth; + +namespace ERPCore.Services.Interfaces; + +/// RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex. +public interface IAuthRecoveryService +{ + Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default); + Task VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default); + Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default); + Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs new file mode 100644 index 0000000..785b50c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuthUserService.cs @@ -0,0 +1,26 @@ +using ERPCore.Dtos.Auth; +using ERPCore.Services.Auth; + +namespace ERPCore.Services.Interfaces; + +/// UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex. +public interface IAuthUserService +{ + Task RegisterAsync(RegisterRequest request, CancellationToken ct = default); + Task LoginAsync(LoginRequest request, CancellationToken ct = default); + Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default); + Task RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default); + Task GetUserDetailsAsync(Guid userId, CancellationToken ct = default); + Task> 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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default); + Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default); + Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default); + Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default); + Task 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 GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IBrandService.cs b/Backend/ERPCore/Services/Interfaces/IBrandService.cs new file mode 100644 index 0000000..33b4a80 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBrandService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Brands; +using ERPCore.Dtos.Common; + +namespace ERPCore.Services.Interfaces; + +/// Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6). +public interface IBrandService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int brandId, CancellationToken ct = default); + Task> CreateAsync(CreateBrandRequest request, CancellationToken ct = default); + Task> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs index c318ef6..a96116e 100644 --- a/Backend/ERPCore/Services/Interfaces/ICategoryService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICategoryService.cs @@ -1,12 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; using ERPCore.Dtos.Categories; using ERPCore.Dtos.Common; namespace ERPCore.Services.Interfaces; -/// Category master business logic (docs/11-BACKEND-PHASE1.md §2.3). +/// +/// 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. +/// public interface ICategoryService { - Task> ListAsync(PageQuery query, CancellationToken ct = default); - Task> GetTreeAsync(CancellationToken ct = default); - Task CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int categoryId, CancellationToken ct = default); + Task> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default); + Task> UpdateAsync(int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default); + + /// Subcategories of one category. 404s when the category itself does not exist. + Task> ListSubCategoriesAsync( + int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default); + + Task?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default); + Task> CreateSubCategoryAsync(int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default); + Task> UpdateSubCategoryAsync(int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ICountService.cs b/Backend/ERPCore/Services/Interfaces/ICountService.cs index 667796e..514e751 100644 --- a/Backend/ERPCore/Services/Interfaces/ICountService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICountService.cs @@ -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; /// Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08). public interface ICountService { + Task> ListAsync( + PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int countId, CancellationToken ct = default); Task CreateAsync(CreateCountRequest request, CancellationToken ct = default); Task EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IGrnService.cs b/Backend/ERPCore/Services/Interfaces/IGrnService.cs index 92c0dc7..a683c26 100644 --- a/Backend/ERPCore/Services/Interfaces/IGrnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IGrnService.cs @@ -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; /// Goods-receipt business logic (docs/11 §4; FR-GRN-01..08). public interface IGrnService { + Task> ListAsync( + PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default); + Task GetAsync(int grnId, CancellationToken ct = default); Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs index b405f27..f73ee08 100644 --- a/Backend/ERPCore/Services/Interfaces/IItemService.cs +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces; public interface IItemService { Task> 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?> GetAsync(int itemId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs b/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs new file mode 100644 index 0000000..e03969e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemTypeService.cs @@ -0,0 +1,20 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; + +namespace ERPCore.Services.Interfaces; + +/// +/// 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. +/// +public interface IItemTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int itemTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs b/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs new file mode 100644 index 0000000..468eb29 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IProductConfigService.cs @@ -0,0 +1,13 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Config; + +namespace ERPCore.Services.Interfaces; + +/// Product configuration business logic (docs/11-BACKEND-PHASE1.md §2.8). Singleton. +public interface IProductConfigService +{ + Task> GetAsync(CancellationToken ct = default); + + Task> UpdateAsync( + UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs index 898588b..a93bca2 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs @@ -16,4 +16,10 @@ public interface IPurchaseOrderService Task> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default); Task ApproveAsync(int poId, CancellationToken ct = default); Task CancelAsync(int poId, string? reason, CancellationToken ct = default); + + /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. + Task SubmitAsync(int poId, CancellationToken ct = default); + + /// Delete a PO — permitted only while Draft, else 409 PO_NOT_EDITABLE. + Task DeleteAsync(int poId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs index 8dc20b7..6543a4c 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs @@ -1,3 +1,4 @@ +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; namespace ERPCore.Services.Interfaces; @@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces; /// Purchase-return business logic (docs/11 §3.4; FR-PROC-08). public interface IPurchaseReturnService { + Task> ListAsync( + PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default); + + Task GetAsync(int returnId, CancellationToken ct = default); + Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs index 309645d..640b3a0 100644 --- a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Enums; using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; @@ -6,7 +7,8 @@ namespace ERPCore.Services.Interfaces; /// Purchase-requisition business logic (docs/11 §3.1). public interface IRequisitionService { - Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task> ListAsync( + PageQuery query, RequisitionStatus? status, CancellationToken ct = default); Task GetAsync(int requisitionId, CancellationToken ct = default); Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default); Task SubmitAsync(int requisitionId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IRfqService.cs b/Backend/ERPCore/Services/Interfaces/IRfqService.cs index 9074dd1..83c42f5 100644 --- a/Backend/ERPCore/Services/Interfaces/IRfqService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRfqService.cs @@ -1,3 +1,5 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; namespace ERPCore.Services.Interfaces; @@ -5,6 +7,8 @@ namespace ERPCore.Services.Interfaces; /// RFQ & vendor-quotation business logic (docs/11 §3.2). public interface IRfqService { + Task> ListAsync(PageQuery query, RfqStatus? status, CancellationToken ct = default); + Task GetAsync(int rfqId, CancellationToken ct = default); Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default); Task AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IRoleService.cs b/Backend/ERPCore/Services/Interfaces/IRoleService.cs new file mode 100644 index 0000000..c561850 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IRoleService.cs @@ -0,0 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Rbac; + +namespace ERPCore.Services.Interfaces; + +/// +/// Role CRUD + permission assignment. AuthHex is the source of truth for Role +/// identity (docs/10 C.9 "shadow user" pattern, applied to Role): every write is +/// forwarded to AuthHex first, then mirrored into the local shadow Role row. +/// Permission assignment is purely local (ERPCore/UI concern, not an AuthHex one). +/// +public interface IRoleService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int roleId, CancellationToken ct = default); + Task> CreateAsync(CreateRoleRequest request, CancellationToken ct = default); + Task> UpdateAsync(int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int roleId, CancellationToken ct = default); + + Task GetPermissionsAsync(int roleId, CancellationToken ct = default); + Task AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default); + + /// Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`. + Task GetMeAsync(string? roleCode, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IStockService.cs b/Backend/ERPCore/Services/Interfaces/IStockService.cs index 3fdaf78..32a720c 100644 --- a/Backend/ERPCore/Services/Interfaces/IStockService.cs +++ b/Backend/ERPCore/Services/Interfaces/IStockService.cs @@ -8,8 +8,13 @@ public interface IStockService { Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); + /// On-hand for every (item, warehouse) pair holding stock — backs the enquiry list. + Task> GetOnHandListAsync( + int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default); + Task> GetLedgerAsync( - int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default); Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/ITransferService.cs b/Backend/ERPCore/Services/Interfaces/ITransferService.cs index 44707e2..8afeda9 100644 --- a/Backend/ERPCore/Services/Interfaces/ITransferService.cs +++ b/Backend/ERPCore/Services/Interfaces/ITransferService.cs @@ -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; /// Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06). public interface ITransferService { + Task> ListAsync( + PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default); + Task GetAsync(int transferId, CancellationToken ct = default); Task CreateAsync(CreateTransferRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs new file mode 100644 index 0000000..56ea564 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs @@ -0,0 +1,20 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Users; + +namespace ERPCore.Services.Interfaces; + +/// +/// Admin-facing user management: list/create/reassign-role against the local +/// shadow `User` table, orchestrating account creation in AuthHex too (see +/// ). +/// +public interface IUserManagementService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task GetAsync(int userId, CancellationToken ct = default); + Task CreateAsync(CreateUserRequest request, CancellationToken ct = default); + Task UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); + + /// AuthHex UserType options for the create-user form's select. + Task> ListUserTypesAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 3a54e1c..c28808c 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -13,36 +13,52 @@ namespace ERPCore.Services; /// /// Item master service. Enforces SKU uniqueness (SKU_DUPLICATE), reference -/// integrity, and optimistic concurrency (CONCURRENCY_CONFLICT) per -/// docs/11-BACKEND-PHASE1.md §2.1–2.2 and 02-SECURITY C.1. +/// integrity, product-configuration gating (CONFIG_DISABLED), and optimistic +/// concurrency (CONCURRENCY_CONFLICT) per docs/11-BACKEND-PHASE1.md §2.1–2.2 +/// and 02-SECURITY C.1. +/// +/// The SKU arrives generated by the client (it encodes the chosen item-type values, +/// e.g. "BL-100-0003"); this service only checks that it is unique. Items hold no +/// item-type reference at all — see docs/10 Part C.9. +/// /// public sealed class ItemService : IItemService { private readonly IRepository _items; private readonly IRepository _categories; + private readonly IRepository _subCategories; + private readonly IRepository _brands; private readonly IRepository _uoms; private readonly IRepository _vendors; private readonly IRepository _warehouses; + private readonly IProductConfigService _config; private readonly IUnitOfWork _uow; public ItemService( IRepository items, IRepository categories, + IRepository subCategories, + IRepository brands, IRepository uoms, IRepository vendors, IRepository warehouses, + IProductConfigService config, IUnitOfWork uow) { _items = items; _categories = categories; + _subCategories = subCategories; + _brands = brands; _uoms = uoms; _vendors = vendors; _warehouses = warehouses; + _config = config; _uow = uow; } public async Task> ListAsync( - PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default) + PageQuery query, EntityStatus? status, int? categoryId, int? subCategoryId, int? brandId, + TrackingMode? trackingMode, CancellationToken ct = default) { var q = _items.Query().AsNoTracking(); @@ -53,14 +69,17 @@ public sealed class ItemService : IItemService } if (status is not null) q = q.Where(i => i.Status == status); if (categoryId is not null) q = q.Where(i => i.CategoryId == categoryId); + if (subCategoryId is not null) q = q.Where(i => i.SubCategoryId == subCategoryId); + if (brandId is not null) q = q.Where(i => i.BrandId == brandId); if (trackingMode is not null) q = q.Where(i => i.TrackingMode == trackingMode); var total = await q.CountAsync(ct); var rows = await q.OrderBy(i => i.Sku) .Skip(query.Skip).Take(query.PageSize) .Select(i => new ItemListItemDto( - i.ItemId, i.Sku, i.Name, i.CategoryId, i.BaseUomId, i.DefaultVendorId, - i.ItemType, i.TrackingMode, i.TaxClass, i.Status)) + i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, + i.BaseUomId, i.DefaultVendorId, + i.StockNature, i.TrackingMode, i.TaxClass, i.Status)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -70,6 +89,7 @@ public sealed class ItemService : IItemService { var item = await _items.Query().AsNoTracking() .Include(i => i.ReorderSettings) + .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct); return item is null ? null : new ETagged(ToDetail(item), item.RowVersion); @@ -80,7 +100,9 @@ public sealed class ItemService : IItemService if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct)) throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); - await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct); + await ValidateReferencesAsync( + request.CategoryId, request.SubCategoryId, request.BrandId, + request.BaseUomId, request.DefaultVendorId, ct); var item = new Item { @@ -88,9 +110,11 @@ public sealed class ItemService : IItemService Name = request.Name.Trim(), Description = request.Description, CategoryId = request.CategoryId, + SubCategoryId = request.SubCategoryId, + BrandId = request.BrandId, BaseUomId = request.BaseUomId, DefaultVendorId = request.DefaultVendorId, - ItemType = request.ItemType, + StockNature = request.StockNature, TrackingMode = request.TrackingMode, TaxClass = request.TaxClass, Status = EntityStatus.Active, @@ -108,6 +132,7 @@ public sealed class ItemService : IItemService { var item = await _items.Query() .Include(i => i.ReorderSettings) + .Include(i => i.UomConversions) .FirstOrDefaultAsync(i => i.ItemId == itemId, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); @@ -118,15 +143,19 @@ public sealed class ItemService : IItemService && await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct)) throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400); - await ValidateReferencesAsync(request.CategoryId, request.BaseUomId, request.DefaultVendorId, ct); + await ValidateReferencesAsync( + request.CategoryId, request.SubCategoryId, request.BrandId, + request.BaseUomId, request.DefaultVendorId, ct); item.Sku = request.Sku.Trim(); item.Name = request.Name.Trim(); item.Description = request.Description; item.CategoryId = request.CategoryId; + item.SubCategoryId = request.SubCategoryId; + item.BrandId = request.BrandId; item.BaseUomId = request.BaseUomId; item.DefaultVendorId = request.DefaultVendorId; - item.ItemType = request.ItemType; + item.StockNature = request.StockNature; item.TrackingMode = request.TrackingMode; item.TaxClass = request.TaxClass; item.UpdatedAt = DateTime.UtcNow; @@ -240,11 +269,56 @@ public sealed class ItemService : IItemService return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions); } - private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct) + /// + /// Validates every FK on an item write, and gates the optional ones on the product + /// configuration (FR-MD-11). Note there is no item-type check: nothing on an item + /// references an item type, so itemTypesEnabled has nothing to reject here — + /// it is advisory and honoured by the frontend only (docs/11 §2.8). + /// + private async Task ValidateReferencesAsync( + int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct) { + var config = (await _config.GetAsync(ct)).Value; + if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422); + if (subCategoryId is not null) + { + if (!config.SubcategoriesEnabled) + throw new DomainException( + ErrorCodes.ConfigDisabled, + "Subcategories are disabled in the product configuration; subCategoryId must be null.", 422); + + var sub = await _subCategories.Query().AsNoTracking() + .FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct); + if (sub is null) + throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} does not exist.", 422); + if (sub.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Subcategory {subCategoryId} is inactive.", 422); + // The two FKs must agree, or the item would claim a category its subcategory + // does not belong to. + if (sub.CategoryId != categoryId) + throw new DomainException( + ErrorCodes.Validation, + $"Subcategory {subCategoryId} belongs to category {sub.CategoryId}, not {categoryId}.", 422); + } + + if (brandId is not null) + { + if (!config.BrandsEnabled) + throw new DomainException( + ErrorCodes.ConfigDisabled, + "Brands are disabled in the product configuration; brandId must be null.", 422); + + var brand = await _brands.Query().AsNoTracking() + .FirstOrDefaultAsync(b => b.BrandId == brandId, ct); + if (brand is null) + throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} does not exist.", 422); + if (brand.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Brand {brandId} is inactive.", 422); + } + if (!await _uoms.Query().AnyAsync(u => u.UomId == baseUomId, ct)) throw new DomainException(ErrorCodes.Validation, $"UOM {baseUomId} does not exist.", 422); @@ -272,11 +346,16 @@ public sealed class ItemService : IItemService } private static ItemDetailDto ToDetail(Item i) => new( - i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.BaseUomId, i.DefaultVendorId, - i.ItemType, i.TrackingMode, i.TaxClass, i.Status, + i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, + i.BaseUomId, i.DefaultVendorId, + i.StockNature, i.TrackingMode, i.TaxClass, i.Status, i.ReorderSettings .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) .ToList(), + i.UomConversions + .OrderBy(c => c.ConversionId) + .Select(c => new UomConversionDto(c.ConversionId, c.FromUomId, c.ToUomId, c.Factor)) + .ToList(), i.CreatedAt, i.UpdatedAt); } diff --git a/Backend/ERPCore/Services/ItemTypeService.cs b/Backend/ERPCore/Services/ItemTypeService.cs new file mode 100644 index 0000000..6297a54 --- /dev/null +++ b/Backend/ERPCore/Services/ItemTypeService.cs @@ -0,0 +1,118 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.ItemTypes; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Item type master service (FR-MD-10) — maintains the Color/Size/Material list that +/// GET /item-types serves to the frontend builder's dropdown. Nothing references +/// these rows, so there is no in-use check to make and no cascade to worry about +/// (docs/11-BACKEND-PHASE1.md §2.7, docs/10 Part C.9). +/// +public sealed class ItemTypeService : IItemTypeService +{ + private readonly IRepository _itemTypes; + private readonly IUnitOfWork _uow; + + public ItemTypeService(IRepository itemTypes, IUnitOfWork uow) + { + _itemTypes = itemTypes; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _itemTypes.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new ItemTypeDto(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int itemTypeId, CancellationToken ct = default) + { + var itemType = await _itemTypes.Query().AsNoTracking() + .FirstOrDefaultAsync(t => t.ItemTypeId == itemTypeId, ct); + return itemType is null ? null : new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task> CreateAsync(CreateItemTypeRequest request, CancellationToken ct = default) + { + var name = request.Name.Trim(); + if (await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower(), ct)) + throw new ConflictException($"An item type named '{name}' already exists."); + + var itemType = new ItemType + { + Name = name, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _itemTypes.AddAsync(itemType, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task> UpdateAsync( + int itemTypeId, UpdateItemTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) + ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); + + if (itemType.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); + + var name = request.Name.Trim(); + if (!string.Equals(itemType.Name, name, StringComparison.Ordinal) + && await _itemTypes.Query().AnyAsync(t => t.Name.ToLower() == name.ToLower() && t.ItemTypeId != itemTypeId, ct)) + throw new ConflictException($"An item type named '{name}' already exists."); + + // Renaming does not touch existing items: their SKUs already encode the values that + // were chosen, and nothing joins back to this row (docs/10 Part C.9). + itemType.Name = name; + itemType.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The item type was modified by another request.", 412); + } + + return new ETagged(Map(itemType), itemType.RowVersion); + } + + public async Task SetStatusAsync(int itemTypeId, EntityStatus status, CancellationToken ct = default) + { + var itemType = await _itemTypes.GetByIdAsync(itemTypeId, ct) + ?? throw new NotFoundException($"Item type {itemTypeId} was not found."); + + itemType.Status = status; + itemType.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static ItemTypeDto Map(ItemType t) => new(t.ItemTypeId, t.Name, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/ProductConfigService.cs b/Backend/ERPCore/Services/ProductConfigService.cs new file mode 100644 index 0000000..ef8d0c4 --- /dev/null +++ b/Backend/ERPCore/Services/ProductConfigService.cs @@ -0,0 +1,71 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Config; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder. +/// Reads are never gated — only writes consult the flags — so switching a feature off +/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8). +/// +public sealed class ProductConfigService : IProductConfigService +{ + private readonly IRepository _config; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public ProductConfigService(IRepository config, ICurrentUser currentUser, IUnitOfWork uow) + { + _config = config; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> GetAsync(CancellationToken ct = default) + { + var config = await _config.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ConfigId == ProductConfig.SingletonId, ct) + ?? throw new NotFoundException("Product configuration has not been seeded."); + + return new ETagged(Map(config), config.RowVersion); + } + + public async Task> UpdateAsync( + UpdateProductConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var config = await _config.GetByIdAsync(ProductConfig.SingletonId, ct) + ?? throw new NotFoundException("Product configuration has not been seeded."); + + if (config.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + + // Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the + // model binder has already rejected anything that would reach here with a null. + config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value; + config.BrandsEnabled = request.BrandsEnabled!.Value; + config.ItemTypesEnabled = request.ItemTypesEnabled!.Value; + config.UpdatedAt = DateTime.UtcNow; + config.UpdatedBy = _currentUser.AuditUserId; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + } + + return new ETagged(Map(config), config.RowVersion); + } + + private static ProductConfigDto Map(ProductConfig c) => new( + c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy); +} diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs index cae9d35..25c114e 100644 --- a/Backend/ERPCore/Services/PurchaseOrderService.cs +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -92,9 +92,10 @@ public sealed class PurchaseOrderService : IPurchaseOrderService DocNo = docNo, VendorId = request.VendorId, RequisitionId = request.RequisitionId, - // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04). + // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04), + // unless the caller explicitly saves a Draft (editable/deletable until submitted). ApprovalRequired = false, - Status = PurchaseOrderStatus.Approved, + Status = request.SaveAsDraft ? PurchaseOrderStatus.Draft : PurchaseOrderStatus.Approved, CreatedBy = actor, CreatedAt = DateTime.UtcNow, Lines = request.Lines.Select(ToLine).ToList() @@ -182,8 +183,41 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return Map(po); } - private static bool IsEditable(PurchaseOrderStatus status) => status is not ( - PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled); + public async Task SubmitAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Status != PurchaseOrderStatus.Draft) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409); + + // Phase 1: no value gate, so a submitted draft goes straight to Approved (FR-PROC-04). + po.Status = PurchaseOrderStatus.Approved; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + + return Map(po); + } + + public async Task DeleteAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Status != PurchaseOrderStatus.Draft) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409); + + _pos.Remove(po); + await _uow.SaveChangesAsync(ct); + } + + // FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it. + // Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05. + private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft; private static PoLine ToLine(CreatePoLineInput l) => new() { diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 4e0dd11..da6ed71 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -25,6 +26,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly IRepository _items; private readonly IRepository _reasonCodes; private readonly IRepository _grnLines; + private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -33,7 +35,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService public PurchaseReturnService( IRepository returns, IRepository vendors, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository grnLines, - IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + IRepository ledger, IStockMutator mutator, INumberSequenceService numbers, + ICurrentUser currentUser, IUnitOfWork uow) { _returns = returns; _vendors = vendors; @@ -41,12 +44,54 @@ public sealed class PurchaseReturnService : IPurchaseReturnService _items = items; _reasonCodes = reasonCodes; _grnLines = grnLines; + _ledger = ledger; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; _uow = uow; } + public async Task> ListAsync( + PageQuery query, int? vendorId, int? warehouseId, CancellationToken ct = default) + { + var q = _returns.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + if (vendorId is not null) q = q.Where(r => r.VendorId == vendorId); + if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.ReturnId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new PurchaseReturnSummaryDto( + r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, + r.CreatedBy, r.CreatedAt, r.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int returnId, CancellationToken ct = default) + { + var ret = await _returns.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); + if (ret is null) return null; + + // Polymorphic ledger reference (docs/10 C.9) — recovered by source-doc lookup. + var ledgerRefs = await _ledger.Query().AsNoTracking() + .Where(l => l.SourceDocType == DocumentTypes.PurchaseReturn && l.SourceDocId == returnId) + .OrderBy(l => l.LedgerId) + .Select(l => l.LedgerId) + .ToListAsync(ct); + + return ToDto(ret, ledgerRefs); + } + public async Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default) { if (request.ReasonCodeId is null) @@ -105,11 +150,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService }, ct); // Map ledger ids after commit so they are populated. - return new PurchaseReturnDto( - entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status, - entity.CreatedBy, - entity.Lines.OrderBy(l => l.ReturnLineId) - .Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(), - ledgerEntries.Select(r => r.LedgerId).ToList()); + return ToDto(entity, ledgerEntries.Select(r => r.LedgerId).ToList()); } + + private static PurchaseReturnDto ToDto(PurchaseReturn r, IReadOnlyList ledgerRefs) => new( + r.ReturnId, r.DocNo, r.VendorId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt, + r.Lines.OrderBy(l => l.ReturnLineId) + .Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(), + ledgerRefs); } diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs index 8390609..d8086a6 100644 --- a/Backend/ERPCore/Services/RequisitionService.cs +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -31,7 +31,8 @@ public sealed class RequisitionService : IRequisitionService _uow = uow; } - public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + public async Task> ListAsync( + PageQuery query, RequisitionStatus? status, CancellationToken ct = default) { var q = _requisitions.Query().AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -39,11 +40,13 @@ public sealed class RequisitionService : IRequisitionService var term = query.Q.Trim(); q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); } + if (status is not null) q = q.Where(r => r.Status == status); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(r => r.RequisitionId) .Skip(query.Skip).Take(query.PageSize) - .Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt)) + .Select(r => new RequisitionSummaryDto( + r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, r.Lines.Count)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); diff --git a/Backend/ERPCore/Services/RfqService.cs b/Backend/ERPCore/Services/RfqService.cs index b73db6e..2072565 100644 --- a/Backend/ERPCore/Services/RfqService.cs +++ b/Backend/ERPCore/Services/RfqService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Procurement; using ERPCore.Infra.UoW; using ERPCore.Repositories.Interfaces; @@ -34,6 +35,31 @@ public sealed class RfqService : IRfqService _uow = uow; } + public async Task> ListAsync( + PageQuery query, RfqStatus? status, CancellationToken ct = default) + { + var q = _rfqs.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.RfqId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new RfqSummaryDto( + r.RfqId, r.DocNo, r.RequisitionId, r.Status, + r.Lines.Count, + // Correlated subquery: there is no Rfq.Quotations navigation to count. + _quotations.Query().Count(qt => qt.RfqId == r.RfqId))) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int rfqId, CancellationToken ct = default) { var rfq = await _rfqs.Query().AsNoTracking() diff --git a/Backend/ERPCore/Services/RoleService.cs b/Backend/ERPCore/Services/RoleService.cs new file mode 100644 index 0000000..d9a9acd --- /dev/null +++ b/Backend/ERPCore/Services/RoleService.cs @@ -0,0 +1,214 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Auth; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Rbac; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class RoleService : IRoleService +{ + private readonly IRepository _roles; + private readonly IRepository _rolePermissions; + private readonly IRepository _permissions; + private readonly IAuthHexClient _authHex; + private readonly IUnitOfWork _uow; + + public RoleService( + IRepository roles, IRepository rolePermissions, IRepository permissions, + IAuthHexClient authHex, IUnitOfWork uow) + { + _roles = roles; + _rolePermissions = rolePermissions; + _permissions = permissions; + _authHex = authHex; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _roles.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.Code, $"%{term}%") || EF.Functions.ILike(r.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(r => r.Code) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int roleId, CancellationToken ct = default) + { + var role = await _roles.Query().AsNoTracking().FirstOrDefaultAsync(r => r.RoleId == roleId, ct); + return role is null ? null : new ETagged(Map(role), role.RowVersion); + } + + public async Task> CreateAsync(CreateRoleRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _roles.Query().AnyAsync(r => r.Code == code, ct)) + throw new ConflictException($"A role with code '{code}' already exists."); + + var authRole = await _authHex.CreateRoleAsync( + new CreateAuthHexRoleRequest { Code = code, Name = request.Name.Trim() }, ct); + + var role = new Role + { + AuthRoleId = authRole.RoleId, + Code = code, + Name = request.Name.Trim(), + IsSystemRole = authRole.IsSystemRole ?? false, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _roles.AddAsync(role, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(role), role.RowVersion); + } + + public async Task> UpdateAsync( + int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(roleId, ct) + ?? throw new NotFoundException($"Role {roleId} was not found."); + + if (role.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412); + + var code = request.Code.Trim(); + if (!string.Equals(role.Code, code, StringComparison.Ordinal) + && await _roles.Query().AnyAsync(r => r.Code == code && r.RoleId != roleId, ct)) + throw new ConflictException($"A role with code '{code}' already exists."); + + await _authHex.UpdateRoleAsync( + new UpdateAuthHexRoleRequest { RoleId = role.AuthRoleId, Code = code, Name = request.Name.Trim() }, ct); + + role.Code = code; + role.Name = request.Name.Trim(); + role.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412); + } + + return new ETagged(Map(role), role.RowVersion); + } + + public async Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(roleId, ct) + ?? throw new NotFoundException($"Role {roleId} was not found."); + + role.Status = status; + role.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(int roleId, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(roleId, ct) + ?? throw new NotFoundException($"Role {roleId} was not found."); + + try + { + await _authHex.DeleteRoleAsync(role.AuthRoleId, ct); + } + catch (DomainException ex) when (ex.Message.Contains("ROLE_IN_USE", StringComparison.OrdinalIgnoreCase)) + { + throw new DomainException(ErrorCodes.RoleInUse, "This role is assigned to one or more users.", 409); + } + + _roles.Remove(role); + await _uow.SaveChangesAsync(ct); + } + + public async Task GetPermissionsAsync(int roleId, CancellationToken ct = default) + { + _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + + var granted = await _rolePermissions.Query().AsNoTracking() + .Where(rp => rp.RoleId == roleId) + .Include(rp => rp.Permission) + .Select(rp => rp.Permission!) + .ToListAsync(ct); + + return new RolePermissionsDto( + roleId, + granted.Where(p => p.NavItemId is not null).Select(p => p.NavItemId!.Value).ToList(), + granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList()); + } + + public async Task AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default) + { + _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + + var existing = await _rolePermissions.Query().Where(rp => rp.RoleId == roleId).ToListAsync(ct); + foreach (var rp in existing) _rolePermissions.Remove(rp); + + var navIds = request.NavItemIds.Distinct().ToList(); + var subNavIds = request.SubNavItemIds.Distinct().ToList(); + + var permissionIds = await _permissions.Query().AsNoTracking() + .Where(p => (p.NavItemId != null && navIds.Contains(p.NavItemId.Value)) + || (p.SubNavItemId != null && subNavIds.Contains(p.SubNavItemId.Value))) + .Select(p => p.PermissionId) + .ToListAsync(ct); + + foreach (var permissionId in permissionIds) + await _rolePermissions.AddAsync(new RolePermission { RoleId = roleId, PermissionId = permissionId }, ct); + + await _uow.SaveChangesAsync(ct); + + return await GetPermissionsAsync(roleId, ct); + } + + public async Task GetMeAsync(string? roleCode, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(roleCode)) + return new MeResponseDto(null, null, Array.Empty()); + + var role = await _roles.Query().AsNoTracking() + .FirstOrDefaultAsync(r => r.Code == roleCode, ct); + if (role is null) + return new MeResponseDto(roleCode, null, Array.Empty()); + + var permissions = await _rolePermissions.Query().AsNoTracking() + .Where(rp => rp.RoleId == role.RoleId) + .Include(rp => rp.Permission!).ThenInclude(p => p.NavItem) + .Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem) + .Select(rp => rp.Permission!) + .ToListAsync(ct); + + var navCodes = permissions + .Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code) + .Where(code => code is not null) + .Select(code => code!) + .Distinct() + .ToList(); + + return new MeResponseDto(role.Code, role.Name, navCodes); + } + + private static RoleDto Map(Role r) => new( + r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs index 2eaedab..1d9e5fb 100644 --- a/Backend/ERPCore/Services/Stock/StockService.cs +++ b/Backend/ERPCore/Services/Stock/StockService.cs @@ -50,14 +50,85 @@ public sealed class StockService : IStockService return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow); } + /// + /// On-hand across every (item, warehouse) pair that holds stock — backs the Stock + /// Enquiry list. Deliberately set-based: four grouped queries regardless of page size, + /// rather than calling per row (which would be N+1). + /// Pairs are sourced from StockLayer, so an item that never had a receipt in a + /// warehouse simply does not appear. + /// + public async Task> GetOnHandListAsync( + int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default) + { + var layers = _layers.Query().AsNoTracking(); + if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); + if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); + + var grouped = layers + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, OnHand = g.Sum(x => x.QtyRemaining) }); + + var total = await grouped.CountAsync(ct); + var page = await grouped + .OrderBy(x => x.ItemId).ThenBy(x => x.WarehouseId) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + if (page.Count == 0) + return PagedResponse.Create([], query.Page, query.PageSize, total); + + // Filtering by the page's ids gives a superset (the cross-product of both lists); + // the join below narrows it back to the actual pairs. + var itemIds = page.Select(p => p.ItemId).Distinct().ToList(); + var warehouseIds = page.Select(p => p.WarehouseId).Distinct().ToList(); + + var onHold = (await _layers.Query().AsNoTracking() + .Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId) + && l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold) + .GroupBy(l => new { l.ItemId, l.WarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.QtyRemaining) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + + var inTransit = (await _transferLines.Query().AsNoTracking() + .Where(l => itemIds.Contains(l.ItemId) + && l.Transfer != null + && warehouseIds.Contains(l.Transfer.SrcWarehouseId) + && l.Transfer.Status == TransferStatus.InTransit) + .GroupBy(l => new { l.ItemId, WarehouseId = l.Transfer!.SrcWarehouseId }) + .Select(g => new { g.Key.ItemId, g.Key.WarehouseId, Qty = g.Sum(x => x.Qty - x.QtyReceived) }) + .ToListAsync(ct)) + .ToDictionary(x => (x.ItemId, x.WarehouseId), x => x.Qty); + + var asOf = DateTime.UtcNow; + var rows = page.Select(p => + { + var key = (p.ItemId, p.WarehouseId); + var hold = onHold.GetValueOrDefault(key); + var transit = inTransit.GetValueOrDefault(key); + const decimal reserved = 0m; + // Same formula as GetOnHandAsync: in-transit is reported, not re-subtracted. + return new StockOnHandDto( + p.ItemId, p.WarehouseId, p.OnHand, p.OnHand - hold - reserved, hold, transit, reserved, asOf); + }).ToList(); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task> GetLedgerAsync( - int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default) + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) { var q = _ledger.Query().AsNoTracking(); if (itemId is not null) q = q.Where(l => l.ItemId == itemId); if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue)); if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); + // Source-doc filter: the ledger references its originating document polymorphically + // (docs/10 C.9), so this is the only way to ask "what did document X post?" — + // needed by any screen that reports on a document's costed movements. + if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(l => l.SourceDocType == sourceDocType); + if (sourceDocId is not null) q = q.Where(l => l.SourceDocId == sourceDocId); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(l => l.LedgerId) diff --git a/Backend/ERPCore/Services/TransferService.cs b/Backend/ERPCore/Services/TransferService.cs index 6429811..a3b4ce1 100644 --- a/Backend/ERPCore/Services/TransferService.cs +++ b/Backend/ERPCore/Services/TransferService.cs @@ -1,6 +1,7 @@ using ERPCore.Domain; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; using ERPCore.Dtos.Stock; using ERPCore.Infra.Auth; using ERPCore.Infra.UoW; @@ -40,6 +41,31 @@ public sealed class TransferService : ITransferService _uow = uow; } + public async Task> ListAsync( + PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default) + { + var q = _transfers.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId); + if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(t => t.TransferId) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new TransferSummaryDto( + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.CreatedBy, t.CreatedAt, t.Lines.Count)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + public async Task GetAsync(int transferId, CancellationToken ct = default) { var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) @@ -190,7 +216,7 @@ public sealed class TransferService : ITransferService } private static TransferDto Map(StockTransfer t) => new( - t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt, t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); } diff --git a/Backend/ERPCore/Services/UserManagementService.cs b/Backend/ERPCore/Services/UserManagementService.cs new file mode 100644 index 0000000..3471270 --- /dev/null +++ b/Backend/ERPCore/Services/UserManagementService.cs @@ -0,0 +1,124 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Auth; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Users; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class UserManagementService : IUserManagementService +{ + private readonly IRepository _users; + private readonly IRepository _roles; + private readonly IAuthUserService _authUsers; + private readonly IAuthHexClient _authHex; + private readonly IUnitOfWork _uow; + + public UserManagementService( + IRepository users, IRepository roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow) + { + _users = users; + _roles = roles; + _authUsers = authUsers; + _authHex = authHex; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + { + IQueryable q = _users.Query().AsNoTracking().Include(u => u.Role); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%")); + } + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(u => u.Username) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task GetAsync(int userId, CancellationToken ct = default) + { + var user = await _users.Query().AsNoTracking().Include(u => u.Role) + .FirstOrDefaultAsync(u => u.UserId == userId, ct); + return user is null ? null : Map(user); + } + + public async Task CreateAsync(CreateUserRequest request, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(request.RoleId, ct) + ?? throw new NotFoundException($"Role {request.RoleId} was not found."); + + var username = request.Username.Trim(); + if (await _users.Query().AnyAsync(u => u.Username == username, ct)) + throw new ConflictException($"A user with username '{username}' already exists."); + + var authUserId = Guid.NewGuid(); + + // Source of truth: AuthHex creates the credential + emails it (registerUser, + // ERP_Auth_Service/Services/UserManager/UserManagerService.cs). + await _authUsers.RegisterAsync(new RegisterRequest + { + UserId = authUserId, + RoleId = role.AuthRoleId, + UserTypeId = request.UserTypeId, + Fullname = request.FullName.Trim(), + UserName = username, + Nic = request.Nic, + Email = request.Email.Trim(), + MobileNumber = request.MobileNumber, + Password = request.Password, + ChkUser = true + }, ct); + + // Mirror into the local shadow User row immediately, rather than waiting + // for ShadowUserClaimsTransformation's next-login JIT provisioning. + var user = new User + { + AuthUserId = authUserId, + Username = username, + DisplayName = request.FullName.Trim(), + RoleId = role.RoleId, + Status = EntityStatus.Active + }; + + await _users.AddAsync(user, ct); + await _uow.SaveChangesAsync(ct); + + user.Role = role; + return Map(user); + } + + public async Task UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default) + { + var user = await _users.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User {userId} was not found."); + var role = await _roles.GetByIdAsync(request.RoleId, ct) + ?? throw new NotFoundException($"Role {request.RoleId} was not found."); + + user.RoleId = role.RoleId; + await _uow.SaveChangesAsync(ct); + + user.Role = role; + return Map(user); + } + + public async Task> ListUserTypesAsync(CancellationToken ct = default) + { + var userTypes = await _authHex.ListUserTypesAsync(ct); + return userTypes.Select(t => new UserTypeOptionDto(t.UserTypeId, t.Code, t.Description)).ToList(); + } + + private static ManagedUserDto Map(User u) => new( + u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 48a544b..fdba3e0 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -24,4 +24,12 @@ public static class ErrorCodes public const string ReasonCodeRequired = "REASON_CODE_REQUIRED"; public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT"; public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY"; + public const string ConfigDisabled = "CONFIG_DISABLED"; + public const string RoleInUse = "ROLE_IN_USE"; + + // Auth proxy (AuthController → AuthHex, docs/11 §2.0) + public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; + public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; + public const string CsrfTokenMismatch = "CSRF_TOKEN_MISMATCH"; + public const string RefreshTokenMissing = "REFRESH_TOKEN_MISSING"; } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index 3d13631..28ac658 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -7,5 +7,8 @@ }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root" + }, + "AuthHex": { + "BaseUrl": "http://localhost:5011" } } diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json index 838af85..f2762ae 100644 --- a/Backend/ERPCore/appsettings.Production.json +++ b/Backend/ERPCore/appsettings.Production.json @@ -4,5 +4,8 @@ }, "Jwt": { "SigningKey": "" + }, + "AuthHex": { + "BaseUrl": "" } } diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index d49cb2d..7735c12 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME" + "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root" }, "Auth": { "Issuer": "AuthHex", @@ -15,5 +15,8 @@ "RequiredUserTypeCode": "", "RequiredRoleCode": "" }, + "AuthHex": { + "BaseUrl": "http://localhost:5011" + }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 7e710c6..4cbffea 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -15,28 +15,66 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`) ## 1. Master Data -> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical. -- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU +> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting (**that endpoint was removed on 2026-07-16** — categories no longer nest; see the entry at the end of this section); `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical. +- [x] Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU - [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1) - [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert) -- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation) +- [x] Category — **rebuilt 2026-07-16 as a two-level Category/SubCategory model** (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely - [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`) - [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse) - [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation) +- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK +- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only +- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` +- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes + +> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2) +> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8. +> +> **Deviations / decisions (all recorded in docs/10 §B.8.4 #11–13):** +> - **`ItemType` enum → `StockNature`.** The Stocked/NonStocked/Service enum was renamed to free the name `ItemType` for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (`RenameColumn`, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it. +> - **`CATEGORY.parent_id` removed.** Arbitrary nesting is gone, replaced by a dedicated `SUBCATEGORY` table (exactly two levels). Items now carry **both** FKs; previously the frontend collapsed them (`effectiveCategoryId = subCategoryId ?? categoryId`), losing the parent. +> - **Item types are unlinked to items, deliberately.** No value table, no join — values live only in the client-generated SKU (`BL-100-0003`) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is *not* a product-variation model; none was requested. +> - **`itemTypesEnabled` is advisory, not enforced.** With no item-type reference on an item there is nothing on a write to reject; only `subcategoriesEnabled`/`brandsEnabled` produce `CONFIG_DISABLED`. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee. +> - **`PUT /product-config` is door-policy-gated only** — any ERP-admitted user can flip the flags. A `CONFIG_MANAGE` permission is reserved for when RBAC lands (open decision #13). +> - **`brandId` is now a documented field**, no longer the undocumented frontend-only extra it was. +> +> **Migration #2 (`AddBrandsSubcategoriesItemTypesAndProductConfig`) carries data, not just DDL.** The scaffolded version dropped `parent_id` outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into `subcategories`, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at **any** depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its *root*, not of its (now-nonexistent) parent category. `Down()` was likewise hand-written to restore the tree instead of dropping `subcategories` and losing it. +> +> Also fixed while writing it: the `ck_product_config_singleton` check constraint was scaffolded as `config_id = 1`, but the column is created quoted-PascalCase (`"ConfigId"`) — unquoted, Postgres folds it to a column that doesn't exist. And `UpdateProductConfigRequest`'s flags are `bool?` on purpose: `[Required]` on a non-nullable `bool` is a no-op, so a body of `{}` would have bound all three to `false` and silently switched every feature off. +> +> **Schema/migration verified:** `dotnet build` clean. Migration `Up` **and** `Down` exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and `StockNature` data preservation; the fixture was then removed. `DataSeeder` seeds `Color`/`Size` + the config singleton idempotently (it needed restructuring — an early `return` in the reason-code path would otherwise have skipped the new seeds on every start after the first). +> +> **Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session.** Auth note: a token *is* obtainable despite the `loginUser` blocker — **`POST /api/v1/auth/register` succeeds and issues the `erp_at` session cookie directly**, and the JWT handler's cookie fallback means that session authenticates every other controller. (`loginUser` still `500`s "Invalid credentials" for that same freshly-registered user, by username *or* email, with *or* without `userTypeId` — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal `roleId`/`userTypeId` GUIDs, supplied by the user; Admin = role `08de6a11-9e9f-4401-8a10-6859860b41ec` / userType `00000000-0000-0000-0000-000000000004`. +> +> Covered: brand/category/subcategory/item-type create; **case-insensitive duplicate name → 409** (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU `BL-100-0003` → 201 and full round-trip on `GET /items/{id}`; new `brandId`/`subCategoryId` list filters; **cross-FK guard → 422** ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; `PUT /product-config {}` → **400** (proving the `bool?` fix — an empty body no longer silently disables everything); `subcategoriesEnabled:false` + `subCategoryId` → **422 CONFIG_DISABLED**, same item without it → 201, and **pre-existing items with a subcategory still read back fine**; `brandsEnabled:false` + `brandId` → 422; **`itemTypesEnabled:false` correctly does NOT block item writes** (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → **412 CONCURRENCY_CONFLICT** (brand + subcategory) / absent → 428; `PATCH /status` → 204 then inactive-brand reference → 422; and **renaming an item type left existing SKUs untouched**, confirming the intended decoupling. Audit stamp confirmed live: `product_config.updatedBy` resolved to a JIT-provisioned shadow user (`SMOKE001`) from the AuthHex `UserId`/`NIC` claims. +> +> Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). **Two artifacts left behind on purpose:** the AuthHex user `smoketest_admin` / NIC `SMOKE001` in AuthHex's own MySQL store, and its ERPCore shadow user (`users.UserId = 3`) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted. ## 2. Procurement > Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired. - [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get) - [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix) -- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel +- [x] Purchase Order: create (auto-approve **or `saveAsDraft`**, `approvalRequired` flag), edit **Draft-only** (If-Match), **submit** (Draft→Approved), **delete** (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open) - [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.) > **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match. -> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. +> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. **Consequence surfaced 2026-07-17:** no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table. + +> ### 2026-07-17 — read endpoints added so the frontend could be connected +> The frontend rewire (see `Frontend/PROGRESS.md`) needed reads that did not exist. `stock-adjustments` and `purchase-returns` had **no GET at all** — a UI could not re-display a record it had just created. +> - **New:** `GET /grns`, `GET /rfqs`, `GET /stock-transfers`, `GET /stock-counts`, `GET /stock-adjustments` **+ `/{id}`**, `GET /purchase-returns` **+ `/{id}`**, `GET /stock/on-hand/list`. All follow `ItemService.ListAsync` (ILike on `q`, filters, `PagedResponse.Create`) with matching `*SummaryDto`s carrying a `lineCount`. +> - **`GET /stock/on-hand/list`** is deliberately set-based — four grouped queries regardless of page size — rather than calling `GetOnHandAsync` per row (N+1). It replaces a client-side loop the mock used to do. +> - **`GET /stock/ledger` gained `sourceDocType`/`sourceDocId`.** The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history. +> - **`ItemDetailDto` gained `conversions`** (+ `.Include(i => i.UomConversions)`): they could only be *written* (`PUT /items/{id}/uom-conversions` returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation `Frontend/PROGRESS.md` had flagged. +> - **DTOs gained fields the entities already had** and the UI needed: `createdBy`/`createdAt` on transfers + counts, `createdAt` on purchase returns, `lineCount` + a `status` filter on requisitions. Cheaper and more honest than deleting working columns from the screens. +> - **Bug fixed — `POST /auth/logout` made `userId` optional.** AuthHex returns `user.userId: null` on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's `UserId` claim and **always** clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser. +> - **Verified:** `dotnet build` clean; every new endpoint returns a correct `PagedResponse` against a live cookie session; `conversions` round-trips; `CONFIG_DISABLED` (422), `CONCURRENCY_CONFLICT` (412) and the cross-FK 422 (*"Subcategory 5 belongs to category 10, not 11"*) all confirmed through the browser. Test data removed afterwards. +> - **Not done — serial numbers (FR-GRN-04, priority M):** `CreateGrnLineInput` carries `batch` but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does **not** collect them rather than silently discarding them. `SERIAL`/`StockLayer.serial_id` already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4. ## 3. Goods Receipt > Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate. -- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. +- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry. - [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred. - [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4). @@ -59,6 +97,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred. - [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO. - [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml` → `RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `int` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match. +- [x] Auth proxy: **`AuthController` fronting AuthHex** (2026-07-16) — the frontend no longer calls AuthHex directly; `Controllers/AuthController.cs` + `Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}` proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) via `Infra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}` (`AuthHex:BaseUrl` config). Sessions delivered as httpOnly Secure `erp_at`/`erp_rt` cookies + `XSRF-TOKEN` double-submit cookie (`Infra/Auth/AuthCookieWriter.cs`, 02-SECURITY §B.2); `ValidateCsrfAttribute` guards every mutating action; the JWT bearer handler now also accepts `erp_at` as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) so every other v1 controller keeps working unchanged. See `docs/11-BACKEND-PHASE1.md §2.0` for the full route table and `docs/02-SECURITY.md` AR-07/AR-08 for the two carried-over exposures (anonymous `getUserDetails`/`LogoutUser`, no rate limiting yet). **Not done this pass:** CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (`lib/api/auth.ts` + login/OTP/reset pages) — all deliberately deferred follow-ups. - [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70). - [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub. - [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built. @@ -72,6 +111,16 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`) +- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value). +- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched. +- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly. +- **Verified:** `dotnet build` clean (0/0); migration **Up and Down** exercised against the live DB (rollback to `AddRolesNavPermissions` then re-apply — both `Done`). **Runtime end-to-end PASSED — 22/22 assertions** (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; **costing proof** (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, **FIFO layer @9.00, valuation 900 — VAT absent from stock**); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010. + +### 2026-07-20 (2) — Procurement sidebar submenu (migration `AddProcurementSubNav`) +- The sidebar submenu is driven by seeded `SubNavItem` rows + `GET /auth/me` navCodes; only Products/Settings had children, so **Purchase Orders had no sidebar section**. Added 4 `SubNavItem`s (ids 9–12, `NavItemId 4`) + 4 `Permission`s (ids 19–22) for Requisitions/RFQs/Purchase Orders/Purchase Returns via `AddProcurementSubNav`. The migration also grants the 4 to any role already holding the parent `NAV:procurement` (raw SQL, `ON CONFLICT DO NOTHING`); `Down()` removes the grants then the rows. +- **Found:** the `Admin` role (`RoleId 2`) was never granted `NAV:procurement` at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. **Verified:** `/auth/me` for Admin returns `procurement` + all 4 children; frontend `tsc`/`eslint` clean. + ### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented - Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services. - Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor. @@ -133,3 +182,21 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `int` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`. - **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied. - **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process. + +### 2026-07-16 — Auth proxy: `AuthController` fronting AuthHex (frontend no longer calls AuthHex directly) +- **Architecture reversal:** the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have **zero** existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration. `docs/10-BACKEND-PHASE1.md` (header, A.4, NFR-03) and `docs/11-BACKEND-PHASE1.md §2.0` updated in place; `docs/02-SECURITY.md` gained AR-07/AR-08 and ticked 3 of 5 B.2 boxes. +- **`Infra/Auth/AuthHex/`** — `IAuthHexClient`/`AuthHexClient` (typed `HttpClient`, `AuthHex:BaseUrl` config = `http://localhost:5011` dev), one C# method per AuthHex `functionName`, hides the `{functionName,payload,reference}`/`{statusCode,success,message,data}` dispatcher envelope entirely; upstream failures → `DomainException` (`AUTH_UPSTREAM_ERROR`/`AUTH_SERVICE_UNAVAILABLE`). +- **`Dtos/Auth/*`** — REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (`AuthSessionResponse`, `OtpLoginVerifiedResponse`) deliberately omit tokens. +- **`Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}`** — orchestrate `IAuthHexClient` calls; `AuthSessionResult`/`OtpAuthSessionResult` (`Services/Auth/AuthSessionResult.cs`) carry tokens from service → controller only, never serialized. +- **`Controllers/AuthController.cs`** — `api/v1/auth/*`, 24 actions (see `docs/11 §2.0` table); inherits `ControllerBase` directly (not `ApiControllerBase`) since most actions need `[AllowAnonymous]` and its ETag/If-Match handling doesn't apply here. +- **Cookie/CSRF (`Infra/Auth/AuthCookieWriter.cs`, `ValidateCsrfAttribute.cs`, `JwtAuthExtensions.cs`):** `erp_at` (Path `/`), `erp_rt` (Path `/api/v1/auth/refresh-token`, scoped so it's only sent to the refresh call), `XSRF-TOKEN` (non-httpOnly) — all `HttpOnly`(except CSRF)/`Secure`/`SameSite=Strict`. `ValidateCsrfAttribute` double-submit-checks `X-XSRF-TOKEN` against the cookie on every mutating action, exempting Bearer-header callers. `JwtAuthExtensions`'s `OnMessageReceived` falls back to the `erp_at` cookie when no `Authorization` header is present — every existing v1 controller keeps working unchanged under either auth mode. +- **Verified:** `dotnet build` clean (0 warn/0 err) after two passes — first pass hit `CS0051` (a public interface/constructor can't expose an `internal` parameter type) on `IAuthHexClient` and its supporting `AuthHex*` wire types, fixed by making them `public`; second pass caught a cookie-path bug (`erp_rt`'s `Path` was written as `/api/auth/refresh-token`, not matching the actual `/api/v1/auth/refresh-token` route — the browser would never have sent the cookie back on refresh) before it shipped. +- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision. +- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed. +- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing. +- **✅ RESOLVED 2026-07-17 — login works.** The AuthHex fix below was applied (`ERP_Auth_Service`, uncommenting the `PasswordHash` assignment) and verified: `POST /api/v1/auth/login` now returns **200 + `Set-Cookie: erp_at`** for a freshly-registered user, where it previously returned `500 "Invalid credentials"`. This unblocked the §1–§5 live verification that had been pending for two sessions. **Users registered before the fix have a null hash and can never log in** — they must be re-registered (the session's `smoketest_admin` among them). +- **Historical:** `loginUser` used to fail with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy. + - **ROOT CAUSE (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners.** Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`. + 1. **The password was never stored — FIXED 2026-07-17.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 was commented out** (`//PasswordHash = PasswordHash`). Every registered user landed in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always threw `"Invalid credentials"`. **Uncommenting that line fixed login outright** — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836). + 2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: ""` will not resolve a user; only email/mobile/NIC will. + - **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed. diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 3bdc995..1b76f10 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -5,28 +5,33 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. ## 0. Foundation -- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — currently unused now that the fetch client is gone (see 2026-07-15 note below) -- [ ] Typed API client / fetch wrapper — **removed 2026-07-15** (`lib/api-client.ts` + `lib/auth-token.ts` deleted per explicit user request: "delete all api connections"). No live code path in the frontend makes an HTTP call to any backend anymore — every screen is 100% mock data (`lib/api/mock-data.ts`). -- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn); `types/common.ts` now also carries `ApiResult` (moved here 2026-07-15 when `lib/api-client.ts` was deleted, since it's a plain data envelope, not fetch-specific) +- [x] **Transport: same-origin Next `rewrites()` proxy** (`next.config.ts`, `/api/*` → `BACKEND_ORIGIN`, default `http://localhost:5224`). `BACKEND_ORIGIN` in `.env.local` / `.env.local.example` — **not** `NEXT_PUBLIC_*`; the browser never sees the backend URL. Chosen over backend CORS because it makes the API same-origin, so CORS and the Secure/SameSite=Strict session cookies stop being problems at all. (`.gitignore`'s `.env*` was silently swallowing the example file — added a `!.env.local.example` negation.) +- [x] **Typed API client rebuilt** (`lib/api-client.ts`, 2026-07-17) — recovered the pre-deletion version from git (`0e4bcf1^`) and adapted: relative `/api/v1` base, **`credentials: "include"`** (never present before), `ApiResult`/`ProblemDetails` imported from `@/types/common` rather than redeclared, `readCsrfToken()` for the eight `[ValidateCsrf]` auth actions. `ApiError`, `apiRequest`, `apiRequestWithETag`, `buildQuery`, `ifMatch`/`idempotencyKey` all carried over. +- [x] **Route guard** (`proxy.ts` — Next 16's rename of `middleware.ts`; the old name still works but warns). Redirects `/dashboard/*` to `/login?next=…` when the `erp_at` cookie is absent. **Presence check only** — the cookie is httpOnly and the JWT is RS256, so the edge cannot validate it; the API stays the authority. +- [x] **Auth** (`lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`) — real login/logout. No token is stored: the session is httpOnly cookies. `lib/auth-session.ts` caches the user *profile* in localStorage for the Header, because there is no `GET /auth/me` and the user object only arrives in the login response. It is display data, not a credential. +- [x] Shared TS types mirroring API DTOs (`types/{common,master-data,procurement,grn,stock,auth}.ts`) — **reconciled field-by-field against the live schemas 2026-07-17**; see the entry below for what had drifted. - [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3) -- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection (the `ApiError` class they used to check via `instanceof` no longer exists); this also fixed a latent bug where the mock layer's plain `Error`-plus-`.code` rejects never matched the old `instanceof ApiError` check, so `CODE_MESSAGES` silently never applied to any mock error - -> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built. +- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection. **Fixed 2026-07-17:** generic framework codes (`conflict`/`not_found`/`validation_error`) were shadowing the server's specific `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes now lose to `detail`; specific domain codes still win. +> **⚠️ The 2026-07-15 note below is HISTORY, not current state.** The fetch infrastructure was rebuilt on 2026-07-17 and `lib/api/mock-data.ts` is deleted — see "2026-07-17 — connected to the real API" at the bottom of this file. Its parting advice (re-verify the RFQ contract; expect to rebuild the client from scratch) was followed and proved correct. +> > **2026-07-15 — fetch infrastructure deleted outright (not just reverted to mock).** Following an earlier same-week pass that wired every `lib/api/*.ts` module to real `fetch` calls (then reverted via `git revert --no-commit` at the user's request — see `Backend`-adjacent history if relevant later), the user asked to go further and delete the underlying connection mechanism entirely, not just leave it unused. Deleted `lib/api-client.ts` (`apiRequest`/`apiRequestWithETag`/`buildQuery`/`ApiError`) and `lib/auth-token.ts` (bearer-token storage) as files. Follow-on fixes this required: (1) `ApiResult` — used by `items.ts`/`purchase-orders.ts`/`vendors.ts` for their mock ETag pattern — moved into `types/common.ts`; (2) `lib/error-map.ts` rewritten to duck-type instead of `instanceof ApiError`; (3) three detail pages (`vendors/[id]`, `products/[id]`, `procurement/purchase-orders/[id]`) had their `err instanceof ApiError ? err.code : (err as {code?:string})?.code` conflict-detection simplified to the duck-typed form only. Also stripped the now-dangling commented-out "real implementation" blocks (`// import { apiRequest... } from "@/lib/api-client"` etc.) from all 15 `lib/api/*.ts` files, since they referenced a now-deleted module. `tsc --noEmit`/`eslint` clean (same pre-existing `login/page.tsx` error and established `set-state-in-effect` pattern only — confirmed unchanged by this pass). > > **If real backend integration is attempted again**, note two things found during the reverted pass: the RFQ backend contract had drifted from this file's assumed shapes (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs`/`RfqService.cs` — no persisted invited-vendor list, `requisitionId` required on create, different comparison DTO field names) — re-verify against the actual backend rather than trusting old assumptions; and a typed fetch client + ETag/error-normalization layer will need to be rebuilt from scratch since `lib/api-client.ts`/`lib/auth-token.ts` no longer exist. ## 1. Auth -- [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage +- [x] Login screen — **wired 2026-07-17** to `POST /auth/login`. Previously it `console.log`'d the plaintext password and pushed to `/dashboard` unconditionally; any schema-valid input "logged in". Now: real call, session cookies, server errors surfaced, `?next=` honoured (same-origin paths only — an absolute URL there would be an open redirect). +- [x] Route guard (`proxy.ts`) + real logout in `components/Layouts/Header.tsx` — the Header no longer hardcodes `john52martinez@gmail.com`, and "Log out" is a real `POST /auth/logout` rather than a ``. - [~] Forgot password — add email screen — UI built (`app/login/forgot`); not yet wired to API - [~] Forgot password — verify OTP screen — UI built (`app/login/forgot/otp`); not yet wired to API - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). - [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 -- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04 +- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-15:** added debounced search + Previous/Next pagination (`categoriesApi.list()` now takes `page`/`pageSize`/`q`/`sortOrder`, page size 5), matching the Vendor list's pagination pattern. +- [~] Brands (`app/dashboard/products/brands` list + create/edit dialog + delete) — **not a documented FR/endpoint**; `lib/api/brands.ts` treats it as a standalone name-only master, same shape as Categories, since Item has no `brandId` in the doc. **2026-07-15:** added the same debounced search + pagination as Categories; `Item`/`CreateItemRequest`/`ItemListItem` gained `brandId` so the new-item variant builder (above) can attach a brand. +- [~] Variant Categories (`app/dashboard/products/variants` list + create/edit dialog + delete) — **frontend-only, not a documented FR/endpoint.** A flat, name-only master list of variant dimensions (seeded with "Color", "Size") that the Item `/new` variant builder now genuinely drives from (see the Items bullet above) — checking a category there shows its value-entry UI, and a "+" on that same page can create a brand-new category (e.g. "Material") inline via `variantCategoriesApi.create`, which then also shows up back here. Values themselves (Red, Blue, S, M...) are still not managed on this page — only entered per-Item on `/new` — so `variant_values` (the individual Red/Blue/S/M records) still isn't a real backend entity; flag to whoever owns the backend contract if that should change. New `types/master-data.ts` (`VariantCategory`/`CreateVariantCategoryRequest`/`UpdateVariantCategoryRequest`), `lib/api/variants.ts` (`variantCategoriesApi`), `lib/validations/master-data.ts` (`validateVariantCategoryName`). Sidebar gained a "Variant" entry under Products (`components/Layouts/AppSidebar.tsx`). - [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). - [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create. - [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05 @@ -34,17 +39,19 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Procurement screens - [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 - [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02 -- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07 +- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry. - [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail -- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode` +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. - [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session - [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` - Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` -> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below). +> **⚠️ The two notes below are HISTORY (2026-07-13).** The GRN backend exists and these screens call it as of 2026-07-17; `GET /grns` + `GET /grns/{id}` are real, and GRN edit/delete were removed because the API has no `PUT`/`DELETE`. The FIFO engine they describe as living in `mock-data.ts` is deleted — the server owns it. +> +> **`[~]` not `[x]`, by design (at the time):** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend existed yet** (this was frontend-only work; see the deviation below). > > **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`). > @@ -62,7 +69,9 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). - Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) -> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). +> **⚠️ HISTORY (2026-07-13).** The Stock Core backend exists and these screens call it as of 2026-07-17; the list endpoints assumed here (`GET /stock-transfers`, `/stock-adjustments`, `/stock-counts`, on-hand list) were all added for real. The in-memory Stock Core described below is deleted. +> +> **`[~]` not `[x]`, by design (at the time) — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). > > **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). > @@ -81,6 +90,48 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## Done +### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass) +- **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived). +- **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages. +- **Deliberately not touched:** the item picker already showed `sku — name` (the request's stated need); multi-GRN-per-PO and adding a non-PO item to a PO GRN already worked. Vendor stays PO-derived (non-selectable) for a PO-based GRN — selecting a different vendor than the PO's would be wrong. +- **Select trigger showed the id, not the label (global fix).** Base UI's `Select.Value` renders the raw selected value unless the `Select.Root` is given an `items` map — the popup items unmount when closed, so their text isn't available to the trigger (confirmed in `@base-ui/react`'s `resolveSelectedLabel`, which `find`s `items` by value and only falls back to stringifying the value when none is supplied). Fixed once in the shared wrapper (`components/ui/select.tsx`): `Select` now walks its own `SelectItem` children and derives the `items` array automatically, so all ~60 `` call sites across 26 files show the selected label without any per-site change. `tsc`/`eslint` clean; verified against Base UI's label-resolution source. +- **Procurement sidebar submenu.** The sidebar builds submenus from backend-seeded `SubNavItem` rows filtered by `GET /auth/me`'s `navCodes`; only Products and Settings had children, so Purchase Orders had no sidebar section (only reachable via the Procurement hub card). Added a `children` array to the Procurement nav entry (`components/Layouts/AppSidebar.tsx`) — Requisitions, RFQs, Purchase Orders, Purchase Returns — matching new backend sub-nav codes. Also found the Admin role (`RoleId 2`) was never granted `NAV:procurement` at all, so the whole Procurement branch was hidden for it; granted the parent + 4 children. **Verified:** `/auth/me` for Admin now returns all five procurement codes → submenu renders. Stale PO hub-card copy ("freely editable while open") updated to the draft/submit wording. +- **Verified:** `tsc --noEmit` clean; `eslint` unchanged from baseline (7 pre-existing `set-state-in-effect` on the PO/GRN screens before and after — 0 new issues, confirmed by stashing and re-counting). Runtime browser verification is the next step in this pass. + +### 2026-07-17 — connected to the real API (mock-data.ts deleted) + +**The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated. + +**Transport + auth** +- Same-origin **Next `rewrites()` proxy** rather than backend CORS (see §0). The backend has no CORS and now needs none. +- Rebuilt `lib/api-client.ts` from `git show 0e4bcf1^`; added `credentials: "include"`. +- New `proxy.ts` route guard, `lib/api/auth.ts`, `lib/auth-session.ts`, `types/auth.ts`. Login/logout are real. +- **Fixed the long-standing `app/login/page.tsx` resolver type error** — `lib/validations.ts` used `z.preprocess`, which widens the schema's *input* type to `unknown`, so `zodResolver` produced a `Resolver<{email: unknown}>` that could not satisfy `useForm`. Form fields always yield strings (RHF defaults them to `""`), so the null-coercion it guarded against cannot happen. **`tsc --noEmit` is now fully clean** — the first time in this file's history. + +**Two real bugs found by driving the browser (both fixed, both invisible to unit-level checks)** +1. **Logout didn't log you out.** AuthHex returns `user.userId: null` on login, so the Header could not supply the `userId` that `POST /auth/logout` required; the call was skipped and `erp_at` survived. Fixed backend-side (`userId` optional, resolved from the token claim, cookies always cleared). Verified: cookies now `[]` after logout. +2. **Generic error codes shadowed the server's message.** `errorMessage()` checked `CODE_MESSAGES[code]` before `detail`, so a duplicate brand showed "This action conflicts with the record's current state." instead of "A brand named 'bosch' already exists." Generic codes (`conflict`/`not_found`/`validation_error`) now lose to `detail`. + +**Contract drift reconciled** (types were rewritten field-by-field against the live OpenAPI, not assumed): +- `itemType` → `stockNature`; `ItemType` is now the Color/Size master. `variants.ts` → `item-types.ts`; the screen moved to `/dashboard/products/item-types`. +- `RfqComparison` was `{lines[].cells[]}` in this app but `{rows[].quotes[]}` on the server, and cells carry `quotationId`. `Rfq` has no `vendorIds`/`createdAt`; `StockTransfer`/`StockCount` had `createdBy`/`createdAt` the DTOs never returned (added server-side rather than dropping the columns); `ReasonCodeContext` had `"CountVariance"` where the server says `"Count"`; `EnterCounts` returns the whole `CountDto`, not `{lines}`; `PostCountResponse.adjustmentId` is nullable; `createReorderRequisition` returns a full `Requisition`, not `{qty}`. +- `remove()` → `updateStatus(id, "Inactive")` on brands/categories/item-types, each with a Status column and Deactivate/Activate (no `DELETE` exists — FR-MD-08). +- New `app/dashboard/products/categories/[id]` for subcategories (their own resource now); new `app/dashboard/products/settings` for Product Configuration (added the shadcn `switch` primitive via the CLI). + +**Features deliberately removed rather than left lying** +- **`initialQty`** and the builder's Quantity column — no such field on the Item contract and no initial-receipt flow; stock arrives via a GRN. It never worked under the mock either. +- **GRN edit/delete** + the `grn/[id]/edit` route — the API has no `PUT`/`DELETE` for a GRN (FR-X-05). +- **RFQ "vendors invited"** — not persisted server-side. The screens show quotations received; the quote form offers any active vendor instead of "invited but pending". +- **Serial capture on GRN** — `CreateGrnLineInput` has no serial field despite FR-GRN-04 (priority M). Not collected rather than silently discarded. Flagged in `Backend/PROGRESS.md` + docs/11 §4. + +**Fixed while rewiring:** the builder hardcoded `baseUomId: 1`, which only worked because the mock seeded that id — against a real DB it is a 422 or, worse, the wrong unit. It now adopts the first real UOM and says so when none exists. The per-row create loop still has no transaction, but the error now reports how many items landed before the failure instead of implying none did. + +**Verified end-to-end in a real browser (Playwright), not just typechecked** — 17/17 then 9/9 on a recheck: guard redirect + `?next=` round-trip; login → cookies (`erp_at` httpOnly) → real user in Header; brand created via the UI; **duplicate → server 409 with its own message**; product-config screen reads the singleton; item-types shows the seeded Color/Size; logout clears cookies. Plus, through the page's own session: **cross-FK guard 422** (*"Subcategory 5 belongs to category 10, not 11"*), item created with **both** `categoryId` and `subCategoryId` + `brandId`, `conversions` present on the detail, **`CONFIG_DISABLED` 422** with the same item succeeding without the gated field and pre-existing items still readable, and a stale `If-Match` → **412 `CONCURRENCY_CONFLICT`**. Test data was removed afterwards; the dev DB is back to empty masters. + +> **The DB is near-empty and that is now visible.** The mock silently supplied warehouses, UOMs, reason codes and sample POs. Screens are blank until that data is created — correct behaviour, not a regression, but a dev seed would make the app pleasant to open. `lib/api/mock-data.ts`'s FIFO engine (`receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) is gone with it: **the browser no longer does inventory maths** — the server does. +> +> **Not yet exercised against real data:** GRN confirm → Stock Enquiry/Ledger/Valuation, transfers, counts and the wastage report. They compile and are wired, but proving the FIFO chain needs a warehouse + PO + receipt seeded first. That is the highest-value next verification. + ### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes) - Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface. - Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers. @@ -121,7 +172,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core. - Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation). - Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes. -- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged). +- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend existed at the time (`Backend/PROGRESS.md` §2 unchanged). **Superseded 2026-07-17** — the Procurement backend exists and these screens now call it; several assumed shapes turned out wrong (see the 2026-07-17 entry). - Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server. ### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes) @@ -132,3 +183,19 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact. - Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged). - Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port). + +### 2026-07-15 — Categories/Brands pagination, Item variant builder (Category→Subcategory→Brand→Color/Size), Variant Categories master (frontend-only; no backend changes) +- **Pagination:** `categoriesApi.list()`/`brandsApi.list()` (`lib/api/categories.ts`/`lib/api/brands.ts`) changed from returning everything on one page to real `page`/`pageSize`/`q`/`sortOrder` filtering+slicing (page size 5), matching the Vendor list's existing pattern. Both list screens gained debounced search + Previous/Next controls with a "Showing X–Y of Z" caption. **Follow-on fix:** the `new/page.tsx` item-create form and anywhere else fetching the full category/brand list for a `` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"|"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`. +- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change. diff --git a/Frontend/erp-system/.env.local.example b/Frontend/erp-system/.env.local.example new file mode 100644 index 0000000..7c31eb9 --- /dev/null +++ b/Frontend/erp-system/.env.local.example @@ -0,0 +1,7 @@ +# Origin of the ERPCore backend. Used ONLY by the Next rewrite proxy in next.config.ts +# (server-side), so it is intentionally not NEXT_PUBLIC_* — the browser never sees it and +# only ever calls this Next server at same-origin /api/v1. +# +# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the +# proxy will refuse. +BACKEND_ORIGIN=http://localhost:5224 diff --git a/Frontend/erp-system/.gitignore b/Frontend/erp-system/.gitignore index 5ef6a52..2b411dd 100644 --- a/Frontend/erp-system/.gitignore +++ b/Frontend/erp-system/.gitignore @@ -32,6 +32,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# ...except the template, which carries no secrets and documents what to set. +!.env.local.example # vercel .vercel diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 49ec7f9..55e4289 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -2,6 +2,7 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar" import { Header } from "@/components/Layouts/Header" import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs" import { Toaster } from "@/components/ui/toast" +import { AuthProvider } from "@/components/auth/AuthProvider" export default function DashboardLayout({ children, @@ -9,22 +10,24 @@ export default function DashboardLayout({ children: React.ReactNode }) { return ( -
- -
-
-
-
- -
-
- {children} + +
+ +
+
+
+
+ +
+
+ {children} +
-
-
- -
+ + + + ) } diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx index dd9f1ad..cc3a0c4 100644 --- a/Frontend/erp-system/app/dashboard/procurement/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -18,7 +18,7 @@ const areas: { title: string; description: string; href: string; icon: LucideIco }, { title: "Purchase Orders", - description: "Auto-approved on creation, freely editable while open, cancellable before receipt.", + description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart, }, diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index a205486..cf3db2a 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react" +import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react" import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" import { warehousesApi } from "@/lib/api/warehouses" @@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() { const [showCancelForm, setShowCancelForm] = useState(false) const [cancelReason, setCancelReason] = useState("") const [cancelling, setCancelling] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [deleting, setDeleting] = useState(false) function toDraftLines(order: PurchaseOrder): DraftLine[] { return order.lines.map((l) => ({ @@ -185,6 +187,39 @@ export default function PurchaseOrderDetailPage() { } } + async function handleSubmitPo() { + if (!po) return + setSaveError(null) + setSubmitting(true) + try { + const updated = await purchaseOrdersApi.submit(po.poId) + setPo(updated) + setLines(toDraftLines(updated)) + toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`) + } catch (err) { + setSaveError(errorMessage(err)) + toast.error("Could not submit purchase order", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete() { + if (!po) return + if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return + setSaveError(null) + setDeleting(true) + try { + await purchaseOrdersApi.remove(po.poId) + toast.success("Draft deleted", po.docNo) + router.push("/dashboard/procurement/purchase-orders") + } catch (err) { + setSaveError(errorMessage(err)) + toast.error("Could not delete purchase order", errorMessage(err)) + setDeleting(false) + } + } + async function handleCancel() { if (!po) return if (!cancelReason.trim()) { @@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() { const editable = isPoEditable(po.status) && !conflict const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) + // A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason; + // a Draft is deleted instead, and closed/cancelled POs are terminal. + const cancellable = po.status === "Approved" || po.status === "PartiallyReceived" return (
@@ -245,18 +283,32 @@ export default function PurchaseOrderDetailPage() {
- {isPoEditable(po.status) && !showCancelForm && ( - - )} +
+ {po.status === "Draft" && ( + <> + + + + )} + {cancellable && !showCancelForm && ( + + )} +
{showCancelForm && ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx index 06d58ae..411799d 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -43,8 +43,12 @@ function newKey() { return `poline-${keySeq}` } +// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN +// receipt (with discount/VAT there). They default to 0 here and stay off the form, but +// remain on the payload because the backend line DTO still requires them; a PO prefilled +// from an RFQ keeps its negotiated price (below). function emptyLine(): DraftLine { - return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" } + return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" } } function NewPurchaseOrderContent() { @@ -98,8 +102,8 @@ function NewPurchaseOrderContent() { uomId: null, warehouseId: null, qty: String(l.qty), - unitPrice: "", - tax: "0.18", + unitPrice: "0", + tax: "0", }) ) ) @@ -115,15 +119,17 @@ function NewPurchaseOrderContent() { setVendorId(rfqVendorId) setLines( rfq.lines.map((l): DraftLine => { - const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) + const cell = comparison.rows + .find((row) => row.itemId === l.itemId) + ?.quotes.find((q) => q.vendorId === rfqVendorId) return { key: newKey(), itemId: l.itemId, uomId: null, warehouseId: null, qty: String(l.qty), - unitPrice: cell ? String(cell.unitPrice) : "", - tax: "0.18", + unitPrice: cell ? String(cell.unitPrice) : "0", + tax: "0", } }) ) @@ -149,7 +155,7 @@ function NewPurchaseOrderContent() { return items?.find((i) => i.itemId === itemId) ?? null } - async function handleSubmit() { + async function handleSubmit(saveAsDraft: boolean) { setHeaderError(null) setSubmitError(null) @@ -195,8 +201,12 @@ function NewPurchaseOrderContent() { vendorId, requisitionId: requisitionId ?? (rfqId ? undefined : null), lines: payloadLines, + saveAsDraft, }) - toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`) + toast.success( + "Purchase order created", + saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).` + ) router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) } catch (err) { setSubmitError(errorMessage(err)) @@ -272,8 +282,6 @@ function NewPurchaseOrderContent() { UOM Warehouse Qty - Unit price - Tax @@ -346,30 +354,6 @@ function NewPurchaseOrderContent() { /> - - updateLine(line.key, { unitPrice: e.target.value })} - className="h-11 text-base" - /> - - - - updateLine(line.key, { tax: e.target.value })} - className="h-11 text-base" - /> - - + diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx index a60d775..ea3de47 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -69,11 +69,22 @@ export default function RfqDetailPage() { const quotedVendorIds = useMemo(() => { const set = new Set() - for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId) + for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId) return set }, [comparison]) - const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds]) + /** + * Vendors still available to quote. + * + * This used to be "invited but not yet quoted", but the invited list does not survive: + * `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor + * link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the + * comparison's columns come from who actually quoted rather than who was asked. + */ + const pendingVendors = useMemo( + () => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId), + [vendors, quotedVendorIds], + ) function itemFor(itemId: number) { return items.find((i) => i.itemId === itemId) @@ -155,9 +166,9 @@ export default function RfqDetailPage() {

{rfq.docNo}

+ {/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}

- {rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""} - Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")} + {rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}

@@ -191,7 +202,7 @@ export default function RfqDetailPage() {

Vendor comparison

- {comparison.lines.every((l) => l.cells.length === 0) ? ( + {comparison.rows.every((r) => r.quotes.length === 0) ? (

No quotations recorded yet.

) : (
@@ -199,19 +210,20 @@ export default function RfqDetailPage() { Item - {rfq.vendorIds.map((vid) => ( + {/* Columns are the vendors that actually quoted — the server computes this. */} + {comparison.vendorIds.map((vid) => ( {vendorFor(vid)?.code ?? `#${vid}`} ))} - {comparison.lines.map((line) => { + {comparison.rows.map((line) => { const item = itemFor(line.itemId) return ( {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} - {rfq.vendorIds.map((vid) => { - const cell = line.cells.find((c) => c.vendorId === vid) + {comparison.vendorIds.map((vid) => { + const cell = line.quotes.find((c) => c.vendorId === vid) return ( {cell ? ( @@ -256,7 +268,7 @@ export default function RfqDetailPage() { value={quoteVendorId} onValueChange={selectQuoteVendor}> - + {pendingVendors.map((vid) => ( diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx index ee05136..cfb8bcb 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -99,6 +99,12 @@ function NewRfqContent() { setHeaderError(null) setSubmitError(null) + // The server requires a requisition — an RFQ is always raised against one + // (docs/11 §3.2). Catch it here rather than letting the POST 400. + if (requisitionId === null) { + setHeaderError("Select the requisition this RFQ is raised against.") + return + } if (vendorIds.size === 0) { setHeaderError("Select at least one vendor to invite.") return diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx index 6cc1f9c..b4d9e79 100644 --- a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -5,10 +5,8 @@ import Link from "next/link" import { FileText, Plus } from "lucide-react" import { rfqsApi } from "@/lib/api/rfqs" -import { vendorsApi } from "@/lib/api/vendors" import { errorMessage } from "@/lib/error-map" import { RfqSummary } from "@/types/procurement" -import { Vendor } from "@/types/master-data" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges" export default function RfqsListPage() { const [rfqs, setRfqs] = useState(null) - const [vendors, setVendors] = useState([]) const [error, setError] = useState(null) + // Vendors are no longer fetched here: the "invited vendors" column is gone because that + // list is not persisted (docs/11 §3.2), so there is nothing to resolve names for. useEffect(() => { - Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) - .then(([r, v]) => { - setRfqs(r.items) - setVendors(v.items) - }) + rfqsApi + .list() + .then((r) => setRfqs(r.items)) .catch((err) => setError(errorMessage(err))) }, []) - function vendorNames(vendorIds: number[]) { - return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") - } - return (
@@ -75,9 +68,11 @@ export default function RfqsListPage() { Doc No Requisition - Vendors invited + {/* "Vendors invited" is gone: the invite list is validated on create but not + persisted (docs/11 §3.2). Quotations received is the fact that survives. */} + Lines + Quotations Status - Created @@ -89,11 +84,11 @@ export default function RfqsListPage() { {r.requisitionId ? `#${r.requisitionId}` : } - {vendorNames(r.vendorIds)} + {r.lineCount} + {r.quotationCount} - {new Date(r.createdAt).toLocaleString()} ))} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 94cd29d..63ff5ab 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -3,17 +3,16 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react" +import { AlertTriangle, ArrowLeft, Save } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" import { uomsApi } from "@/lib/api/uoms" -import { vendorsApi } from "@/lib/api/vendors" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" +import { validateItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data" +import { Item, StockNature, TrackingMode } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -21,30 +20,9 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Skeleton } from "@/components/ui/skeleton" import { toast } from "@/components/ui/toast" -interface ReorderDraft { - key: string - warehouseId: number | null - reorderPoint: string - reorderQty: string -} - -interface ConversionDraft { - key: string - fromUom: number | null - toUom: number | null - factor: string -} - -let keySeq = 0 -function newKey() { - keySeq += 1 - return `row-${keySeq}` -} - export default function ItemDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() @@ -54,20 +32,29 @@ export default function ItemDetailPage() { const [etag, setEtag] = useState(null) const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) - const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([]) const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [loadError, setLoadError] = useState(null) // Basic info form const [sku, setSku] = useState("") const [name, setName] = useState("") + // No longer editable here — carried through unchanged so a save doesn't silently clear it. const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) + // Carried through edits so a save doesn't silently drop the item's subcategory/brand. + // Not editable here — they are chosen on the create screen's builder. + const [subCategoryId, setSubCategoryId] = useState(null) + const [brandId, setBrandId] = useState(null) const [baseUomId, setBaseUomId] = useState(null) + const [stockNature, setStockNature] = useState("Stocked") + // Default vendor, tax class, and tracking mode are no longer editable on this page — + // carried through unchanged (from the loaded item) so a save doesn't silently clear them. const [defaultVendorId, setDefaultVendorId] = useState(null) - const [itemType, setItemType] = useState("Stocked") const [trackingMode, setTrackingMode] = useState("None") const [taxClass, setTaxClass] = useState("") + // Frontend-only: there's no warehouse field anywhere on the Item contract, so this + // isn't sent on save — nothing to wire it to server-side. + const [warehouseId, setWarehouseId] = useState(null) const [errors, setErrors] = useState>({}) const [conflict, setConflict] = useState(false) @@ -75,31 +62,19 @@ export default function ItemDetailPage() { const [saving, setSaving] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false) - // Reorder settings - const [reorderLines, setReorderLines] = useState([]) - const [reorderErrors, setReorderErrors] = useState>>({}) - const [reorderSaveError, setReorderSaveError] = useState(null) - const [savingReorder, setSavingReorder] = useState(false) - - // UOM conversions - const [conversionLines, setConversionLines] = useState([]) - const [conversionErrors, setConversionErrors] = useState>>({}) - const [conversionSaveError, setConversionSaveError] = useState(null) - const [savingConversions, setSavingConversions] = useState(false) - function applyItem(data: Item) { setItem(data) setSku(data.sku) setName(data.name) setDescription(data.description ?? "") setCategoryId(data.categoryId) + setSubCategoryId(data.subCategoryId) + setBrandId(data.brandId) setBaseUomId(data.baseUomId) setDefaultVendorId(data.defaultVendorId) - setItemType(data.itemType) + setStockNature(data.stockNature) setTrackingMode(data.trackingMode) setTaxClass(data.taxClass ?? "") - setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) }))) - setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) } function load() { @@ -117,11 +92,10 @@ export default function ItemDetailPage() { useEffect(() => { if (!Number.isFinite(itemId)) return load() - Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()]) - .then(([cat, uo, ve, wh]) => { + Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })]) + .then(([cat, uo, wh]) => { setCategories(cat.items) setUoms(uo.items) - setVendors(ve.items) setWarehouses(wh.items) }) .catch(() => {}) @@ -139,7 +113,7 @@ export default function ItemDetailPage() { try { const result = await itemsApi.update( item.itemId, - { sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null }, + { sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null }, etag ) applyItem(result.data) @@ -177,83 +151,6 @@ export default function ItemDetailPage() { } } - function updateReorderLine(key: string, patch: Partial) { - setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) - } - function removeReorderLine(key: string) { - setReorderLines((prev) => prev.filter((l) => l.key !== key)) - } - - async function handleSaveReorder() { - if (!item) return - setReorderSaveError(null) - const nextErrors: Record> = {} - for (const line of reorderLines) { - const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty }) - if (Object.keys(errs).length > 0) nextErrors[line.key] = errs - } - setReorderErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - setReorderSaveError("Fix the highlighted rows before saving.") - return - } - - const settings: ItemReorderSetting[] = reorderLines.map((l) => ({ - warehouseId: l.warehouseId as number, - reorderPoint: Number(l.reorderPoint), - reorderQty: Number(l.reorderQty), - })) - - setSavingReorder(true) - try { - const result = await itemsApi.updateReorder(item.itemId, { settings }) - setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev)) - toast.success("Reorder settings saved") - } catch (err) { - setReorderSaveError(errorMessage(err)) - toast.error("Could not save reorder settings", errorMessage(err)) - } finally { - setSavingReorder(false) - } - } - - function updateConversionLine(key: string, patch: Partial) { - setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) - } - function removeConversionLine(key: string) { - setConversionLines((prev) => prev.filter((l) => l.key !== key)) - } - - async function handleSaveConversions() { - if (!item) return - setConversionSaveError(null) - const nextErrors: Record> = {} - for (const line of conversionLines) { - const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor }) - if (Object.keys(errs).length > 0) nextErrors[line.key] = errs - } - setConversionErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - setConversionSaveError("Fix the highlighted rows before saving.") - return - } - - const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) })) - - setSavingConversions(true) - try { - const result = await itemsApi.updateUomConversions(item.itemId, { conversions }) - setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev)) - setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) - toast.success("UOM conversions saved") - } catch (err) { - setConversionSaveError(errorMessage(err)) - toast.error("Could not save UOM conversions", errorMessage(err)) - } finally { - setSavingConversions(false) - } - } - function uomName(uomId: number) { return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` } @@ -333,13 +230,14 @@ export default function ItemDetailPage() { setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
-
- - setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} /> -
- value={categoryId} onValueChange={setCategoryId} disabled={conflict}> + + value={categoryId} + onValueChange={setCategoryId} + disabled={conflict} + items={categories.map((c) => ({ label: c.name, value: c.categoryId }))} + > @@ -355,7 +253,12 @@ export default function ItemDetailPage() {
- value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> + + value={baseUomId} + onValueChange={setBaseUomId} + disabled={conflict} + items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} + > @@ -370,27 +273,10 @@ export default function ItemDetailPage() {
- - value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}> - - - - - {vendors.map((v) => ( - - {v.code} — {v.name} - - ))} - - -
-
- - setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} /> -
-
- - value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}> + {/* "Item type" now means a Color/Size dimension master — this field is the + stock-nature one it used to be confused with (docs/11 §8). */} + + value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}> @@ -402,15 +288,22 @@ export default function ItemDetailPage() {
- - value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}> + + + value={warehouseId} + onValueChange={setWarehouseId} + disabled={conflict} + items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} + > - + - None - Batch - Serial + {warehouses.map((w) => ( + + {w.code} — {w.name} + + ))}
@@ -426,165 +319,8 @@ export default function ItemDetailPage() {
-
-
-
-

Reorder settings

-

Per-warehouse reorder point and quantity (FR-MD-05).

-
- -
- - {reorderLines.length > 0 && ( - - - - Warehouse - Reorder point - Reorder qty - - - - - {reorderLines.map((line) => { - const errs = reorderErrors[line.key] ?? {} - return ( - - - value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}> - - - - - {warehouses.map((w) => ( - - {w.code} - - ))} - - - - - - updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" /> - - - - updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" /> - - - - - - - ) - })} - -
- )} - - {reorderSaveError && ( -
{reorderSaveError}
- )} - -
- -
-
- -
-
-
-

UOM conversions

-

Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).

-
- -
- - {conversionLines.length > 0 && ( - - - - From UOM - To UOM - Factor - - - - - {conversionLines.map((line) => { - const errs = conversionErrors[line.key] ?? {} - return ( - - - value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}> - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - - - - - - value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}> - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - - - - - - updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" /> - - - - - - - ) - })} - -
- )} - - {conversionSaveError && ( -
{conversionSaveError}
- )} - -
- -
-
-

- {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."} + {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).

) diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx new file mode 100644 index 0000000..4b83c0f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -0,0 +1,383 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" + +import { brandsApi } from "@/lib/api/brands" +import { errorMessage } from "@/lib/error-map" +import { validateBrandName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { EntityStatus, PaginationMeta } from "@/types/common" +import { Brand } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +type SortOrder = "asc" | "desc" +type SortKey = "brandId" | "name" | "status" | "createdAt" +type StatusFilter = EntityStatus | "All" + +const PAGE_SIZE = 5 + +export default function BrandsPage() { + const [brands, setBrands] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + // Sorts only the currently loaded page client-side — the backend ignores `sort` and + // always returns Name ascending, so this doesn't hold across page turns or other columns. + const [sortKey, setSortKey] = useState("name") + const [sortOrder, setSortOrder] = useState("asc") + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + const timeout = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => { + setPage(1) + }, [search, status]) + + function load() { + setError(null) + brandsApi + .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE }) + .then((res) => { + setBrands(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [search, status, page]) + + const hasFilters = search.trim().length > 0 || status !== "All" + + const sortedBrands = brands + ? [...brands].sort((a, b) => { + const cmp = + sortKey === "brandId" + ? a.brandId - b.brandId + : sortKey === "createdAt" + ? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + : a[sortKey].localeCompare(b[sortKey]) + return sortOrder === "asc" ? cmp : -cmp + }) + : null + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortOrder((o) => (o === "asc" ? "desc" : "asc")) + } else { + setSortKey(key) + setSortOrder("asc") + } + } + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(brand: Brand) { + setEditing(brand) + setName(brand.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateBrandName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + let result + if (editing) { + // The list response carries no ETag, so re-read to get a fresh If-Match token + // rather than guessing one. A concurrent edit surfaces as 412 from the server. + const current = await brandsApi.get(editing.brandId) + result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "") + } else { + result = await brandsApi.create({ name }) + } + toast.success(editing ? "Brand updated" : "Brand created", result.data.name) + setOpen(false) + setName("") + setEditing(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(brand: Brand) { + const next = brand.status === "Active" ? "Inactive" : "Active" + setDeletingId(brand.brandId) + try { + await brandsApi.updateStatus(brand.brandId, next) + toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name) + load() + } catch (err) { + toast.error("Could not update brand status", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + + return ( +
+
+
+ + + +
+

Brands

+

Manage product brands.

+
+
+ + + Add Brand} /> + + + {editing ? "Edit brand" : "New brand"} + Give the brand a name. + + + + Name + setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search brands…" + className="h-14 w-full pl-11 text-base" + aria-label="Search brands" + /> +
+ value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {error && ( +
{error}
+ )} + + {!error && brands === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && brands !== null && brands.length === 0 && ( +
+ +

+ {hasFilters ? "No brands match your search/filter." : "No brands yet."} +

+
+ )} + + {!error && brands !== null && brands.length > 0 && ( + <> + + + + + toggleSort("brandId")} /> + + + toggleSort("name")} /> + + + toggleSort("status")} /> + + + toggleSort("createdAt")} /> + + Actions + + + + {sortedBrands!.map((b) => ( + + #{b.brandId} + {b.name} + + {b.status} + + {new Date(b.createdAt).toLocaleDateString()} + +
+ + + {/* Deactivate, not delete: the API has no DELETE for any master + (FR-MD-08) — records referenced by transactions must survive. */} + + + } + > + {b.status === "Active" ? : } + + handleToggleStatus(b)} + /> + +
+
+
+ ))} +
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + + )} +
+ ) +} + +function SortableHeader({ + label, + active, + order, + onClick, +}: { + label: string + active: boolean + order: SortOrder + onClick: () => void +}) { + return ( + + ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx new file mode 100644 index 0000000..c41f9a9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx @@ -0,0 +1,233 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useParams } from "next/navigation" +import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react" + +import { categoriesApi, subCategoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { validateCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Category, SubCategory } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +/** + * Subcategories of one category — the single optional level below it (FR-MD-04). + * The hierarchy is exactly two deep, so there is no recursion here by design. + */ +export default function CategorySubCategoriesPage() { + const params = useParams<{ id: string }>() + const categoryId = Number(params.id) + + const [category, setCategory] = useState(null) + const [subCategories, setSubCategories] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [togglingId, setTogglingId] = useState(null) + + function load() { + setError(null) + categoriesApi + .get(categoryId) + .then((res) => setCategory(res.data)) + .catch((err) => setError(errorMessage(err))) + categoriesApi + .listSubCategories(categoryId, { pageSize: 200 }) + .then((res) => setSubCategories(res.items)) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [categoryId]) + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(sub: SubCategory) { + setEditing(sub) + setName(sub.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + if (editing) { + // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. + const current = await subCategoriesApi.get(editing.subCategoryId) + await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "") + } else { + await categoriesApi.createSubCategory(categoryId, { name }) + } + toast.success(editing ? "Subcategory updated" : "Subcategory created", name) + setOpen(false) + setName("") + setEditing(null) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleToggleStatus(sub: SubCategory) { + const next = sub.status === "Active" ? "Inactive" : "Active" + setTogglingId(sub.subCategoryId) + try { + await subCategoriesApi.updateStatus(sub.subCategoryId, next) + toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name) + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingId(null) + } + } + + return ( +
+
+
+ + + +
+

{category ? `${category.name} — Subcategories` : "Subcategories"}

+

+ The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category. +

+
+
+ + + New Subcategory} /> + + + {editing ? "Edit subcategory" : "New subcategory"} + Give the subcategory a name. + + + + Name + setName(e.target.value)} placeholder="Hex Bolts" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && subCategories === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && subCategories !== null && subCategories.length === 0 && ( +
+ +

No subcategories yet — items can attach straight to the category.

+
+ )} + + {!error && subCategories !== null && subCategories.length > 0 && ( + + + + ID + Name + Status + Created At + Actions + + + + {subCategories.map((s) => ( + + #{s.subCategoryId} + {s.name} + + {s.status} + + {new Date(s.createdAt).toLocaleDateString()} + +
+ + + + + } + > + {s.status === "Active" ? : } + + handleToggleStatus(s)} + /> + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 19037b4..d11549e 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,85 +2,156 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ListTree, Plus } from "lucide-react" +import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" import { validateCategoryName } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Category, CategoryTreeNode } from "@/types/master-data" +import { EntityStatus, PaginationMeta } from "@/types/common" +import { Category } from "@/types/master-data" +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) { - return ( -
-
- - {node.name} - #{node.categoryId} -
- {node.children.map((child) => ( - - ))} -
- ) -} +type SortOrder = "asc" | "desc" +type SortKey = "categoryId" | "name" | "status" | "createdAt" +type StatusFilter = EntityStatus | "All" + +const PAGE_SIZE = 5 export default function CategoriesPage() { - const [tree, setTree] = useState(null) - const [flat, setFlat] = useState([]) + const [categories, setCategories] = useState(null) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + // Sorts only the currently loaded page client-side — the backend ignores `sort` and + // always returns Name ascending, so this doesn't hold across page turns or other columns. + const [sortKey, setSortKey] = useState("name") + const [sortOrder, setSortOrder] = useState("asc") + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) const [name, setName] = useState("") - const [parentId, setParentId] = useState(null) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + const timeout = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => { + setPage(1) + }, [search, status]) function load() { setError(null) - Promise.all([categoriesApi.tree(), categoriesApi.list()]) - .then(([t, f]) => { - setTree(t) - setFlat(f.items) + categoriesApi + .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE }) + .then((res) => { + setCategories(res.items) + setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) } - useEffect(load, []) + useEffect(load, [search, status, page]) - async function handleCreate() { + const hasFilters = search.trim().length > 0 || status !== "All" + + const sortedCategories = categories + ? [...categories].sort((a, b) => { + const cmp = + sortKey === "categoryId" + ? a.categoryId - b.categoryId + : sortKey === "createdAt" + ? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + : a[sortKey].localeCompare(b[sortKey]) + return sortOrder === "asc" ? cmp : -cmp + }) + : null + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortOrder((o) => (o === "asc" ? "desc" : "asc")) + } else { + setSortKey(key) + setSortOrder("asc") + } + } + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(category: Category) { + setEditing(category) + setName(category.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { const nextErrors = validateCategoryName(name) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const category = await categoriesApi.create({ name, parentId }) - toast.success("Category created", category.name) + let result + if (editing) { + // The list carries no ETag, so re-read for a fresh If-Match rather than guessing. + const current = await categoriesApi.get(editing.categoryId) + result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "") + } else { + result = await categoriesApi.create({ name }) + } + toast.success(editing ? "Category updated" : "Category created", result.data.name) setOpen(false) setName("") - setParentId(null) + setEditing(null) setErrors({}) load() } catch (err) { setErrors({ name: errorMessage(err) }) - toast.error("Could not create category", errorMessage(err)) + toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) } finally { setSubmitting(false) } } + /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(category: Category) { + const next = category.status === "Active" ? "Inactive" : "Active" + setDeletingId(category.categoryId) + try { + await categoriesApi.updateStatus(category.categoryId, next) + toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name) + load() + } catch (err) { + toast.error("Could not update category status", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + return (
@@ -90,16 +161,16 @@ export default function CategoriesPage() {

Categories

-

Hierarchical item category structure (FR-MD-04).

+

Item category master (FR-MD-04).

- New Category} /> + New Category} /> - New category - Optionally nest it under an existing category. + {editing ? "Edit category" : "New category"} + Give the category a name. @@ -107,60 +178,213 @@ export default function CategoriesPage() { setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> - - Parent (optional) - value={parentId} onValueChange={setParentId}> - - - - - {flat.map((c) => ( - - {c.name} - - ))} - - -
-
+
+
+ + setSearchInput(e.target.value)} + placeholder="Search categories…" + className="h-14 w-full pl-11 text-base" + aria-label="Search categories" + /> +
+ value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ {error && (
{error}
)} - {!error && tree === null && ( + {!error && categories === null && (
{Array.from({ length: 3 }).map((_, i) => ( - + ))}
)} - {!error && tree !== null && tree.length === 0 && ( + {!error && categories !== null && categories.length === 0 && (
-

No categories yet.

+

+ {hasFilters ? "No categories match your search/filter." : "No categories yet."} +

)} - {!error && tree !== null && tree.length > 0 && ( -
- {tree.map((node) => ( - - ))} -
+ {!error && categories !== null && categories.length > 0 && ( + <> + + + + + toggleSort("categoryId")} /> + + + toggleSort("name")} /> + + + toggleSort("status")} /> + + + toggleSort("createdAt")} /> + + Actions + + + + {sortedCategories!.map((c) => ( + + #{c.categoryId} + {c.name} + + {c.status} + + {new Date(c.createdAt).toLocaleDateString()} + +
+ {/* Subcategories are their own resource now, not a nested tree. */} + + + + + + + {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} + + + } + > + {c.status === "Active" ? : } + + handleToggleStatus(c)} + /> + +
+
+
+ ))} +
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + )} ) } + +function SortableHeader({ + label, + active, + order, + onClick, +}: { + label: string + active: boolean + order: SortOrder + onClick: () => void +}) { + return ( + + ) +} diff --git a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx new file mode 100644 index 0000000..71d6d22 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -0,0 +1,236 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react" + +import { itemTypesApi } from "@/lib/api/item-types" +import { errorMessage } from "@/lib/error-map" +import { validateItemTypeName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { ItemType } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +/** + * Item Types (docs/11 §2.7) — the dimension names (Color, Size, Material) the item + * builder's checkboxes read. Formerly "Variant Categories" in this app. + * + * These are names only. The values (Red, S, M) live in each item's generated SKU and are + * not stored, so nothing here links to an item — renaming a type leaves existing SKUs + * untouched. + */ +export default function ItemTypesPage() { + const [itemTypes, setItemTypes] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [togglingId, setTogglingId] = useState(null) + + function load() { + setError(null) + itemTypesApi + .list({ pageSize: 200 }) + .then((res) => setItemTypes(res.items)) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(itemType: ItemType) { + setEditing(itemType) + setName(itemType.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateItemTypeName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + if (editing) { + // Re-read for a fresh If-Match; a concurrent edit surfaces as 412. + const current = await itemTypesApi.get(editing.itemTypeId) + await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "") + } else { + await itemTypesApi.create({ name }) + } + toast.success(editing ? "Item type updated" : "Item type created", name) + setOpen(false) + setName("") + setEditing(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + /** Deactivate, never delete — the API has no DELETE (FR-MD-08). */ + async function handleToggleStatus(itemType: ItemType) { + const next = itemType.status === "Active" ? "Inactive" : "Active" + setTogglingId(itemType.itemTypeId) + try { + await itemTypesApi.updateStatus(itemType.itemTypeId, next) + toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name) + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingId(null) + } + } + + return ( +
+
+
+ + + +
+

Item Types

+

+ Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU. +

+
+
+ + + Add Item Type} /> + + + {editing ? "Edit item type" : "New item type"} + Give the item type a name. + + + + Name + setName(e.target.value)} + placeholder="Material" + aria-invalid={!!errors.name} + /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && itemTypes === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && itemTypes !== null && itemTypes.length === 0 && ( +
+ +

No item types yet.

+
+ )} + + {!error && itemTypes !== null && itemTypes.length > 0 && ( + + + + ID + Name + Status + Created At + Actions + + + + {itemTypes.map((t) => ( + + #{t.itemTypeId} + {t.name} + + {t.status} + + {new Date(t.createdAt).toLocaleDateString()} + +
+ + + + + } + > + {t.status === "Active" ? : } + + handleToggleStatus(t)} + /> + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index eda1169..d3b99e5 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -1,91 +1,239 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import { ArrowLeft, Plus, X } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" +import { brandsApi } from "@/lib/api/brands" +import { itemTypesApi } from "@/lib/api/item-types" +import { productConfig } from "@/lib/api/product-config" import { uomsApi } from "@/lib/api/uoms" -import { vendorsApi } from "@/lib/api/vendors" -import { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateItemForm } from "@/lib/validations/master-data" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { validateVariantItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { ItemType, TrackingMode } from "@/types/master-data" +import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +function skuSegment(text: string, maxLen: number): string { + const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "") + return cleaned.slice(0, maxLen) || "GEN" +} + +function buildVariantSku(categoryLabel: string, values: string[]): string { + return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") +} + +/** The item builder only ever offers these two dimensions, regardless of what else exists + * in the Item Types master list. */ +const BUILDER_ITEM_TYPES = ["color", "size"] +function isBuilderItemType(name: string): boolean { + return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase()) +} + export default function NewItemPage() { const router = useRouter() - const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null) - const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null) - const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null) + const [categories, setCategories] = useState(null) + const [brands, setBrands] = useState(null) + const [itemTypes, setItemTypes] = useState(null) + const [config, setConfig] = useState(null) + const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) + /** Defaults to the first UOM once loaded; null only means none exist yet. */ + const [baseUomId, setBaseUomId] = useState(null) + const [stockNature, setStockNature] = useState("Stocked") const [loadError, setLoadError] = useState(null) - const [sku, setSku] = useState("") - const [name, setName] = useState("") - const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) - const [baseUomId, setBaseUomId] = useState(null) - const [defaultVendorId, setDefaultVendorId] = useState(null) - const [itemType, setItemType] = useState("Stocked") - const [trackingMode, setTrackingMode] = useState("None") - const [taxClass, setTaxClass] = useState("STD") + const [subCategories, setSubCategories] = useState([]) + const [subCategoryId, setSubCategoryId] = useState(null) + const [brandId, setBrandId] = useState(null) + // Frontend-only: there's no warehouse field anywhere on the Item contract, so this + // isn't sent on submit — nothing to wire it to server-side. + const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) + const [warehouseId, setWarehouseId] = useState(null) + + const [checkedItemTypeIds, setCheckedItemTypeIds] = useState([]) + const [valuesByCategory, setValuesByCategory] = useState>({}) + const [inputByCategory, setInputByCategory] = useState>({}) + // Lets a specific generated combination be dropped from the preview table before + // submit, without having to remove and re-add the whole value that produced it. + const [removedVariantKeys, setRemovedVariantKeys] = useState>(new Set()) const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })]) - .then(([cat, uo, ve]) => { + Promise.all([ + categoriesApi.list({ pageSize: 200, status: "Active" }), + brandsApi.list({ pageSize: 200, status: "Active" }), + itemTypesApi.list({ pageSize: 200, status: "Active" }), + productConfig(), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + ]) + .then(([cat, br, types, cfg, uoms, wh]) => { setCategories(cat.items) - setUoms(uo.items) - setVendors(ve.items) + setBrands(br.items) + setItemTypes(types.items) + setConfig(cfg) + setUoms(uoms.items) + setBaseUomId(uoms.items[0]?.uomId ?? null) + setWarehouses(wh.items) }) .catch((err) => setLoadError(errorMessage(err))) }, []) + // Subcategories are their own resource now — fetched per category rather than filtered + // out of a flat list by parentId (that column no longer exists). + useEffect(() => { + if (categoryId === null || !config?.subcategoriesEnabled) { + setSubCategories([]) + return + } + categoriesApi + .listSubCategories(categoryId, { pageSize: 200, status: "Active" }) + .then((res) => setSubCategories(res.items)) + .catch(() => setSubCategories([])) + }, [categoryId, config?.subcategoriesEnabled]) + + const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? "" + const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? "" + /** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */ + const effectiveLabel = subCategoryLabel || categoryLabel + const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? "" + + function handleCategoryChange(value: number | null) { + setCategoryId(value) + setSubCategoryId(null) + } + + function toggleItemType(itemTypeId: number) { + setCheckedItemTypeIds((prev) => + prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId] + ) + } + + function addValue(itemTypeId: number) { + const value = (inputByCategory[itemTypeId] ?? "").trim() + if (value) { + setValuesByCategory((prev) => { + const existing = prev[itemTypeId] ?? [] + if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev + return { ...prev, [itemTypeId]: [...existing, value] } + }) + } + setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" })) + } + + function removeValue(itemTypeId: number, value: string) { + setValuesByCategory((prev) => ({ + ...prev, + [itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value), + })) + } + + const activeCategories = useMemo( + () => + (itemTypes ?? []) + .filter((t) => checkedItemTypeIds.includes(t.itemTypeId)) + .map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] })) + .filter((t) => t.values.length > 0), + [itemTypes, checkedItemTypeIds, valuesByCategory] + ) + + const allVariants = useMemo(() => { + if (activeCategories.length === 0) return [] + let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }] + for (const cat of activeCategories) { + const next: typeof combinations = [] + for (const combo of combinations) { + for (const value of cat.values) { + next.push({ + key: combo.key ? `${combo.key}::${value}` : value, + parts: [...combo.parts, { name: cat.name, value }], + }) + } + } + combinations = next + } + return combinations.map((c) => ({ + ...c, + sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)), + })) + }, [activeCategories, effectiveLabel]) + + const variants = useMemo( + () => allVariants.filter((v) => !removedVariantKeys.has(v.key)), + [allVariants, removedVariantKeys] + ) + + function removeVariant(key: string) { + setRemovedVariantKeys((prev) => new Set(prev).add(key)) + } + async function handleSubmit() { setSubmitError(null) - const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return + if (baseUomId === null) { + setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.") + return + } setSubmitting(true) + let created = 0 try { - const { data: item } = await itemsApi.create({ - sku, - name, - description: description || null, - categoryId: categoryId as number, - baseUomId: baseUomId as number, - defaultVendorId, - itemType, - trackingMode, - taxClass: taxClass || null, - }) - toast.success("Item created", `${item.sku} — ${item.name}`) - router.push(`/dashboard/products/${item.itemId}`) + for (const variant of variants) { + await itemsApi.create({ + sku: variant.sku, + name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`, + // Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the + // category, which lost the parent entirely. The server rejects a mismatched + // pair with 422. + categoryId: categoryId as number, + subCategoryId, + brandId, + baseUomId, + stockNature, + trackingMode: "None", + }) + created += 1 + } + toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`) + router.push("/dashboard/products") } catch (err) { - const fe = fieldErrors(err) - if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) - setSubmitError(errorMessage(err)) - toast.error("Could not create item", errorMessage(err)) + // Each row is its own POST with no transaction, so a failure partway (e.g. a + // duplicate SKU) leaves the earlier rows created. Say so rather than implying + // nothing happened. + const detail = errorMessage(err) + setSubmitError( + created > 0 + ? `${detail} — ${created} item${created === 1 ? "" : "s"} were already created before this failed.` + : detail, + ) + toast.error("Could not create all variants", detail) } finally { setSubmitting(false) } } - const loading = !categories || !uoms || !vendors + const loading = !categories || !brands || !itemTypes || !config return (
@@ -95,7 +243,7 @@ export default function NewItemPage() {

New Item

-

SKU, category, base UOM, item type, and tracking mode (FR-MD-01).

+

Category, subcategory, brand, and item types (FR-MD-01).

@@ -105,26 +253,26 @@ export default function NewItemPage() { {loading && !loadError && } + {!loading && baseUomId === null && ( +
+ No unit of measure exists yet. Items need a base UOM —{" "} + + create one first + + . +
+ )} + {!loading && ( <>
-
- - setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" /> - -
-
- - setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" /> - -
-
- - setDescription(e.target.value)} className="h-12 text-base" /> -
- value={categoryId} onValueChange={setCategoryId}> + + value={categoryId} + onValueChange={handleCategoryChange} + items={(categories ?? []).map((c) => ({ label: c.name, value: c.categoryId }))} + > @@ -138,44 +286,92 @@ export default function NewItemPage() {
+ {/* Config flags are honoured by hiding the field: sending a gated value would + just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */} + {config?.subcategoriesEnabled && ( +
+ + + value={subCategoryId} + onValueChange={setSubCategoryId} + disabled={subCategories.length === 0} + items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))} + > + + + + + {subCategories.map((s) => ( + + {s.name} + + ))} + + +
+ )} + {config?.brandsEnabled && ( +
+ + + value={brandId} + onValueChange={setBrandId} + items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} + > + + + + + {(brands ?? []).map((b) => ( + + {b.name} + + ))} + + +
+ )} +
+ + + value={warehouseId} + onValueChange={setWarehouseId} + items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} + > + + + + + {warehouses.map((w) => ( + + {w.code} — {w.name} + + ))} + + +
- value={baseUomId} onValueChange={setBaseUomId}> - + + value={baseUomId} + onValueChange={setBaseUomId} + items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} + > + - {(uoms ?? []).map((u) => ( + {uoms.map((u) => ( {u.name} ))} -
- - value={defaultVendorId} onValueChange={setDefaultVendorId}> - - - - - {(vendors ?? []).map((v) => ( - - {v.code} — {v.name} - - ))} - - -
-
- - setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> -
-
- - value={itemType} onValueChange={(v) => v && setItemType(v)}> + + value={stockNature} onValueChange={(v) => v && setStockNature(v)}> @@ -186,21 +382,130 @@ export default function NewItemPage() {
-
- - value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> - - - - - None - Batch - Serial - - -
+ {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no + item-type reference), so this section IS the enforcement. */} + {config?.itemTypesEnabled && ( +
+
+

Item types

+

+ Check the item types that apply, then add their values to generate a SKU per combination. +

+
+ +
+ {(itemTypes ?? []) + .filter((t) => isBuilderItemType(t.name)) + .map((t) => ( + + ))} +
+ + + + {checkedItemTypeIds.length > 0 && ( +
+ {(itemTypes ?? []) + .filter((t) => checkedItemTypeIds.includes(t.itemTypeId)) + .map((t) => { + const currentInput = inputByCategory[t.itemTypeId] ?? "" + + return ( +
+ +
+ setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(t.itemTypeId) + } + }} + placeholder={t.name} + className="h-11 text-base" + /> + +
+
+ {(valuesByCategory[t.itemTypeId] ?? []).map((v) => ( + + {v} + + + ))} +
+
+ ) + })} +
+ )} + + {variants.length > 0 && ( +
+ + + + {activeCategories.map((cat) => ( + {cat.name} + ))} + SKU + {/* Quantity column removed 2026-07-17: there is no `initialQty` on the + Item contract and no initial-receipt flow — stock arrives via a GRN. + The input was informational-only under the mock and would now be a + field that silently discards what you type. */} + + + + + {variants.map((variant) => ( + + {variant.parts.map((part, i) => ( + + {part.value} + + ))} + {variant.sku} + + + + + ))} + +
+
+ )} +
+ )} + {submitError && (
{submitError}
)} @@ -210,7 +515,7 @@ export default function NewItemPage() { Cancel diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index ce888b2..e5b4e9e 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -6,10 +6,12 @@ import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Sear import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" +import { brandsApi } from "@/lib/api/brands" +import { productConfig } from "@/lib/api/product-config" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { EntityStatus, PaginationMeta } from "@/types/common" -import { Category, ItemListItem, TrackingMode } from "@/types/master-data" +import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -26,6 +28,9 @@ const PAGE_SIZE = 10 export default function ItemsPage() { const [items, setItems] = useState(null) const [categories, setCategories] = useState([]) + const [subCategories, setSubCategories] = useState([]) + const [brands, setBrands] = useState([]) + const [config, setConfig] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -64,12 +69,33 @@ export default function ItemsPage() { useEffect(load, [page, query, status, categoryId, trackingMode]) useEffect(() => { categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) + brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) + productConfig().then(setConfig).catch(() => {}) }, []) + // No "list all subcategories" endpoint exists — they're scoped per category — so once + // categories are in, fetch each one's subcategories in parallel to build a flat lookup. + useEffect(() => { + if (categories.length === 0) return + Promise.all( + categories.map((c) => + categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] })) + ) + ).then((results) => setSubCategories(results.flatMap((r) => r.items))) + }, [categories]) + function categoryName(id: number) { return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` } + function subCategoryName(id: number) { + return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}` + } + + function brandName(id: number) { + return brands.find((b) => b.brandId === id)?.name ?? `#${id}` + } + const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" return ( @@ -106,7 +132,11 @@ export default function ItemsPage() { aria-label="Search items" /> - value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}> + + value={categoryId} + onValueChange={(v) => setCategoryId(v ?? "All")} + items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]} + > @@ -175,6 +205,8 @@ export default function ItemsPage() { SKU Name Category + {config?.subcategoriesEnabled && Subcategory} + {config?.brandsEnabled && Brand} Type Tracking Status @@ -191,7 +223,17 @@ export default function ItemsPage() {
{item.name} {categoryName(item.categoryId)} - {item.itemType} + {config?.subcategoriesEnabled && ( + + {item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"} + + )} + {config?.brandsEnabled && ( + + {item.brandId !== null ? brandName(item.brandId) : "—"} + + )} + {item.stockNature} {item.trackingMode} (null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(null) + + function load() { + setError(null) + productConfigApi + .get() + .then((res) => { + setConfig(res.data) + setEtag(res.etag) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) { + if (!config) return + setSaving(flag) + try { + // All three flags are always sent — the server rejects a partial body (400), which + // is what stops an omitted flag from silently switching a feature off. + const res = await productConfigApi.update( + { + subcategoriesEnabled: config.subcategoriesEnabled, + brandsEnabled: config.brandsEnabled, + itemTypesEnabled: config.itemTypesEnabled, + [flag]: next, + }, + etag ?? "", + ) + setConfig(res.data) + setEtag(res.etag) + toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`) + } catch (err) { + toast.error("Could not save configuration", errorMessage(err)) + load() // a 412 means someone else changed it — resync rather than retry blind + } finally { + setSaving(null) + } + } + + return ( +
+
+ + + +
+

Product Configuration

+

+ Switch optional product features on or off for this deployment (FR-MD-11). +

+
+
+ + {error && ( +
{error}
+ )} + + {!error && !config && } + + {!error && config && ( +
+
+ +

Product Capabilities

+
+
+ +
+ toggle("subcategoriesEnabled", v)} + /> + + toggle("brandsEnabled", v)} + /> +
+ + {config.updatedAt && ( +

+ Last changed {new Date(config.updatedAt).toLocaleString()} + {config.updatedBy ? ` by user #${config.updatedBy}` : ""}. +

+ )} +
+ )} +
+ ) +} + +const LABELS: Record = { + subcategoriesEnabled: "Subcategories", + brandsEnabled: "Brands", +} + +function ToggleRow({ + label, + description, + checked, + busy, + onChange, +}: { + label: string + description: string + checked: boolean + busy: boolean + onChange: (next: boolean) => void +}) { + return ( +
+ +
+ {label} + {description} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx deleted file mode 100644 index 941f7fe..0000000 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx +++ /dev/null @@ -1,444 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { useParams, useRouter } from "next/navigation" -import Link from "next/link" -import { ArrowLeft, Plus, Trash2 } from "lucide-react" - -import { grnsApi } from "@/lib/api/grns" -import { warehousesApi } from "@/lib/api/warehouses" -import { itemsApi } from "@/lib/api/items" -import { uomsApi } from "@/lib/api/uoms" -import { errorMessage } from "@/lib/error-map" -import { validateLine, splitSerials } from "@/lib/validations/grn" -import { cn } from "@/lib/utils" -import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn" -import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data" - -import { Button, buttonVariants } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { FieldError } from "@/components/ui/field" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { Skeleton } from "@/components/ui/skeleton" -import { toast } from "@/components/ui/toast" - -interface DraftLine { - key: string - poLineId: number | null - itemId: number | null - uomId: number | null - binId: number | null - qty: string - unitCost: string - holdStatus: HoldStatus - batchNo: string - expiryDate: string - serialNumbersText: string -} - -let keySeq = 0 -function newKey() { - keySeq += 1 - return `egline-${keySeq}` -} - -function emptyLine(): DraftLine { - return { - key: newKey(), - poLineId: null, - itemId: null, - uomId: null, - binId: null, - qty: "", - unitCost: "", - holdStatus: "Available", - batchNo: "", - expiryDate: "", - serialNumbersText: "", - } -} - -export default function EditGrnPage() { - const params = useParams<{ id: string }>() - const router = useRouter() - const grnId = Number(params.id) - - const [grn, setGrn] = useState(null) - const [warehouses, setWarehouses] = useState(null) - const [items, setItems] = useState(null) - const [uoms, setUoms] = useState(null) - const [bins, setBins] = useState([]) - const [loadError, setLoadError] = useState(null) - - const [warehouseId, setWarehouseId] = useState(null) - const [lines, setLines] = useState([]) - - const [headerError, setHeaderError] = useState(null) - const [lineErrors, setLineErrors] = useState>>({}) - const [submitError, setSubmitError] = useState(null) - const [submitting, setSubmitting] = useState(false) - - useEffect(() => { - if (!Number.isFinite(grnId)) return - Promise.all([ - grnsApi.get(grnId), - warehousesApi.list(), - itemsApi.list({ pageSize: 200, status: "Active" }), - uomsApi.list(), - ]) - .then(([g, wh, it, uo]) => { - if (g.status !== "Draft") { - setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`) - setGrn(g) - return - } - setGrn(g) - setWarehouses(wh.items) - setItems(it.items) - setUoms(uo.items) - setWarehouseId(g.warehouseId) - setLines( - g.lines.map( - (l): DraftLine => ({ - key: newKey(), - poLineId: l.poLineId, - itemId: l.itemId, - uomId: l.uomId, - binId: l.binId, - qty: String(l.qty), - unitCost: String(l.unitCost), - holdStatus: l.holdStatus, - batchNo: "", - expiryDate: "", - serialNumbersText: "", - }) - ) - ) - }) - .catch((err) => setLoadError(errorMessage(err))) - }, [grnId]) - - useEffect(() => { - if (!warehouseId) { - setBins([]) - return - } - warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([])) - }, [warehouseId]) - - function updateLine(key: string, patch: Partial) { - setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) - } - - function removeLine(key: string) { - setLines((prev) => prev.filter((l) => l.key !== key)) - } - - function itemFor(itemId: number | null) { - return items?.find((i) => i.itemId === itemId) ?? null - } - - async function handleSubmit() { - if (!grn) return - setSubmitError(null) - setHeaderError(null) - - if (!warehouseId) { - setHeaderError("Select a warehouse.") - return - } - if (lines.length === 0) { - setSubmitError("Add at least one line.") - return - } - - const nextLineErrors: Record> = {} - for (const line of lines) { - const errors = validateLine({ - itemId: line.itemId, - uomId: line.uomId, - qty: line.qty, - unitCost: line.unitCost, - trackingMode: itemFor(line.itemId)?.trackingMode ?? null, - batchNo: line.batchNo, - serialNumbersText: line.serialNumbersText, - }) - if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors - } - setLineErrors(nextLineErrors) - if (Object.keys(nextLineErrors).length > 0) { - setSubmitError("Fix the highlighted lines before submitting.") - return - } - - const payloadLines: CreateGrnLineInput[] = lines.map((l) => { - const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None" - return { - poLineId: l.poLineId, - itemId: l.itemId as number, - uomId: l.uomId as number, - binId: l.binId, - qty: Number(l.qty), - unitCost: Number(l.unitCost), - holdStatus: l.holdStatus, - batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null, - serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null, - } - }) - - setSubmitting(true) - try { - const updated = await grnsApi.update(grn.grnId, { - poId: grn.poId, - vendorId: grn.vendorId, - warehouseId: warehouseId as number, - lines: payloadLines, - }) - toast.success("GRN updated", `${updated.docNo} saved.`) - router.push(`/dashboard/receiving/grn/${updated.grnId}`) - } catch (err) { - setSubmitError(errorMessage(err)) - toast.error("Could not update GRN", errorMessage(err)) - } finally { - setSubmitting(false) - } - } - - if (loadError) { - return ( -
-
- - - -

Edit GRN

-
-
{loadError}
-
- ) - } - - const loading = !grn || !warehouses || !items || !uoms - - return ( -
-
- - - -
-

Edit {grn?.docNo ?? "GRN"}

-

Only Draft GRNs can be edited — confirming posts stock layers permanently.

-
-
- - {loading && } - - {!loading && ( - <> -
-
- - value={warehouseId} onValueChange={(v) => setWarehouseId(v)}> - - - - - {(warehouses ?? []).map((w) => ( - - {w.code} — {w.name} - - ))} - - -
-
- - {headerError && ( -
{headerError}
- )} - -
-
-

Lines

- -
- - {lines.length > 0 && ( - - - - Item - UOM - Bin - Qty - Unit cost - Hold status - Batch / Serial - - - - - {lines.map((line) => { - const item = itemFor(line.itemId) - const errors = lineErrors[line.key] ?? {} - return ( - - - value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> - - - - - {(items ?? []).map((i) => ( - - {i.sku} — {i.name} - - ))} - - - - - - value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - - - - value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> - - - - - {bins.map((b) => ( - - {b.code} - - ))} - - - - - updateLine(line.key, { qty: e.target.value })} - className="h-11 text-base" - /> - - - - updateLine(line.key, { unitCost: e.target.value })} - className="h-11 text-base" - /> - - - - value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> - - - - - Available - On hold (inspection) - - - - - {item?.trackingMode === "Batch" && ( -
- updateLine(line.key, { batchNo: e.target.value })} - className="h-9 text-sm" - /> - updateLine(line.key, { expiryDate: e.target.value })} - className="h-9 text-sm" - /> - -
- )} - {item?.trackingMode === "Serial" && ( -
-