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:
@@ -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<->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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Generated
+10917
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11131
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11366
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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user