feat: add vendor optional fields and configuration hooks

- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -0,0 +1,40 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Access configuration — the default role for people who have none. Both reading and changing it need the
/// <c>settings</c> nav permission (it decides what a brand-new sign-in can do).
/// </summary>
[Route("api/v1/access-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class AccessConfigController : ApiControllerBase
{
private readonly IAccessConfigService _config;
public AccessConfigController(IAccessConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<AccessConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<AccessConfigDto>> Update([FromBody] UpdateAccessConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,6 +1,8 @@
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// the audit trail is required (AR-01 compensating control) and read access is the
/// only way to consume it.
/// </summary>
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
[Route("api/v1/audit-logs")]
public sealed class AuditLogsController : ApiControllerBase
{
@@ -47,8 +47,7 @@ public sealed class AuthController : ControllerBase
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
return Ok(await _roles.GetMeAsync(ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
@@ -25,8 +25,9 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +67,7 @@ public sealed class BranchesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -63,6 +69,7 @@ public sealed class DepartmentsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class DesignationsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
// Any HR role may list employees (the leave and payroll screens pick from it); everything else about a person
// - the record itself, documents, pay data - is behind the Employees (or Payroll) permission, endpoint by endpoint.
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -83,6 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +102,7 @@ public sealed class EmployeesController : ApiControllerBase
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -103,6 +113,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -113,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -122,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -140,6 +154,7 @@ public sealed class EmployeesController : ApiControllerBase
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -147,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -163,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -177,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -186,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
@@ -206,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -215,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
return File(content, contentType, fileName);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,11 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -0,0 +1,71 @@
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// One-time opening balances. Gated on the <c>accounts.opening-balances</c> nav code (Admin always passes;
/// other roles need it granted under Settings → Roles).
/// </summary>
[Route("api/v1/opening-balances")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.opening-balances")]
public sealed class OpeningBalancesController : ApiControllerBase
{
private readonly IOpeningBalanceService _openingBalances;
private readonly IOpeningStockService _openingStock;
public OpeningBalancesController(IOpeningBalanceService openingBalances, IOpeningStockService openingStock)
{
_openingBalances = openingBalances;
_openingStock = openingStock;
}
/// <summary>The cash and bank accounts to enter opening amounts for — or the posted record, once done.</summary>
[HttpGet("cash-bank")]
[ProducesResponseType(typeof(CashBankOpeningStateDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CashBankOpeningStateDto>> GetCashBank(CancellationToken ct) =>
Ok(await _openingBalances.GetCashBankAsync(ct));
/// <summary>Posts the cash and bank opening balances as one journal. Allowed once; later calls get 409.</summary>
[HttpPost("cash-bank")]
[ProducesResponseType(typeof(OpeningBalanceRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningBalanceRunDto>> PostCashBank(
[FromBody] PostCashBankOpeningRequest request, CancellationToken ct)
{
var run = await _openingBalances.PostCashBankAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
/// <summary>Opening-stock runs posted so far, newest first.</summary>
[HttpGet("stock")]
[ProducesResponseType(typeof(IReadOnlyList<OpeningStockRunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<OpeningStockRunSummaryDto>>> ListStock(CancellationToken ct) =>
Ok(await _openingStock.ListAsync(ct));
/// <summary>One opening-stock run with its lines.</summary>
[HttpGet("stock/{runId:int}")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OpeningStockRunDto>> GetStock(int runId, CancellationToken ct)
{
var run = await _openingStock.GetAsync(runId, ct);
return run is null ? NotFound() : Ok(run);
}
/// <summary>
/// Posts opening stock: a FIFO layer per line and one journal (Dr Inventory / Cr Opening Balance Equity).
/// An item that already has stock movements in that warehouse is refused with 409.
/// </summary>
[HttpPost("stock")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningStockRunDto>> PostStock([FromBody] PostOpeningStockRequest request, CancellationToken ct)
{
var run = await _openingStock.PostAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
}
@@ -0,0 +1,46 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Procurement configuration endpoints — optional vendor fields and PO tax.
/// <para>
/// <b>Authorization:</b> reading is open to any signed-in ERP user, because the vendor and purchase-order
/// forms must read the flags and those screens sit under different nav codes. The flags are not
/// sensitive. Changing them needs the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/procurement-config")]
public sealed class ProcurementConfigController : ApiControllerBase
{
private readonly IProcurementConfigService _config;
public ProcurementConfigController(IProcurementConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProcurementConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProcurementConfigDto>> Update(
[FromBody] UpdateProcurementConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -20,8 +20,9 @@ public sealed class PurchaseOrdersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
[HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _codes.ListAsync(context, query, ct));
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[HttpPost]
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
+18 -2
View File
@@ -41,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, [FromQuery] bool? isAdmin, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, isAdmin, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -117,4 +117,20 @@ public sealed class UsersController : ApiControllerBase
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
}
/// <summary>
/// One-time migration helper (administrators only): copies each role-less user's role from AuthHex into the ERP.
/// Safe to repeat — users that already have a role are left alone. See
/// <see cref="IUserManagementService.BackfillRolesFromAuthHexAsync"/>.
/// </summary>
[HttpPost("backfill-roles")]
[ProducesResponseType(typeof(BackfillRolesResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<BackfillRolesResultDto>> BackfillRoles(CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can run the role backfill.", 403);
return Ok(await _users.BackfillRolesFromAuthHexAsync(ct));
}
}
@@ -1,16 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")]
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
@@ -35,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -45,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -57,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -67,7 +70,7 @@ public sealed class VendorsController : ApiControllerBase
}
[HttpDelete("{vendorId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -0,0 +1,26 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Access configuration — a singleton row (same shape as <see cref="GrnConfig"/>).
/// <see cref="DefaultRoleId"/> is the role a person gets on their first sign-in, and when they have none:
/// without it they would have no permissions at all. It must be a least-privilege role and can never be the
/// administrator role (enforced when it is saved).
/// </summary>
public class AccessConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public int? DefaultRoleId { get; set; }
public Role? DefaultRole { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -39,8 +39,9 @@ public class ApprovalConfig
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
public string? Level2RoleCodes { get; set; }
/// <summary>Comma-separated role IDS allowed to give the second approval; empty = anyone with the Approve action.
/// (Ids, not codes: a role can be renamed without breaking this rule.)</summary>
public string? Level2RoleIds { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One posted opening balance (cash and bank, or stock) — the record that the opening entry was made, when,
/// by whom, and which General Ledger journal carries it. The General Ledger has no "opening posted" notion, so
/// this table is what refuses a second posting: cash/bank sets <see cref="OnceKey"/> (unique index), so it can
/// be posted once. Stock leaves it null and can be loaded in several runs; each item in a warehouse can be
/// opened only once (an item that already has stock movements there is refused).
/// </summary>
public class OpeningBalanceRun
{
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceKind Kind { get; set; }
/// <summary>Set (to the kind's name) only for kinds that may be posted once; a unique index on it is the
/// once-only guard. Null for stock, which may be loaded in batches (MySQL allows many NULLs under a unique index).</summary>
public string? OnceKey { get; set; }
/// <summary>The opening date — the GL journal's entry date. Reports starting after it show these amounts as opening.</summary>
public DateOnly CutoverDate { get; set; }
/// <summary>The GL journal number (JV-…). Null only for the instant between claiming the run and the GL
/// confirming the posting, inside one transaction; never null on a committed row.</summary>
public string? GlJournalNo { get; set; }
/// <summary>Sum of the debit amounts (what the Opening Balance Equity account is credited with).</summary>
public decimal TotalAmount { get; set; }
public string Currency { get; set; } = "LKR";
public int PostedBy { get; set; }
public DateTime PostedAt { get; set; }
public ICollection<OpeningBalanceLine> Lines { get; set; } = new List<OpeningBalanceLine>();
public ICollection<OpeningStockLine> StockLines { get; set; } = new List<OpeningStockLine>();
}
/// <summary>One cash or bank account's opening amount within an <see cref="OpeningBalanceRun"/>.
/// Positive = a debit balance (money held); negative = a credit balance (an overdraft).</summary>
public class OpeningBalanceLine
{
public int OpeningBalanceLineId { get; set; }
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceRun? Run { get; set; }
public string AccountCode { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
/// <summary>One item's opening stock in one warehouse within a stock <see cref="OpeningBalanceRun"/>.</summary>
public class OpeningStockLine
{
public int OpeningStockLineId { get; set; }
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceRun? Run { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Quantity in the item's base UOM.</summary>
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
/// <summary>Qty x UnitCost, rounded to 4 dp — what the inventory account is debited with.</summary>
public decimal Value { get; set; }
}
@@ -21,5 +21,10 @@ public class PoLine
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
/// <summary>Tax amount for this line = round(Qty x UnitPrice x <see cref="Tax"/>, 2). Stored so the
/// figure on the PO, its PDF and the totals never drifts from what was approved. Always 0 while
/// Procurement configuration's PO tax switch is off.</summary>
public decimal TaxAmount { get; set; }
public decimal QtyReceived { get; set; }
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Procurement configuration — a singleton row, same shape as <see cref="GrnConfig"/>.
/// Controls the optional vendor fields and whether purchase orders carry tax.
/// </summary>
public class ProcurementConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Which contact detail vendors carry: none (default), a mobile number or an email.
/// A value sent for a field that is not selected is rejected server-side (CONFIG_DISABLED).</summary>
public VendorContactMode VendorContactMode { get; set; } = VendorContactMode.None;
/// <summary>When on, vendors carry an optional company address.</summary>
public bool VendorAddressEnabled { get; set; }
/// <summary>When on (default — the field existed before it became optional), vendors carry optional payment terms.
/// When off the field is hidden and a value for it is rejected (CONFIG_DISABLED); stored values are kept.</summary>
public bool VendorPaymentTermsEnabled { get; set; } = true;
/// <summary>When on (default), vendors carry an optional tax registration number. Same rules as payment terms.
/// Purchase invoices still snapshot a vendor's stored number either way.</summary>
public bool VendorTaxRegEnabled { get; set; } = true;
/// <summary>When on, purchase-order lines carry a tax rate (prefilled from the item) and tax amount;
/// when off, tax is hidden everywhere on POs and a non-zero tax is rejected server-side.</summary>
public bool PoTaxEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+13 -8
View File
@@ -3,25 +3,30 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
/// An ERP role. The ERP owns roles and what they may do: <see cref="User.RoleId"/> points here and
/// <see cref="RolePermission"/> holds the permissions. AuthHex only authenticates people and knows nothing about roles.
/// </summary>
public class Role
{
public int RoleId { get; set; }
public Guid AuthRoleId { get; set; }
/// <summary>Legacy link to the AuthHex role this one used to mirror. Unused now; kept (nullable) until the AuthHex
/// role table is retired, then dropped.</summary>
public Guid? AuthRoleId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystemRole { get; set; }
/// <summary>True for the administrator role: its holders pass every permission check, bypass branch scoping and can
/// manage users and roles. This flag — never the role's code or name — is what "admin" means, so the role can be
/// renamed freely. Not settable through the API; only the seeded Admin role carries it.</summary>
public bool IsAdmin { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -51,6 +51,31 @@ public class SalesConfig
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
// ---- Optional customer fields. Each is on by default (the field existed before it became optional). When off the
// field is hidden on the customer forms and a new value for it is rejected server-side (CONFIG_DISABLED); values
// already stored are kept.
/// <summary>Customers carry an optional phone number.</summary>
public bool CustomerPhoneEnabled { get; set; } = true;
/// <summary>Customers carry an optional email address.</summary>
public bool CustomerEmailEnabled { get; set; } = true;
/// <summary>Customers carry an optional address (lines 1 and 2).</summary>
public bool CustomerAddressEnabled { get; set; } = true;
/// <summary>Customers carry an optional city.</summary>
public bool CustomerCityEnabled { get; set; } = true;
/// <summary>Customers carry an optional country.</summary>
public bool CustomerCountryEnabled { get; set; } = true;
/// <summary>Customers have a credit limit.</summary>
public bool CustomerCreditLimitEnabled { get; set; } = true;
/// <summary>Customers have credit days (how long an invoice may stay unpaid — the customer's "age").</summary>
public bool CustomerCreditDaysEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -15,6 +15,13 @@ public class Vendor
public string? Terms { get; set; }
public string? TaxReg { get; set; }
public string Currency { get; set; } = "LKR";
/// <summary>Mobile number — only recorded while Procurement configuration's vendor contact mode is Mobile.</summary>
public string? Mobile { get; set; }
/// <summary>Email — only recorded while the vendor contact mode is Email.</summary>
public string? Email { get; set; }
/// <summary>Company address — only recorded while vendor address is enabled.</summary>
public string? Address { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int BranchId { get; set; }
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Which opening balance a run carries. Each kind is posted ONCE for the life of the system, so a
/// unique index on the kind is the "already posted" guard (the General Ledger itself does not stop a
/// second opening entry). Stored as a string.
/// </summary>
public enum OpeningBalanceKind
{
CashBank,
Stock
}
@@ -3,8 +3,10 @@ namespace ERPCore.Domain.Enums;
/// <summary>
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
/// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it back
/// to <see cref="Draft"/> (FR-PROC-04). Stored as a string.
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it to
/// <see cref="Rejected"/> (FR-PROC-04) - a status of its own, so a rejected order is never mistaken for a fresh
/// Draft or one still awaiting approval. A Rejected order is editable like a Draft and goes back through
/// approval when resubmitted. Stored as a string.
/// </summary>
public enum PurchaseOrderStatus
{
@@ -14,5 +16,6 @@ public enum PurchaseOrderStatus
PartiallyReceived,
FullyReceived,
Closed,
Cancelled
Cancelled,
Rejected
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Which single contact detail a deployment records for vendors (Procurement configuration).
/// <see cref="None"/> hides the field everywhere. Stored as a string.
/// </summary>
public enum VendorContactMode
{
None,
Mobile,
Email
}
@@ -10,7 +10,7 @@ public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int D
public sealed record ApprovalConfigDto(
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt);
IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt);
public sealed class UpdateApprovalConfigRequest
{
@@ -18,7 +18,8 @@ public sealed class UpdateApprovalConfigRequest
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
public List<string> Level2RoleCodes { get; set; } = new();
/// <summary>Roles allowed to give the second approval; empty = anyone with the Approve action.</summary>
public List<int> Level2RoleIds { get; set; } = new();
}
public sealed class ApproveRequest
-28
View File
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Auth;
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
public sealed class AuthHexRoleDto
{
public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class CreateAuthHexRoleRequest
{
[Required] public string Code { get; set; } = string.Empty;
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
public sealed class UpdateAuthHexRoleRequest
{
[Required] public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
+3 -1
View File
@@ -57,7 +57,9 @@ public sealed class RegisterRequest
{
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
public Guid? UserId { get; set; }
[Required] public Guid RoleId { get; set; }
/// <summary>Ignored for self-registration and never taken from a caller: the ERP assigns roles itself. Sent to AuthHex only
/// while the old AuthHex (which still demands a role id) is deployed — see <c>AuthHex:LegacyRoleId</c>.</summary>
public Guid? RoleId { get; set; }
[Required] public Guid UserTypeId { get; set; }
public string? Fullname { get; set; }
public string? UserName { get; set; }
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Access configuration resource: which role a person gets when they have none.</summary>
public sealed record AccessConfigDto(int? DefaultRoleId, string? DefaultRoleName, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>Full replacement. <c>null</c> clears the default (people without a role then have no permissions at all).</summary>
public sealed class UpdateAccessConfigRequest
{
[Range(1, int.MaxValue)] public int? DefaultRoleId { get; set; }
}
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Config;
/// <summary>Procurement configuration resource.</summary>
public sealed record ProcurementConfigDto(
VendorContactMode VendorContactMode, bool VendorAddressEnabled, bool PoTaxEnabled,
DateTime? UpdatedAt, int? UpdatedBy,
bool VendorPaymentTermsEnabled = true, bool VendorTaxRegEnabled = true);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable so an omitted flag is a 400 rather than silently binding to a default
/// (same reasoning as <see cref="UpdateGrnConfigRequest"/>).
/// </summary>
public sealed class UpdateProcurementConfigRequest
{
[Required] public VendorContactMode? VendorContactMode { get; set; }
[Required] public bool? VendorAddressEnabled { get; set; }
[Required] public bool? PoTaxEnabled { get; set; }
[Required] public bool? VendorPaymentTermsEnabled { get; set; }
[Required] public bool? VendorTaxRegEnabled { get; set; }
}
+11 -1
View File
@@ -6,7 +6,10 @@ namespace ERPCore.Dtos.Config;
/// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy,
bool CustomerPhoneEnabled = true, bool CustomerEmailEnabled = true, bool CustomerAddressEnabled = true,
bool CustomerCityEnabled = true, bool CustomerCountryEnabled = true,
bool CustomerCreditLimitEnabled = true, bool CustomerCreditDaysEnabled = true);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
@@ -22,4 +25,11 @@ public sealed class UpdateSalesConfigRequest
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
[Required] public bool? CustomerPhoneEnabled { get; set; }
[Required] public bool? CustomerEmailEnabled { get; set; }
[Required] public bool? CustomerAddressEnabled { get; set; }
[Required] public bool? CustomerCityEnabled { get; set; }
[Required] public bool? CustomerCountryEnabled { get; set; }
[Required] public bool? CustomerCreditLimitEnabled { get; set; }
[Required] public bool? CustomerCreditDaysEnabled { get; set; }
}
+4 -2
View File
@@ -27,7 +27,8 @@ public sealed record GrnDto(
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
int? PurchaseInvoiceId, string? PurchaseInvoiceNo,
string? PoDocNo = null, Dtos.Procurement.PoVendorDto? Vendor = null);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
@@ -93,7 +94,8 @@ public sealed class CreateGrnRequest
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).
/// Ignored when <see cref="PoId"/> is set: a GRN against a purchase order always belongs to that order's branch.</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.OpeningBalances;
/// <summary>One cash or bank account as the opening-balance screen shows it. <c>Amount</c> is filled only once posted.</summary>
public sealed record OpeningCashBankAccountDto(
string AccountType, string AccountName, string? BankName, string? AccountNumber,
string GlAccountCode, string CurrencyCode, decimal? Amount);
public sealed record OpeningBalanceLineDto(string AccountCode, string AccountName, decimal Amount);
/// <summary>A posted opening balance (the record of the one-time entry).</summary>
public sealed record OpeningBalanceRunDto(
int OpeningBalanceRunId, string Kind, DateOnly CutoverDate, string? GlJournalNo,
decimal TotalAmount, string Currency, DateTime PostedAt, int PostedBy,
IReadOnlyList<OpeningBalanceLineDto> Lines);
/// <summary>The cash/bank screen's state: the accounts to enter amounts for, or the posted record once done.</summary>
public sealed record CashBankOpeningStateDto(
OpeningBalanceRunDto? Posted, IReadOnlyList<OpeningCashBankAccountDto> Accounts, string BaseCurrency);
public sealed class OpeningBalanceLineInput
{
/// <summary>The GL account code of a cash or bank account, as returned by the state endpoint.</summary>
[Required, StringLength(20)] public string GlAccountCode { get; set; } = string.Empty;
/// <summary>Opening balance, at most 2 decimals. Positive = money held; negative = an overdraft. Zero lines are ignored.</summary>
public decimal Amount { get; set; }
}
public sealed class PostCashBankOpeningRequest
{
/// <summary>The opening date (the GL entry date). Must fall in an open accounting period and not be in the future.</summary>
[Required] public DateOnly? CutoverDate { get; set; }
[Required, MinLength(1)] public List<OpeningBalanceLineInput> Lines { get; set; } = new();
}
// ---------------------------------------------------------------- opening stock
public sealed class OpeningStockLineInput
{
[Required] public int ItemId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Quantity in the item's base UOM; more than 0, at most 4 decimals.</summary>
public decimal Qty { get; set; }
/// <summary>Cost of ONE base unit, 0 or more, at most 4 decimals — this becomes the FIFO layer's cost.</summary>
public decimal UnitCost { get; set; }
}
public sealed class PostOpeningStockRequest
{
/// <summary>The opening date (the stock layers' receipt date and the GL entry date). Not in the future.</summary>
[Required] public DateOnly? CutoverDate { get; set; }
[Required, MinLength(1), MaxLength(500)] public List<OpeningStockLineInput> Lines { get; set; } = new();
}
public sealed record OpeningStockLineDto(
int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseCode,
decimal Qty, decimal UnitCost, decimal Value);
public sealed record OpeningStockRunSummaryDto(
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
DateTime PostedAt, int PostedBy);
public sealed record OpeningStockRunDto(
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
DateTime PostedAt, int PostedBy, IReadOnlyList<OpeningStockLineDto> Lines);
@@ -5,9 +5,14 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
/// <summary><c>Tax</c> is the rate (0..1); <c>TaxAmount</c> the stored amount for the line.</summary>
public sealed record PoLineDto(
int PoLineId, int ItemId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived, decimal TaxAmount = 0);
/// <summary>Vendor details shown on a purchase order. <c>Mobile</c>/<c>Email</c>/<c>Address</c> are null
/// unless the matching Procurement configuration switch is on.</summary>
public sealed record PoVendorDto(int VendorId, string Code, string Name, string? Mobile, string? Email, string? Address);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
@@ -15,11 +20,12 @@ public sealed record PurchaseOrderDto(
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines, PoVendorDto? Vendor = null);
public sealed record PurchaseOrderSummaryDto(
int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals);
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals,
PoVendorDto? Vendor = null);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
+3 -1
View File
@@ -11,4 +11,6 @@ namespace ERPCore.Dtos.Rbac;
/// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
int? RoleId = null, bool HasRole = false,
IReadOnlyDictionary<string, int>? Permissions = null);
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Rbac;
public sealed record RoleDto(
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
DateTime CreatedAt, DateTime? UpdatedAt, bool IsAdmin = false);
public sealed class CreateRoleRequest
{
+7
View File
@@ -84,3 +84,10 @@ public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
/// <summary>Outcome of the one-time copy of each user's role from AuthHex into the ERP's users table.</summary>
public sealed record BackfillRolesResultDto(
int Examined, int Assigned, int AlreadyHadRole, IReadOnlyList<BackfillRolesIssueDto> Issues);
/// <summary>A user the backfill could not give a role, and why — for an administrator to assign by hand.</summary>
public sealed record BackfillRolesIssueDto(int UserId, string Username, string Reason);
+14 -1
View File
@@ -6,7 +6,8 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt,
string? Mobile = null, string? Email = null, string? Address = null);
public sealed class CreateVendorRequest
{
@@ -15,6 +16,12 @@ public sealed class CreateVendorRequest
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(20)] public string? Mobile { get; set; }
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(200)] public string? Email { get; set; }
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(500)] public string? Address { get; set; }
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
public int BranchId { get; set; }
@@ -27,6 +34,12 @@ public sealed class UpdateVendorRequest
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(20)] public string? Mobile { get; set; }
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(200)] public string? Email { get; set; }
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(500)] public string? Address { get; set; }
public int BranchId { get; set; }
}
@@ -39,7 +39,12 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- UserManager --------------------------------------------------
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
{
// Until AuthHex stops requiring a role (its roles are being retired), give it a fixed placeholder so
// registration keeps working. The ERP never reads this value; the person's real role is users.RoleId.
request.RoleId ??= Guid.TryParse(_config["AuthHex:LegacyRoleId"], out var legacy) ? legacy : null;
return CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
}
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
@@ -149,23 +154,6 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- RoleManager --------------------------------------------------
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
// ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -57,12 +57,4 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
}
@@ -22,4 +22,24 @@ public static class AuthHexClaims
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
/// </summary>
public const string BranchId = "erp:BranchId";
/// <summary>
/// Local claim: "false" when the deployment has branching switched off (Settings → Branch Configuration), absent or
/// "true" otherwise. Lets <see cref="ICurrentBranchContext"/> tell "a branch-less user in a single-branch
/// deployment" (sees everything) from "a branch-less user in a multi-branch deployment" (sees no branch's data).
/// </summary>
public const string BranchingEnabled = "erp:BranchingEnabled";
// ---- Local claims: the ERP's own authorization identity ---------------------------------------------------
// Injected by ShadowUserClaimsTransformation from the ERP's users/roles tables on every request. They are NEVER
// read from the token: the role belongs to the ERP, AuthHex only says who the person is. The AuthHex RoleId /
// RoleCode claims above are used only by the temporary legacy fallback.
/// <summary>The effective role's id (the user's role, or the default role). Absent when the person has no usable role.</summary>
public const string ErpRoleId = "erp:RoleId";
public const string ErpRoleCode = "erp:RoleCode";
/// <summary>"true" when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>).</summary>
public const string ErpIsAdmin = "erp:IsAdmin";
/// <summary>Present when the ERP user is inactive; the <c>ErpAccess</c> policy then refuses the request (403).</summary>
public const string ErpBlocked = "erp:Blocked";
}
+18 -5
View File
@@ -4,9 +4,10 @@ using ERPCore.Domain.Entities;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Resolves the audit actor from the current HTTP request's principal
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
/// when the request is unauthenticated (e.g. health checks, seeding).
/// Resolves the audit actor and the ERP-owned role from the current HTTP request's principal.
/// Every value here was put on the principal by <see cref="ShadowUserClaimsTransformation"/> from the ERP's own
/// tables — none comes from AuthHex role claims. Falls back to <see cref="SystemActor"/> when the request is
/// unauthenticated (e.g. health checks, seeding).
/// </summary>
public sealed class CurrentUser : ICurrentUser
{
@@ -32,9 +33,18 @@ public sealed class CurrentUser : ICurrentUser
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
public int? RoleId
{
get
{
var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleId);
return int.TryParse(raw, out var id) ? id : null;
}
}
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleCode);
public bool IsAdmin => string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpIsAdmin), "true", StringComparison.Ordinal);
public int? BranchId
{
@@ -44,4 +54,7 @@ public sealed class CurrentUser : ICurrentUser
return int.TryParse(raw, out var id) ? id : null;
}
}
public bool BranchingEnabled =>
!string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchingEnabled), "false", StringComparison.Ordinal);
}
@@ -9,7 +9,9 @@ namespace ERPCore.Infra.Auth;
public interface ICurrentBranchContext
{
/// <summary>The branch to scope reads to. Null means "don't filter" — used for
/// admins, unauthenticated/system contexts, and any caller with no resolved branch.</summary>
/// admins, unauthenticated/system contexts, and (only when the deployment has branching switched off) people with
/// no branch. In a branching deployment, a signed-in non-admin with no linked employee gets
/// <see cref="CurrentBranchContext.NoBranchId"/>: a branch that matches no data, so they see none (deny by default).</summary>
int? BranchId { get; }
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
@@ -18,17 +20,29 @@ public interface ICurrentBranchContext
}
/// <summary>
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/>
/// already applies for write-path checks.
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins are never filtered, nor is anyone in a
/// deployment with branching off. A non-admin without a branch (no linked employee) in a branching deployment is scoped to
/// <see cref="NoBranchId"/> — they used to fall through unfiltered and could read every branch's records, while the
/// write path (<see cref="Services.Interfaces.IBranchAccessService"/>) already refused them.
/// </summary>
public sealed class CurrentBranchContext : ICurrentBranchContext
{
/// <summary>The "branch" of a person who has none in a branching deployment. No row has branch 0, so filters on it match nothing.</summary>
public const int NoBranchId = 0;
private readonly ICurrentUser _currentUser;
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId;
public int? BranchId
{
get
{
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return null;
if (_currentUser.BranchId is { } own) return own;
return _currentUser.BranchingEnabled ? NoBranchId : null;
}
}
public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null;
public bool IsActive => BranchId is not null;
}
+20 -7
View File
@@ -1,9 +1,9 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Abstraction over the authenticated principal, used purely as the audit actor
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
/// claim — never from the request body (see 00-CORE §4).
/// Abstraction over the authenticated principal: the audit actor plus the ERP-owned role. Everything here is
/// derived from the ERP's own users/roles tables via ShadowUserClaimsTransformation, keyed by the AuthHex
/// identity — never from the request body, and never from AuthHex role claims (see 00-CORE §4).
/// </summary>
public interface ICurrentUser
{
@@ -20,13 +20,20 @@ public interface ICurrentUser
/// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; }
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary>
/// <summary>
/// The id of the person's effective ERP role (their own, else the configured default), read from the ERP's users
/// table on each request. Null when they have no usable role (none assigned and no default, or the role is
/// inactive) — they then have no permissions.
/// </summary>
int? RoleId { get; }
/// <summary>The effective role's code, for display and logging. Never use it to make a permission decision.</summary>
string? RoleCode { get; }
/// <summary>
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// True when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>) — not when its
/// name happens to be "Admin". Admins pass every permission check and are the only users that bypass
/// branch scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// </summary>
bool IsAdmin { get; }
@@ -37,4 +44,10 @@ public interface ICurrentUser
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
/// </summary>
int? BranchId { get; }
/// <summary>
/// Whether the deployment uses branches at all (Settings → Branch Configuration). When off, every document lives on the
/// one default branch and nobody is branch-scoped. Defaults to true — the safe side — for contexts that don't say.
/// </summary>
bool BranchingEnabled => true;
}
@@ -37,7 +37,6 @@ public static class JwtAuthExtensions
var publicKeyXml = config["Auth:RsaPublicKeyXml"]
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
var requiredUserType = config["Auth:RequiredUserTypeCode"];
var requiredRole = config["Auth:RequiredRoleCode"];
// AuthHex publishes no JWKS; the RSA public key is configured statically.
var rsa = RSA.Create();
@@ -85,12 +84,13 @@ public static class JwtAuthExtensions
options.AddPolicy(ErpAccessPolicy, policy =>
{
policy.RequireAuthenticatedUser();
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
// shared IdP). Empty config = require a valid ERP token only.
// Door gate: only enforce a UserType when configured (AuthHex is a shared IdP). Empty config =
// require a valid ERP token only. (There is no role door gate any more: roles are the ERP's own,
// and are enforced per endpoint by the Nav policies.)
if (!string.IsNullOrWhiteSpace(requiredUserType))
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
if (!string.IsNullOrWhiteSpace(requiredRole))
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
// An inactive ERP user is refused outright, even while their AuthHex token is still valid.
policy.RequireAssertion(ctx => !ctx.User.HasClaim(c => c.Type == AuthHexClaims.ErpBlocked));
});
});
@@ -57,9 +57,9 @@ public sealed class NavRequirement : IAuthorizationRequirement
}
}
/// <summary>Admin always passes (mirrors every other Admin-bypasses-scoping rule in this
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission,
/// and — when the policy names one — the specific action within it too.</summary>
/// <summary>An administrator (<see cref="ICurrentUser.IsAdmin"/> — the role flag, not a role name) always passes,
/// mirroring every other admin-bypasses-scoping rule in this codebase (e.g. <c>BranchAccessService</c>); everyone
/// else needs the role's permission and — when the policy names one — the specific action within it too.</summary>
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
{
private readonly ICurrentUser _currentUser;
@@ -81,9 +81,9 @@ public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequiremen
return;
}
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return;
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleId, requirement.NavCode)) return;
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value))
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleId, requirement.NavCode, requirement.Action.Value))
context.Succeed(requirement);
}
}
@@ -8,19 +8,43 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
/// Turns an authenticated AuthHex principal into ERPCore's authorization identity.
/// <para>
/// AuthHex says WHO the person is: its token carries a custom <c>UserId</c> (GUID) and no <c>sub</c>/<c>nameid</c>.
/// The ERP decides WHAT they may do, from its own tables. In one query this finds the local shadow
/// <see cref="User"/> (keyed by <c>auth_user_id</c>, JIT-provisioned on first sign-in) together with its
/// <see cref="Role"/> and branch, then injects:
/// <list type="bullet">
/// <item><c>NameIdentifier</c> — the local int user id (audit actor);</item>
/// <item><see cref="AuthHexClaims.ErpRoleId"/> / <see cref="AuthHexClaims.ErpRoleCode"/> / <see cref="AuthHexClaims.ErpIsAdmin"/> — the effective role;</item>
/// <item><see cref="AuthHexClaims.BranchId"/> — the branch of the linked employee;</item>
/// <item><see cref="AuthHexClaims.ErpBlocked"/> — when the ERP user is inactive (the door policy then answers 403).</item>
/// </list>
/// Because the role is read from the database on every request, changing someone's role or a role's permissions
/// takes effect on their very next request — no re-login, no stale token.
/// </para>
/// <para>
/// <b>Effective role</b> = the user's own role if it is active; otherwise nothing. A user with no role gets one
/// assigned and saved: during the rollout, the role named by a still-present AuthHex <c>RoleCode</c> claim
/// (<c>Auth:LegacyRoleClaimFallback</c>, on by default, to be switched off once every user has a role); else the
/// configured default role (<see cref="AccessConfig.DefaultRoleId"/>). With neither, they have no permissions.
/// </para>
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
/// </summary>
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{
private readonly ErpDbContext _db;
private readonly bool _legacyRoleFallback;
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
public ShadowUserClaimsTransformation(ErpDbContext db, IConfiguration configuration)
{
_db = db;
// Default ON: the safe choice while some users may still have no local role.
_legacyRoleFallback = !bool.TryParse(configuration["Auth:LegacyRoleClaimFallback"], out var enabled) || enabled;
}
private sealed record UserRow(
int UserId, EntityStatus Status, int? RoleId, string? RoleCode, EntityStatus? RoleStatus, bool RoleIsAdmin, int? BranchId, bool? BranchingEnabled);
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
@@ -33,29 +57,63 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
if (!Guid.TryParse(raw, out var authUserId))
return principal; // no mappable identity → CurrentUser falls back to system
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
var localId = await ResolveOrProvisionAsync(authUserId, nic);
var row = await LoadAsync(authUserId)
?? await ProvisionAsync(authUserId, principal.FindFirstValue(AuthHexClaims.Nic));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, row.UserId.ToString()));
if (row.BranchId is not null)
identity.AddClaim(new Claim(AuthHexClaims.BranchId, row.BranchId.Value.ToString()));
if (row.BranchingEnabled == false)
identity.AddClaim(new Claim(AuthHexClaims.BranchingEnabled, "false"));
var branchId = await _db.Employees.AsNoTracking()
.Where(e => e.UserId == localId)
.Select(e => (int?)e.BranchId)
.FirstOrDefaultAsync();
if (branchId is not null)
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
if (row.Status != EntityStatus.Active)
{
identity.AddClaim(new Claim(AuthHexClaims.ErpBlocked, "1"));
return principal;
}
// No role yet? Assign one (and save it) so the Users screen shows what they actually have.
if (row.RoleId is null)
row = await AssignMissingRoleAsync(row, principal.FindFirstValue(AuthHexClaims.RoleCode));
// An inactive role grants nothing: the person is treated as having no role until it is reactivated or changed.
if (row.RoleId is not null && row.RoleStatus == EntityStatus.Active)
{
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleId, row.RoleId.Value.ToString()));
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleCode, row.RoleCode ?? string.Empty));
identity.AddClaim(new Claim(AuthHexClaims.ErpIsAdmin, row.RoleIsAdmin ? "true" : "false"));
}
return principal;
}
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic)
{
var existing = await _db.Users.AsNoTracking()
/// <summary>The user with its role and branch, in ONE round trip.</summary>
private Task<UserRow?> LoadAsync(Guid authUserId) =>
_db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.Select(u => new UserRow(
u.UserId, u.Status, u.RoleId,
u.Role != null ? u.Role.Code : null,
u.Role != null ? (EntityStatus?)u.Role.Status : null,
u.Role != null && u.Role.IsAdmin,
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
.FirstOrDefaultAsync();
if (existing != 0) return existing;
private Task<UserRow?> LoadAsync(int userId) =>
_db.Users.AsNoTracking()
.Where(u => u.UserId == userId)
.Select(u => new UserRow(
u.UserId, u.Status, u.RoleId,
u.Role != null ? u.Role.Code : null,
u.Role != null ? (EntityStatus?)u.Role.Status : null,
u.Role != null && u.Role.IsAdmin,
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
.FirstOrDefaultAsync();
private async Task<UserRow> ProvisionAsync(Guid authUserId, string? nic)
{
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
var user = new User
{
@@ -69,16 +127,58 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user.UserId;
}
catch (DbUpdateException)
{
// Lost a race (unique auth_user_id) — the row now exists; re-read it.
// Lost a race (unique auth_user_id) — the row now exists; re-read it below.
_db.Entry(user).State = EntityState.Detached;
return await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstAsync();
}
return await LoadAsync(authUserId) ?? throw new InvalidOperationException("Could not provision the ERP user.");
}
private async Task<UserRow> AssignMissingRoleAsync(UserRow row, string? legacyRoleCode)
{
var role = null as Role;
// 1) Rollout safety net: the role the (old) AuthHex token still names — what this person could do until now.
if (_legacyRoleFallback && !string.IsNullOrWhiteSpace(legacyRoleCode))
{
var code = legacyRoleCode.Trim().ToLower();
role = await _db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Code.ToLower() == code && r.Status == EntityStatus.Active);
}
// 2) The configured default role (least privilege by design; never the admin role).
if (role is null)
{
role = await _db.AccessConfig.AsNoTracking()
.Where(c => c.ConfigId == AccessConfig.SingletonId && c.DefaultRole != null && c.DefaultRole.Status == EntityStatus.Active)
.Select(c => c.DefaultRole)
.FirstOrDefaultAsync();
}
if (role is null) return row; // no role and no default → no permissions
try
{
// Only fill a role that is still empty: one an administrator assigned a moment ago is never overwritten.
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.UserId == row.UserId);
if (tracked is not null && tracked.RoleId is null)
{
tracked.RoleId = role.RoleId;
await _db.SaveChangesAsync();
}
else if (tracked?.RoleId is int existing && existing != role.RoleId)
{
return await LoadAsync(row.UserId) ?? row; // somebody assigned a role meanwhile: use theirs
}
}
catch (DbUpdateException)
{
// Not fatal: the request still carries the role for this call; the next request retries the save.
_db.ChangeTracker.Clear();
}
return row with { RoleId = role.RoleId, RoleCode = role.Code, RoleStatus = role.Status, RoleIsAdmin = role.IsAdmin };
}
}
@@ -1,27 +1,34 @@
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
/// actions (docs/02-SECURITY.md §B.2). Bearer-token callers
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
/// issues alongside the session cookies.
/// <para>
/// Every mutating cookie-authenticated endpoint is covered by <see cref="CsrfProtectionFilter"/> (registered globally),
/// so this attribute is only needed on an action that must be checked even when the session cookie is absent
/// (AuthController's token-in-body endpoints).
/// </para>
/// </summary>
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var request = context.HttpContext.Request;
Enforce(context.HttpContext.Request);
await next();
}
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
{
await next();
return;
}
/// <summary>Throws a 403 <c>CSRF_TOKEN_MISMATCH</c> unless the request is bearer-authenticated or carries a header matching the CSRF cookie.</summary>
public static void Enforce(HttpRequest request)
{
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString())) return;
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
@@ -29,6 +36,33 @@ public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
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);
}
}
/// <summary>
/// Registered globally: any state-changing request (POST/PUT/PATCH/DELETE) that is authenticated by the browser's
/// session cookie must pass the double-submit check. Not applied to safe methods, to anonymous endpoints (login,
/// registration — no session to ride on), to actions that already carry <see cref="ValidateCsrfAttribute"/>, or to
/// requests with no session cookie at all (nothing for a forged request to ride on; those fail authentication anyway).
/// </summary>
public sealed class CsrfProtectionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var http = context.HttpContext;
var request = http.Request;
var unsafeMethod = HttpMethods.IsPost(request.Method) || HttpMethods.IsPut(request.Method)
|| HttpMethods.IsPatch(request.Method) || HttpMethods.IsDelete(request.Method);
var endpoint = http.GetEndpoint();
if (unsafeMethod
&& request.Cookies.ContainsKey(JwtAuthExtensions.AccessTokenCookie)
&& endpoint?.Metadata.GetMetadata<IAllowAnonymous>() is null
&& endpoint?.Metadata.GetMetadata<ValidateCsrfAttribute>() is null)
{
ValidateCsrfAttribute.Enforce(request);
}
await next();
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>The singleton access-configuration row; the check constraint makes "singleton" a database guarantee.</summary>
public sealed class AccessConfigConfiguration : IEntityTypeConfiguration<AccessConfig>
{
public void Configure(EntityTypeBuilder<AccessConfig> builder)
{
builder.ToTable("access_config", t =>
t.HasCheckConstraint("ck_access_config_singleton", $"`ConfigId` = {AccessConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
// If the default role is deleted the setting clears itself rather than blocking the delete.
builder.HasOne(c => c.DefaultRole)
.WithMany()
.HasForeignKey(c => c.DefaultRoleId)
.OnDelete(DeleteBehavior.SetNull);
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -34,7 +34,7 @@ public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<Appro
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
builder.Property(c => c.Level2RoleIds).HasMaxLength(500);
// Dual approval on by default for both documents (plan §8 recommended defaults).
builder.HasData(new ApprovalConfig
@@ -41,7 +41,11 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 },
// Added with the permission-coverage pass (Phase B): these two sidebar sections had no row, so no role could be
// granted them and the sidebar had to bypass the check for everybody.
new NavItem { NavItemId = 15, Code = "hrm", Label = "Human Resources", Href = "/dashboard/hrm", SortOrder = 15 },
new NavItem { NavItemId = 16, Code = "sales-returns", Label = "Sales Returns", Href = "/dashboard/sales/sales-returns", SortOrder = 16 }
);
}
}
@@ -0,0 +1,67 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Posted opening balances. The unique index on Kind is the once-only guard.</summary>
public sealed class OpeningBalanceRunConfiguration : IEntityTypeConfiguration<OpeningBalanceRun>
{
public void Configure(EntityTypeBuilder<OpeningBalanceRun> builder)
{
builder.ToTable("opening_balance_runs");
builder.HasKey(x => x.OpeningBalanceRunId);
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasIndex(x => x.Kind);
// The once-only guard: only kinds that may be posted once set this, so a second attempt hits the unique index.
builder.Property(x => x.OnceKey).HasMaxLength(20);
builder.HasIndex(x => x.OnceKey).IsUnique();
builder.Property(x => x.CutoverDate).IsRequired();
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.TotalAmount).HasPrecision(18, 4);
builder.Property(x => x.Currency).IsRequired().HasMaxLength(3);
builder.Property(x => x.PostedAt).IsRequired();
}
}
public sealed class OpeningBalanceLineConfiguration : IEntityTypeConfiguration<OpeningBalanceLine>
{
public void Configure(EntityTypeBuilder<OpeningBalanceLine> builder)
{
builder.ToTable("opening_balance_lines");
builder.HasKey(x => x.OpeningBalanceLineId);
builder.Property(x => x.AccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.AccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Amount).HasPrecision(18, 4);
builder.HasOne(x => x.Run)
.WithMany(r => r.Lines)
.HasForeignKey(x => x.OpeningBalanceRunId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class OpeningStockLineConfiguration : IEntityTypeConfiguration<OpeningStockLine>
{
public void Configure(EntityTypeBuilder<OpeningStockLine> builder)
{
builder.ToTable("opening_stock_lines");
builder.HasKey(x => x.OpeningStockLineId);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.UnitCost).HasPrecision(18, 4);
builder.Property(x => x.Value).HasPrecision(18, 4);
builder.HasOne(x => x.Run)
.WithMany(r => r.StockLines)
.HasForeignKey(x => x.OpeningBalanceRunId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ItemId, x.WarehouseId });
}
}
@@ -90,7 +90,20 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 },
new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 },
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 }
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 },
new Permission { PermissionId = 60, Code = "NAV:procurement.po-approvals", SubNavItemId = 50 },
new Permission { PermissionId = 61, Code = "NAV:accounts.opening-balances", SubNavItemId = 51 },
new Permission { PermissionId = 62, Code = "NAV:hrm", NavItemId = 15 },
new Permission { PermissionId = 63, Code = "NAV:sales-returns", NavItemId = 16 },
new Permission { PermissionId = 64, Code = "NAV:hrm.employees", SubNavItemId = 52 },
new Permission { PermissionId = 65, Code = "NAV:hrm.attendance", SubNavItemId = 53 },
new Permission { PermissionId = 66, Code = "NAV:hrm.leave", SubNavItemId = 54 },
new Permission { PermissionId = 67, Code = "NAV:hrm.payroll", SubNavItemId = 55 },
new Permission { PermissionId = 68, Code = "NAV:hrm.reports", SubNavItemId = 56 },
new Permission { PermissionId = 69, Code = "NAV:hrm.settings", SubNavItemId = 57 },
new Permission { PermissionId = 70, Code = "NAV:settings.audit-logs", SubNavItemId = 58 },
// SubNavItem 24 (Notification Config) never had a permission row, so it could not be granted in Settings → Roles.
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 }
);
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton procurement-configuration row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors GrnConfig).
/// </summary>
public sealed class ProcurementConfigConfiguration : IEntityTypeConfiguration<ProcurementConfig>
{
public void Configure(EntityTypeBuilder<ProcurementConfig> builder)
{
builder.ToTable("procurement_config", t =>
t.HasCheckConstraint("ck_procurement_config_singleton", $"`ConfigId` = {ProcurementConfig.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.VendorContactMode)
.HasConversion<string>()
.HasMaxLength(10)
.IsRequired()
.HasDefaultValue(VendorContactMode.None);
builder.Property(c => c.VendorAddressEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.PoTaxEnabled).IsRequired().HasDefaultValue(false);
// Default on, and the sentinel is the CLR initializer (true), so an explicit false is still written on insert.
builder.Property(c => c.VendorPaymentTermsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.VendorTaxRegEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -52,6 +52,7 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
builder.Property(l => l.Tax).HasPrecision(9, 4);
builder.Property(l => l.TaxAmount).HasPrecision(18, 4).HasDefaultValue(0m);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.HasOne(l => l.PurchaseOrder)
@@ -12,7 +12,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
builder.ToTable("roles");
builder.HasKey(r => r.RoleId);
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired();
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id");
builder.HasIndex(r => r.AuthRoleId).IsUnique();
builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
@@ -20,6 +20,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
builder.Property(r => r.IsAdmin).IsRequired().HasDefaultValue(false);
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
@@ -27,6 +27,13 @@ public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesCon
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerPhoneEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerEmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerAddressEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCityEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCountryEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCreditLimitEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.CustomerCreditDaysEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.HasOne(c => c.DefaultWarehouse)
.WithMany()
@@ -55,6 +55,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 50, NavItemId = 4, Code = "procurement.po-approvals", Label = "PO Approval Queue", Href = "/dashboard/procurement/purchase-orders/approvals", SortOrder = 6 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
@@ -73,6 +74,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 },
new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 },
new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 },
new SubNavItem { SubNavItemId = 51, NavItemId = 12, Code = "accounts.opening-balances", Label = "Opening Balances", Href = "/dashboard/accounts/opening-balances", SortOrder = 7 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
@@ -91,7 +93,17 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 },
// Human Resources (NavItemId 15) children — mirror the sidebar order (AppSidebar.tsx). hrm.payroll also guards
// salary structures, loans, bank details, payslips and the pay reports.
new SubNavItem { SubNavItemId = 52, NavItemId = 15, Code = "hrm.employees", Label = "Employees", Href = "/dashboard/hrm/employees", SortOrder = 1 },
new SubNavItem { SubNavItemId = 53, NavItemId = 15, Code = "hrm.attendance", Label = "Attendance", Href = "/dashboard/hrm/attendance", SortOrder = 2 },
new SubNavItem { SubNavItemId = 54, NavItemId = 15, Code = "hrm.leave", Label = "Leave", Href = "/dashboard/hrm/leave", SortOrder = 3 },
new SubNavItem { SubNavItemId = 55, NavItemId = 15, Code = "hrm.payroll", Label = "Payroll", Href = "/dashboard/hrm/payroll", SortOrder = 4 },
new SubNavItem { SubNavItemId = 56, NavItemId = 15, Code = "hrm.reports", Label = "Reports", Href = "/dashboard/hrm/reports", SortOrder = 5 },
new SubNavItem { SubNavItemId = 57, NavItemId = 15, Code = "hrm.settings", Label = "HR Settings", Href = "/dashboard/hrm/settings", SortOrder = 6 },
// Who changed what (including salary changes) is its own Settings entry, not part of "Settings" at large.
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 }
);
}
}
@@ -18,6 +18,9 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
builder.Property(v => v.Terms).HasMaxLength(50);
builder.Property(v => v.TaxReg).HasMaxLength(50);
builder.Property(v => v.Mobile).HasMaxLength(20);
builder.Property(v => v.Email).HasMaxLength(200);
builder.Property(v => v.Address).HasMaxLength(500);
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
builder.Property(v => v.Status)
@@ -61,12 +61,67 @@ public static class DataSeeder
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedProcurementConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
// After the rest: needs the permission rows (HasData) and saves in steps, because the access config
// points at roles that must have real generated ids first.
await SeedAccessAsync(db, ct);
}
/// <summary>
/// The roles every deployment must have, and the access configuration (idempotent):
/// <list type="bullet">
/// <item>an <b>Administrator</b> role flagged <see cref="Role.IsAdmin"/> — an existing role whose code is "admin" is
/// flagged rather than duplicated; a fresh database gets one with every permission, so it is never lock-out empty;</item>
/// <item>a <b>No access</b> role with no permissions at all — the default for new sign-ins, so a mistake can never grant access;</item>
/// <item>the singleton access config, pointing its default role at "No access" the first time only (an admin's later choice is never overwritten).</item>
/// </list>
/// </summary>
private static async Task SeedAccessAsync(ErpDbContext db, CancellationToken ct)
{
var now = DateTime.UtcNow;
var admin = await db.Roles.FirstOrDefaultAsync(r => r.IsAdmin, ct)
?? await db.Roles.FirstOrDefaultAsync(r => r.Code.ToLower() == "admin", ct);
if (admin is null)
{
admin = new Role { Code = "Admin", Name = "Administrator", IsSystemRole = true, IsAdmin = true, Status = EntityStatus.Active, CreatedAt = now };
db.Roles.Add(admin);
await db.SaveChangesAsync(ct);
foreach (var permissionId in await db.Permissions.Select(p => p.PermissionId).ToListAsync(ct))
db.RolePermissions.Add(new RolePermission { RoleId = admin.RoleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
await db.SaveChangesAsync(ct);
}
else if (!admin.IsAdmin)
{
admin.IsAdmin = true;
admin.UpdatedAt = now;
await db.SaveChangesAsync(ct);
}
var noAccess = await db.Roles.FirstOrDefaultAsync(r => r.Code == DefaultNoAccessRoleCode, ct);
if (noAccess is null)
{
noAccess = new Role { Code = DefaultNoAccessRoleCode, Name = "No access", IsSystemRole = true, Status = EntityStatus.Active, CreatedAt = now };
db.Roles.Add(noAccess);
await db.SaveChangesAsync(ct);
}
if (!await db.AccessConfig.AnyAsync(c => c.ConfigId == AccessConfig.SingletonId, ct))
{
db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = noAccess.RoleId });
await db.SaveChangesAsync(ct);
}
}
/// <summary>Code of the seeded least-privilege role new sign-ins start with.</summary>
public const string DefaultNoAccessRoleCode = "NoAccess";
/// <summary>
/// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent.
/// </summary>
@@ -201,6 +256,16 @@ public static class DataSeeder
return true;
}
/// <summary>Seeds the procurement configuration singleton — vendor contact/address and PO tax all off,
/// until an admin opts in from Settings → Configuration → Procurement Configuration.</summary>
private static async Task<bool> SeedProcurementConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.ProcurementConfig.AnyAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct)) return false;
db.ProcurementConfig.Add(new ProcurementConfig { ConfigId = ProcurementConfig.SingletonId });
return true;
}
/// <summary>
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
/// values once so an existing deployment keeps the mapping it has been running with.
@@ -48,6 +48,9 @@ public class ErpDbContext : DbContext
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
public DbSet<ProcurementConfig> ProcurementConfig => Set<ProcurementConfig>();
/// <summary>Singleton row: the default role for people who have none.</summary>
public DbSet<AccessConfig> AccessConfig => Set<AccessConfig>();
/// <summary>Singleton row mapping each accounting role to a GL account code.</summary>
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
@@ -98,6 +101,9 @@ public class ErpDbContext : DbContext
// --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) ---
public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>();
public DbSet<OpeningBalanceRun> OpeningBalanceRuns => Set<OpeningBalanceRun>();
public DbSet<OpeningBalanceLine> OpeningBalanceLines => Set<OpeningBalanceLine>();
public DbSet<OpeningStockLine> OpeningStockLines => Set<OpeningStockLine>();
// --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) ---
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProcurementConfigAndVendorContact : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Address",
table: "vendors",
type: "varchar(500)",
maxLength: 500,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<string>(
name: "Email",
table: "vendors",
type: "varchar(200)",
maxLength: 200,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.AddColumn<string>(
name: "Mobile",
table: "vendors",
type: "varchar(20)",
maxLength: 20,
nullable: true,
collation: "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "procurement_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
VendorContactMode = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false, defaultValue: "None", collation: "utf8mb4_unicode_ci"),
VendorAddressEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
PoTaxEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_procurement_config", x => x.ConfigId);
table.CheckConstraint("ck_procurement_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_procurement_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_procurement_config_UpdatedBy",
table: "procurement_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "procurement_config");
migrationBuilder.DropColumn(
name: "Address",
table: "vendors");
migrationBuilder.DropColumn(
name: "Email",
table: "vendors");
migrationBuilder.DropColumn(
name: "Mobile",
table: "vendors");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPoLineTaxAmount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "TaxAmount",
table: "po_lines",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
// Totals now sum the stored amount instead of recomputing qty x price x rate, so give any
// line that already carries a rate its amount (same rounding: half away from zero, 2 dp).
migrationBuilder.Sql("UPDATE po_lines SET TaxAmount = ROUND(Qty * UnitPrice * Tax, 2) WHERE Tax <> 0;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TaxAmount",
table: "po_lines");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddPoApprovalsNav : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 50, "procurement.po-approvals", "/dashboard/procurement/purchase-orders/approvals", null, "PO Approval Queue", 4, 6 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 60, "NAV:procurement.po-approvals", null, 50 });
// Grant the new queue entry. Admin gets everything (the server only lets an Admin approve
// anyway); any other role that can already open Purchase Orders (permission 30) gets View.
// Idempotent, like the grants in AddDualApproval.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 60, CASE WHEN LOWER(r.`Code`) = 'admin' THEN 31 ELSE 1 END
FROM `roles` r
WHERE LOWER(r.`Code`) = 'admin'
OR EXISTS (SELECT 1 FROM `role_permissions` rp
WHERE rp.`RoleId` = r.`RoleId` AND rp.`PermissionId` = 30);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 60;");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 60);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 50);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddOpeningBalances : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "opening_balance_runs",
columns: table => new
{
OpeningBalanceRunId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Kind = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
OnceKey = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
CutoverDate = table.Column<DateOnly>(type: "date", nullable: false),
GlJournalNo = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: true, collation: "utf8mb4_unicode_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Currency = table.Column<string>(type: "varchar(3)", maxLength: 3, nullable: false, collation: "utf8mb4_unicode_ci"),
PostedBy = table.Column<int>(type: "int", nullable: false),
PostedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_opening_balance_runs", x => x.OpeningBalanceRunId);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "opening_balance_lines",
columns: table => new
{
OpeningBalanceLineId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
OpeningBalanceRunId = table.Column<int>(type: "int", nullable: false),
AccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
AccountName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false, collation: "utf8mb4_unicode_ci"),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_opening_balance_lines", x => x.OpeningBalanceLineId);
table.ForeignKey(
name: "FK_opening_balance_lines_opening_balance_runs_OpeningBalanceRun~",
column: x => x.OpeningBalanceRunId,
principalTable: "opening_balance_runs",
principalColumn: "OpeningBalanceRunId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "opening_stock_lines",
columns: table => new
{
OpeningStockLineId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
OpeningBalanceRunId = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<int>(type: "int", nullable: false),
WarehouseId = table.Column<int>(type: "int", nullable: false),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Value = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_opening_stock_lines", x => x.OpeningStockLineId);
table.ForeignKey(
name: "FK_opening_stock_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_opening_stock_lines_opening_balance_runs_OpeningBalanceRunId",
column: x => x.OpeningBalanceRunId,
principalTable: "opening_balance_runs",
principalColumn: "OpeningBalanceRunId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_opening_stock_lines_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 51, "accounts.opening-balances", "/dashboard/accounts/opening-balances", null, "Opening Balances", 12, 7 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 61, "NAV:accounts.opening-balances", null, 51 });
// Admin only: posting opening balances is a one-time ledger entry. Other roles can be granted it
// under Settings → Roles. Idempotent, like the other nav grants.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, 61, 31 FROM `roles` r WHERE LOWER(r.`Code`) = 'admin';
""");
migrationBuilder.CreateIndex(
name: "IX_opening_balance_lines_OpeningBalanceRunId",
table: "opening_balance_lines",
column: "OpeningBalanceRunId");
migrationBuilder.CreateIndex(
name: "IX_opening_balance_runs_Kind",
table: "opening_balance_runs",
column: "Kind");
migrationBuilder.CreateIndex(
name: "IX_opening_balance_runs_OnceKey",
table: "opening_balance_runs",
column: "OnceKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_opening_stock_lines_ItemId_WarehouseId",
table: "opening_stock_lines",
columns: new[] { "ItemId", "WarehouseId" });
migrationBuilder.CreateIndex(
name: "IX_opening_stock_lines_OpeningBalanceRunId",
table: "opening_stock_lines",
column: "OpeningBalanceRunId");
migrationBuilder.CreateIndex(
name: "IX_opening_stock_lines_WarehouseId",
table: "opening_stock_lines",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` = 61;");
migrationBuilder.DropTable(
name: "opening_balance_lines");
migrationBuilder.DropTable(
name: "opening_stock_lines");
migrationBuilder.DropTable(
name: "opening_balance_runs");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 61);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 51);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddVendorFieldSwitches : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "VendorPaymentTermsEnabled",
table: "procurement_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "VendorTaxRegEnabled",
table: "procurement_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "VendorPaymentTermsEnabled",
table: "procurement_config");
migrationBuilder.DropColumn(
name: "VendorTaxRegEnabled",
table: "procurement_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class RolesOwnedByErp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Level2RoleCodes",
table: "approval_config",
newName: "Level2RoleIds");
migrationBuilder.AlterColumn<Guid>(
name: "auth_role_id",
table: "roles",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddColumn<bool>(
name: "IsAdmin",
table: "roles",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "access_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
DefaultRoleId = table.Column<int>(type: "int", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_access_config", x => x.ConfigId);
table.CheckConstraint("ck_access_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_access_config_roles_DefaultRoleId",
column: x => x.DefaultRoleId,
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_access_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
// ---- Data: the existing administrator role becomes THE administrator by flag, not by name.
migrationBuilder.Sql("UPDATE `roles` SET `IsAdmin` = 1 WHERE LOWER(`Code`) = 'admin';");
// ---- Data: "who may give the second approval" was a list of role CODES; it is now a list of role IDS
// (a rename can no longer break it). The renamed column still holds the codes — convert them in place.
// A code that matches no role simply drops out.
migrationBuilder.Sql("""
UPDATE `approval_config` c
SET c.`Level2RoleIds` = (
SELECT GROUP_CONCAT(r.`RoleId` ORDER BY r.`RoleId` SEPARATOR ',')
FROM `roles` r
WHERE FIND_IN_SET(LOWER(r.`Code`), LOWER(REPLACE(c.`Level2RoleIds`, ' ', ''))) > 0)
WHERE c.`Level2RoleIds` IS NOT NULL AND c.`Level2RoleIds` <> '';
""");
migrationBuilder.CreateIndex(
name: "IX_access_config_DefaultRoleId",
table: "access_config",
column: "DefaultRoleId");
migrationBuilder.CreateIndex(
name: "IX_access_config_UpdatedBy",
table: "access_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Turn the role ids back into codes before the column goes back to its old meaning.
migrationBuilder.Sql("""
UPDATE `approval_config` c
SET c.`Level2RoleIds` = (
SELECT GROUP_CONCAT(r.`Code` SEPARATOR ',')
FROM `roles` r
WHERE FIND_IN_SET(r.`RoleId`, REPLACE(c.`Level2RoleIds`, ' ', '')) > 0)
WHERE c.`Level2RoleIds` IS NOT NULL AND c.`Level2RoleIds` <> '';
""");
migrationBuilder.DropTable(
name: "access_config");
migrationBuilder.DropColumn(
name: "IsAdmin",
table: "roles");
migrationBuilder.RenameColumn(
name: "Level2RoleIds",
table: "approval_config",
newName: "Level2RoleCodes");
migrationBuilder.AlterColumn<Guid>(
name: "auth_role_id",
table: "roles",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class PermissionCoverage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[,]
{
{ 15, "hrm", "/dashboard/hrm", null, "Human Resources", 15 },
{ 16, "sales-returns", "/dashboard/sales/sales-returns", null, "Sales Returns", 16 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 71, "NAV:settings.notification-config", null, 24 });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 58, "settings.audit-logs", "/dashboard/settings/audit-logs", null, "Audit Log", 9, 5 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 62, "NAV:hrm", 15, null },
{ 63, "NAV:sales-returns", 16, null },
{ 70, "NAV:settings.audit-logs", null, 58 }
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 52, "hrm.employees", "/dashboard/hrm/employees", null, "Employees", 15, 1 },
{ 53, "hrm.attendance", "/dashboard/hrm/attendance", null, "Attendance", 15, 2 },
{ 54, "hrm.leave", "/dashboard/hrm/leave", null, "Leave", 15, 3 },
{ 55, "hrm.payroll", "/dashboard/hrm/payroll", null, "Payroll", 15, 4 },
{ 56, "hrm.reports", "/dashboard/hrm/reports", null, "Reports", 15, 5 },
{ 57, "hrm.settings", "/dashboard/hrm/settings", null, "HR Settings", 15, 6 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 64, "NAV:hrm.employees", null, 52 },
{ 65, "NAV:hrm.attendance", null, 53 },
{ 66, "NAV:hrm.leave", null, 54 },
{ 67, "NAV:hrm.payroll", null, 55 },
{ 68, "NAV:hrm.reports", null, 56 },
{ 69, "NAV:hrm.settings", null, 57 }
});
// Grants. Idempotent (INSERT IGNORE on the (RoleId, PermissionId) key), and keyed on the role's IsAdmin flag,
// never on its code or name. Everything below is least-privilege: nobody who is not an administrator gets
// Human Resources, the audit log or notification config until an administrator grants it under Settings → Roles.
// (HRM used to be open to every signed-in user; this closes it.)
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, p.`PermissionId`, 31
FROM `roles` r
JOIN `permissions` p ON p.`PermissionId` BETWEEN 62 AND 71
WHERE r.`IsAdmin` = 1;
""");
// Sales Returns used to ride on the Sales permission; it is its own sidebar entry now, so whoever could reach
// Sales (the module or any of its pages) keeps reaching Returns, with the same actions they hold on Sales.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT rp.`RoleId`, 63, BIT_OR(rp.`Actions`)
FROM `role_permissions` rp
JOIN `permissions` p ON p.`PermissionId` = rp.`PermissionId`
WHERE p.`Code` = 'NAV:sales' OR p.`Code` LIKE 'NAV:sales.%'
GROUP BY rp.`RoleId`;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` BETWEEN 62 AND 71;");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 62);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 63);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 64);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 65);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 66);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 67);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 68);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 69);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 70);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 71);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 16);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 52);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 53);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 54);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 55);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 56);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 57);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 58);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 15);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddCustomerFieldSwitches : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CustomerAddressEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCityEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCountryEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCreditDaysEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerCreditLimitEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerEmailEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "CustomerPhoneEnabled",
table: "sales_config",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomerAddressEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCityEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCountryEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCreditDaysEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerCreditLimitEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerEmailEnabled",
table: "sales_config");
migrationBuilder.DropColumn(
name: "CustomerPhoneEnabled",
table: "sales_config");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -23,12 +23,42 @@ namespace ERPCore.Migrations
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("ERPCore.Domain.Entities.AccessConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<int?>("DefaultRoleId")
.HasColumnType("int");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.HasKey("ConfigId");
b.HasIndex("DefaultRoleId");
b.HasIndex("UpdatedBy");
b.ToTable("access_config", null, t =>
{
t.HasCheckConstraint("ck_access_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.ApprovalConfig", b =>
{
b.Property<int>("ApprovalConfigId")
.HasColumnType("int");
b.Property<string>("Level2RoleCodes")
b.Property<string>("Level2RoleIds")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
@@ -3382,6 +3412,24 @@ namespace ERPCore.Migrations
Label = "Production",
SortOrder = 14,
Status = "Active"
},
new
{
NavItemId = 15,
Code = "hrm",
Href = "/dashboard/hrm",
Label = "Human Resources",
SortOrder = 15,
Status = "Active"
},
new
{
NavItemId = 16,
Code = "sales-returns",
Href = "/dashboard/sales/sales-returns",
Label = "Sales Returns",
SortOrder = 16,
Status = "Active"
});
});
@@ -3556,6 +3604,127 @@ namespace ERPCore.Migrations
b.ToTable("number_sequences", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceLine", b =>
{
b.Property<int>("OpeningBalanceLineId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("OpeningBalanceLineId"));
b.Property<string>("AccountCode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("AccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("OpeningBalanceRunId")
.HasColumnType("int");
b.HasKey("OpeningBalanceLineId");
b.HasIndex("OpeningBalanceRunId");
b.ToTable("opening_balance_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceRun", b =>
{
b.Property<int>("OpeningBalanceRunId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("OpeningBalanceRunId"));
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("varchar(3)");
b.Property<DateOnly>("CutoverDate")
.HasColumnType("date");
b.Property<string>("GlJournalNo")
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("OnceKey")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("PostedAt")
.HasColumnType("datetime(6)");
b.Property<int>("PostedBy")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("OpeningBalanceRunId");
b.HasIndex("Kind");
b.HasIndex("OnceKey")
.IsUnique();
b.ToTable("opening_balance_runs", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningStockLine", b =>
{
b.Property<int>("OpeningStockLineId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("OpeningStockLineId"));
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<int>("OpeningBalanceRunId")
.HasColumnType("int");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("UnitCost")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Value")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("WarehouseId")
.HasColumnType("int");
b.HasKey("OpeningStockLineId");
b.HasIndex("OpeningBalanceRunId");
b.HasIndex("WarehouseId");
b.HasIndex("ItemId", "WarehouseId");
b.ToTable("opening_stock_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.Property<int>("PayrollLineId")
@@ -4226,6 +4395,78 @@ namespace ERPCore.Migrations
PermissionId = 59,
Code = "NAV:settings.approvals",
SubNavItemId = 49
},
new
{
PermissionId = 60,
Code = "NAV:procurement.po-approvals",
SubNavItemId = 50
},
new
{
PermissionId = 61,
Code = "NAV:accounts.opening-balances",
SubNavItemId = 51
},
new
{
PermissionId = 62,
Code = "NAV:hrm",
NavItemId = 15
},
new
{
PermissionId = 63,
Code = "NAV:sales-returns",
NavItemId = 16
},
new
{
PermissionId = 64,
Code = "NAV:hrm.employees",
SubNavItemId = 52
},
new
{
PermissionId = 65,
Code = "NAV:hrm.attendance",
SubNavItemId = 53
},
new
{
PermissionId = 66,
Code = "NAV:hrm.leave",
SubNavItemId = 54
},
new
{
PermissionId = 67,
Code = "NAV:hrm.payroll",
SubNavItemId = 55
},
new
{
PermissionId = 68,
Code = "NAV:hrm.reports",
SubNavItemId = 56
},
new
{
PermissionId = 69,
Code = "NAV:hrm.settings",
SubNavItemId = 57
},
new
{
PermissionId = 70,
Code = "NAV:settings.audit-logs",
SubNavItemId = 58
},
new
{
PermissionId = 71,
Code = "NAV:settings.notification-config",
SubNavItemId = 24
});
});
@@ -4255,6 +4496,12 @@ namespace ERPCore.Migrations
.HasPrecision(9, 4)
.HasColumnType("decimal(9,4)");
b.Property<decimal>("TaxAmount")
.ValueGeneratedOnAdd()
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)")
.HasDefaultValue(0m);
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
@@ -4398,6 +4645,58 @@ namespace ERPCore.Migrations
b.ToTable("price_adjustment_lines", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProcurementConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<bool>("PoTaxEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.Property<bool>("VendorAddressEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<string>("VendorContactMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("varchar(10)")
.HasDefaultValue("None");
b.Property<bool>("VendorPaymentTermsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("VendorTaxRegEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("procurement_config", null, t =>
{
t.HasCheckConstraint("ck_procurement_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{
b.Property<int>("ConfigId")
@@ -5121,7 +5420,7 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RoleId"));
b.Property<Guid>("AuthRoleId")
b.Property<Guid?>("AuthRoleId")
.HasColumnType("char(36)")
.HasColumnName("auth_role_id");
@@ -5133,6 +5432,11 @@ namespace ERPCore.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(false);
b.Property<bool>("IsSystemRole")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
@@ -5530,6 +5834,41 @@ namespace ERPCore.Migrations
b.Property<int?>("CashCustomerId")
.HasColumnType("int");
b.Property<bool>("CustomerAddressEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCityEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCountryEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCreditDaysEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerCreditLimitEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerEmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("CustomerPhoneEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<int?>("DefaultWarehouseId")
.HasColumnType("int");
@@ -7403,6 +7742,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 50,
Code = "procurement.po-approvals",
Href = "/dashboard/procurement/purchase-orders/approvals",
Label = "PO Approval Queue",
NavItemId = 4,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 20,
Code = "procurement.purchase-returns",
@@ -7553,6 +7902,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 51,
Code = "accounts.opening-balances",
Href = "/dashboard/accounts/opening-balances",
Label = "Opening Balances",
NavItemId = 12,
SortOrder = 7,
Status = "Active"
},
new
{
SubNavItemId = 27,
Code = "production.templates",
@@ -7661,6 +8020,76 @@ namespace ERPCore.Migrations
NavItemId = 6,
SortOrder = 8,
Status = "Active"
},
new
{
SubNavItemId = 52,
Code = "hrm.employees",
Href = "/dashboard/hrm/employees",
Label = "Employees",
NavItemId = 15,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 53,
Code = "hrm.attendance",
Href = "/dashboard/hrm/attendance",
Label = "Attendance",
NavItemId = 15,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 54,
Code = "hrm.leave",
Href = "/dashboard/hrm/leave",
Label = "Leave",
NavItemId = 15,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 55,
Code = "hrm.payroll",
Href = "/dashboard/hrm/payroll",
Label = "Payroll",
NavItemId = 15,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 56,
Code = "hrm.reports",
Href = "/dashboard/hrm/reports",
Label = "Reports",
NavItemId = 15,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 57,
Code = "hrm.settings",
Href = "/dashboard/hrm/settings",
Label = "HR Settings",
NavItemId = 15,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 58,
Code = "settings.audit-logs",
Href = "/dashboard/settings/audit-logs",
Label = "Audit Log",
NavItemId = 9,
SortOrder = 5,
Status = "Active"
});
});
@@ -8005,6 +8434,10 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("VendorId"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("BranchId")
.HasColumnType("int");
@@ -8023,6 +8456,14 @@ namespace ERPCore.Migrations
.HasColumnType("varchar(3)")
.HasDefaultValue("LKR");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("Mobile")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -8334,6 +8775,23 @@ namespace ERPCore.Migrations
b.ToTable("hr_work_shifts", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.AccessConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.Role", "DefaultRole")
.WithMany()
.HasForeignKey("DefaultRoleId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("DefaultRole");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
{
b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch")
@@ -9127,6 +9585,44 @@ namespace ERPCore.Migrations
b.Navigation("Branch");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.OpeningBalanceRun", "Run")
.WithMany("Lines")
.HasForeignKey("OpeningBalanceRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Run");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningStockLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.OpeningBalanceRun", "Run")
.WithMany("StockLines")
.HasForeignKey("OpeningBalanceRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("Run");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
@@ -9259,6 +9755,16 @@ namespace ERPCore.Migrations
b.Navigation("PriceAdjustment");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProcurementConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
@@ -10693,6 +11199,13 @@ namespace ERPCore.Migrations
b.Navigation("Modules");
});
modelBuilder.Entity("ERPCore.Domain.Entities.OpeningBalanceRun", b =>
{
b.Navigation("Lines");
b.Navigation("StockLines");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.Navigation("Components");
+10 -1
View File
@@ -28,7 +28,12 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
builder.Services.AddControllers(o => o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>())
builder.Services.AddControllers(o =>
{
o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>();
// CSRF double-submit check on every mutating, cookie-authenticated endpoint (docs/02-SECURITY.md §B.2).
o.Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>();
})
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
@@ -119,6 +124,10 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>();
builder.Services.AddScoped<IAccessConfigService, AccessConfigService>();
builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>();
builder.Services.AddScoped<IOpeningStockService, OpeningStockService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
@@ -0,0 +1,79 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Access configuration over the singleton row seeded by DataSeeder. The default role is what every first sign-in
/// (and every role-less person) receives, so it is held to a hard rule: it must exist, be active, and NEVER be an
/// administrator role — otherwise anyone who can register would become an administrator.
/// </summary>
public sealed class AccessConfigService : IAccessConfigService
{
private readonly IRepository<AccessConfig> _config;
private readonly IRepository<Role> _roles;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public AccessConfigService(IRepository<AccessConfig> config, IRepository<Role> roles, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_roles = roles;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<AccessConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultRole)
.FirstOrDefaultAsync(c => c.ConfigId == AccessConfig.SingletonId, ct)
?? throw new NotFoundException("Access configuration has not been seeded.");
return new ETagged<AccessConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<AccessConfigDto>> UpdateAsync(
UpdateAccessConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(AccessConfig.SingletonId, ct)
?? throw new NotFoundException("Access configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
if (request.DefaultRoleId is int roleId)
{
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
if (role.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Role '{role.Name}' is inactive and cannot be the default.", 422);
if (role.IsAdmin)
throw new DomainException(ErrorCodes.Validation, "The administrator role cannot be the default role — everyone who signs in would become an administrator.", 422);
}
config.DefaultRoleId = request.DefaultRoleId;
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 await GetAsync(ct);
}
private static AccessConfigDto Map(AccessConfig c) => new(c.DefaultRoleId, c.DefaultRole?.Name, c.UpdatedAt, c.UpdatedBy);
}
@@ -89,5 +89,5 @@ public sealed class ApprovalInboxService
}
private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct)
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleCode, navCode, RolePermissionAction.Approve, ct);
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleId, navCode, RolePermissionAction.Approve, ct);
}
+31 -17
View File
@@ -18,20 +18,21 @@ namespace ERPCore.Services;
/// </summary>
public sealed class ApprovalService : IApprovalService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<ApprovalConfig> _config;
private readonly IRepository<DocumentApproval> _approvals;
private readonly IRepository<User> _users;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Role> _roles;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public ApprovalService(
IRepository<ApprovalConfig> config, IRepository<DocumentApproval> approvals, IRepository<User> users,
IRepository<RolePermission> rolePermissions, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow)
IRepository<RolePermission> rolePermissions, IRepository<Role> roles, ICurrentUser currentUser,
INotificationService notifications, IUnitOfWork uow)
{
_roles = roles;
_config = config;
_approvals = approvals;
_users = users;
@@ -56,8 +57,15 @@ public sealed class ApprovalService : IApprovalService
config.StockAdjustmentLevel2ThresholdValue = Math.Max(0m, request.StockAdjustmentLevel2ThresholdValue);
config.PriceAdjustmentLevels = Math.Clamp(request.PriceAdjustmentLevels, 0, 2);
config.PriceAdjustmentLevel2ThresholdPercent = Math.Max(0m, request.PriceAdjustmentLevel2ThresholdPercent);
var roles = request.Level2RoleCodes.Select(r => r.Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
config.Level2RoleCodes = roles.Count == 0 ? null : string.Join(",", roles);
var roleIds = request.Level2RoleIds.Where(id => id > 0).Distinct().ToList();
if (roleIds.Count > 0)
{
var known = await _roles.Query().AsNoTracking().Where(r => roleIds.Contains(r.RoleId)).Select(r => r.RoleId).ToListAsync(ct);
var unknown = roleIds.Except(known).ToList();
if (unknown.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Role(s) not found: {string.Join(", ", unknown)}.", 422);
}
config.Level2RoleIds = roleIds.Count == 0 ? null : string.Join(",", roleIds);
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -117,9 +125,13 @@ public sealed class ApprovalService : IApprovalService
return ("You gave the first approval — a different person has to give the second.", ErrorCodes.ApprovalSamePerson);
if (target.CurrentLevel >= 2)
{
var roles = Level2Roles(await LoadConfigAsync(ct));
if (roles.Count > 0 && !_currentUser.IsAdmin && !roles.Contains(_currentUser.RoleCode ?? string.Empty))
return ($"Only {string.Join(", ", roles)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
var allowed = Level2RoleIds(await LoadConfigAsync(ct));
if (allowed.Count > 0 && !_currentUser.IsAdmin && !(_currentUser.RoleId is int roleId && allowed.Contains(roleId)))
{
var names = await _roles.Query().AsNoTracking().Where(r => allowed.Contains(r.RoleId)).OrderBy(r => r.Name)
.Select(r => r.Name).ToListAsync(ct);
return ($"Only {string.Join(", ", names)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
}
}
return (null, null);
}
@@ -141,15 +153,15 @@ public sealed class ApprovalService : IApprovalService
var approverRoleIds = grants.Where(g => g.Actions.HasFlag(RolePermissionAction.Approve)).Select(g => g.RoleId).ToHashSet();
var candidates = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null)
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleCode = u.Role!.Code })
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null && u.Role!.Status == EntityStatus.Active)
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleIsAdmin = u.Role!.IsAdmin })
.ToListAsync(ct);
var level2Roles = target.CurrentLevel >= 2 ? Level2Roles(await LoadConfigAsync(ct)) : [];
var level2Roles = target.CurrentLevel >= 2 ? Level2RoleIds(await LoadConfigAsync(ct)) : [];
return candidates
.Where(u => string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase) || approverRoleIds.Contains(u.RoleId))
.Where(u => u.RoleIsAdmin || approverRoleIds.Contains(u.RoleId))
.Where(u => u.UserId != target.RequestedBy && u.UserId != target.Level1ApprovedBy)
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleCode) || string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase))
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleId) || u.RoleIsAdmin)
.Select(u => u.UserId)
.ToList();
}
@@ -166,13 +178,15 @@ public sealed class ApprovalService : IApprovalService
=> await _config.Query().AsNoTracking().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct)
?? new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
private static HashSet<string> Level2Roles(ApprovalConfig c)
=> (c.Level2RoleCodes ?? string.Empty)
private static HashSet<int> Level2RoleIds(ApprovalConfig c)
=> (c.Level2RoleIds ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
.Select(s => int.TryParse(s, out var id) ? id : 0)
.Where(id => id > 0)
.ToHashSet();
private static ApprovalConfigDto Map(ApprovalConfig c) => new(
c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue,
c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent,
Level2Roles(c).ToList(), c.UpdatedAt);
Level2RoleIds(c).Order().ToList(), c.UpdatedAt);
}
@@ -15,6 +15,9 @@ public sealed class AuthUserService : IAuthUserService
public async Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default)
{
request.UserId ??= Guid.NewGuid();
// A self-registering person never chooses their role (this used to accept any AuthHex role id, including an
// admin one). They sign in with the ERP's default role until an administrator assigns a real one.
request.RoleId = null;
var result = await _authHex.RegisterAsync(request, ct);
return ToSessionResult(result);
}
@@ -209,7 +209,12 @@ public sealed class BankDepositService : IBankDepositService
private async Task<(int BranchId, int? CashScope)> ResolveBranchAsync(int requested, CancellationToken ct)
{
if (_currentBranch.IsActive && _currentBranch.BranchId is { } own)
{
// A branch-less person in a branching deployment has no branch to deposit for.
if (own == CurrentBranchContext.NoBranchId)
throw new DomainException(ErrorCodes.Validation, "Your account is not linked to a branch, so you cannot record bank deposits. Ask an administrator to link your employee record to a branch.", 403);
return (own, own);
}
var branchId = await _branchConfig.ResolveBranchIdAsync(requested, ct);
return await _branchConfig.IsBranchingEnabledAsync(ct) ? (branchId, branchId) : (branchId, null);
+53 -16
View File
@@ -139,6 +139,7 @@ public sealed class CustomerService : ICustomerService
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
}
var fields = await GetFieldSwitchesAsync(ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customer = new Customer
{
@@ -146,15 +147,15 @@ public sealed class CustomerService : ICustomerService
CustomerType = request.CustomerType,
Name = name,
DisplayName = Normalize(request.DisplayName),
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
AddressLine1 = Normalize(request.AddressLine1),
AddressLine2 = Normalize(request.AddressLine2),
City = Normalize(request.City),
Country = Normalize(request.Country),
Phone = Gate(fields.Phone, request.Phone, null, "phone numbers"),
Email = Gate(fields.Email, request.Email, null, "email addresses"),
AddressLine1 = Gate(fields.Address, request.AddressLine1, null, "addresses"),
AddressLine2 = Gate(fields.Address, request.AddressLine2, null, "addresses"),
City = Gate(fields.City, request.City, null, "cities"),
Country = Gate(fields.Country, request.Country, null, "countries"),
TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
CreditLimit = request.CreditLimit,
CreditDays = request.CreditDays,
CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, 0m, "credit limits"),
CreditDays = Gate(fields.CreditDays, request.CreditDays, 0, "credit days"),
DefaultWarehouseId = request.DefaultWarehouseId,
Status = EntityStatus.Active,
BranchId = branchId,
@@ -197,15 +198,17 @@ public sealed class CustomerService : ICustomerService
customer.CustomerType = request.CustomerType;
customer.Name = name;
customer.DisplayName = Normalize(request.DisplayName);
customer.Phone = Normalize(request.Phone);
customer.Email = Normalize(request.Email);
customer.AddressLine1 = Normalize(request.AddressLine1);
customer.AddressLine2 = Normalize(request.AddressLine2);
customer.City = Normalize(request.City);
customer.Country = Normalize(request.Country);
// A field switched off in Sales Configuration keeps whatever is stored; only a *different* value is refused.
var fields = await GetFieldSwitchesAsync(ct);
customer.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers");
customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses");
customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses");
customer.AddressLine2 = Gate(fields.Address, request.AddressLine2, customer.AddressLine2, "addresses");
customer.City = Gate(fields.City, request.City, customer.City, "cities");
customer.Country = Gate(fields.Country, request.Country, customer.Country, "countries");
customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
customer.CreditLimit = request.CreditLimit;
customer.CreditDays = request.CreditDays;
customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits");
customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days");
customer.DefaultWarehouseId = request.DefaultWarehouseId;
customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
customer.UpdatedAt = DateTime.UtcNow;
@@ -238,4 +241,38 @@ public sealed class CustomerService : ICustomerService
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Which optional customer fields Sales Configuration has switched on (all on when the row is missing).</summary>
private sealed record FieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays);
private async Task<FieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new FieldSwitches(
c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled,
c.CustomerCountryEnabled, c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled))
.FirstOrDefaultAsync(ct)
?? new FieldSwitches(true, true, true, true, true, true, true);
/// <summary>On: the (trimmed) requested value. Off: the stored value is kept; a different value is a 422 CONFIG_DISABLED
/// (the UI hides the field, so only a stale or hand-built request gets here).</summary>
private static string? Gate(bool enabled, string? requested, string? stored, string what)
{
var value = Normalize(requested);
if (enabled) return value;
if (value is null || string.Equals(value, stored, StringComparison.Ordinal)) return stored;
throw new DomainException(ErrorCodes.ConfigDisabled, $"Customer {what} are turned off in Sales configuration.", 422);
}
private static decimal Gate(bool enabled, decimal requested, decimal stored, string what)
{
if (enabled || requested == 0m || requested == stored) return enabled ? requested : stored;
throw new DomainException(ErrorCodes.ConfigDisabled, $"Customer {what} are turned off in Sales configuration.", 422);
}
private static int Gate(bool enabled, int requested, int stored, string what)
{
if (enabled || requested == 0 || requested == stored) return enabled ? requested : stored;
throw new DomainException(ErrorCodes.ConfigDisabled, $"Customer {what} are turned off in Sales configuration.", 422);
}
}
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
@@ -13,7 +14,11 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
PropertyNameCaseInsensitive = true,
// GL's request DTOs have non-nullable strings (e.g. JournalType) that its [ApiController] validation treats as
// required: an explicit `"journalType": null` is a 400, while an omitted property just takes the default.
// So never send nulls.
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IGeneralLedgerClient _client;
@@ -82,7 +87,9 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
{
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
var message = (string.IsNullOrWhiteSpace(envelope?.Message) ? null : envelope!.Message)
?? DescribeProblem(response.Body)
?? "The General Ledger service rejected the request.";
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
}
@@ -90,6 +97,43 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
return envelope.Data;
}
/// <summary>
/// GL answers some failures (request-validation errors) with an ASP.NET ProblemDetails body rather than its own
/// envelope; turn that into a readable sentence instead of a generic "rejected the request".
/// </summary>
private static string? DescribeProblem(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return null;
var details = new List<string>();
foreach (var prop in root.EnumerateObject())
{
if (!string.Equals(prop.Name, "errors", StringComparison.OrdinalIgnoreCase) || prop.Value.ValueKind != JsonValueKind.Object) continue;
foreach (var field in prop.Value.EnumerateObject())
{
var text = field.Value.ValueKind == JsonValueKind.Array
? string.Join(" ", field.Value.EnumerateArray().Select(v => v.GetString()))
: field.Value.ToString();
details.Add($"{field.Name}: {text}");
}
}
if (details.Count > 0) return "The General Ledger service rejected the request — " + string.Join("; ", details);
if (root.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String)
return "The General Ledger service rejected the request — " + title.GetString();
}
catch (JsonException)
{
// Not JSON at all (a proxy page, say): fall through to the generic message.
}
return null;
}
private sealed class GlEnvelope<T>
{
public int StatusCode { get; set; }
+2
View File
@@ -12,6 +12,8 @@ public sealed class GlJournalEntryRequest
{
public int PeriodId { get; set; }
public DateOnly EntryDate { get; set; }
/// <summary>Free-text journal type (max 30) — e.g. OPENING_BALANCE. Left null by the flows that never set one.</summary>
public string? JournalType { get; set; }
public string? SourceModule { get; set; }
public string? Reference { get; set; }
public string? Narration { get; set; }

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