Compare commits

...

46 Commits

Author SHA1 Message Date
Naveen cb6b92d221 Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-19 00:12:41 +05:30
ImanThiyanga 3f10ae3bca changes fix for production 2026-09-19 00:00:42 +05:30
ImanThiyanga 61fe1a0e0c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Successful in 31s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 13m12s
2026-09-18 18:23:35 +00:00
ImanThiyanga 941f20d4c5 migrations fix 2026-09-18 23:52:26 +05:30
kalana_Thilakarathna b06d20e649 Merge pull request 'test to yml' (#71) from Dev into production
Reviewed-on: #71
2026-09-18 05:23:55 +00:00
Kalana B. Thilakarathna 445d323bed test to yml
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m24s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m59s
2026-09-18 10:52:17 +05:30
kalana_Thilakarathna 9028255800 Merge pull request 'workflowupdate' (#70) from Dev into production
Reviewed-on: #70
2026-09-18 04:51:58 +00:00
Kalana B. Thilakarathna 1b2f244853 workflowupdate
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m21s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m6s
2026-09-18 10:21:36 +05:30
kalana_Thilakarathna 475bcfec1a Merge pull request 'pipeline update' (#69) from Dev into production
Reviewed-on: #69
2026-09-18 04:48:49 +00:00
Kalana B. Thilakarathna 85db26ec82 pipeline update
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m30s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 10:18:13 +05:30
kalana_Thilakarathna baee987d26 Merge pull request 'test' (#68) from Dev into production
Reviewed-on: #68
2026-09-17 08:25:31 +00:00
Kalana B. Thilakarathna f45a59f20d test
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m57s
2026-09-17 13:55:06 +05:30
kalana_Thilakarathna 32304cbaff Merge pull request 'user fix at workflow' (#67) from Dev into production
Reviewed-on: #67
2026-09-17 07:35:02 +00:00
Kalana B. Thilakarathna 2b64bb0ebc user fix at workflow
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m17s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 13:04:15 +05:30
kalana_Thilakarathna ffe5251236 Merge pull request 'workflow fix' (#66) from Dev into production
Reviewed-on: #66
2026-09-17 07:26:14 +00:00
Kalana B. Thilakarathna d04a7fb3bc workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Has been cancelled
2026-09-17 12:55:38 +05:30
kalana_Thilakarathna d76595085e Merge pull request 'workflow fix' (#65) from Dev into production
Reviewed-on: #65
2026-09-17 07:08:22 +00:00
Kalana B. Thilakarathna bad33a8492 workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m57s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 12:38:05 +05:30
kalana_Thilakarathna b0216fca30 Merge pull request 'triger erpo core workflow' (#64) from Dev into production
Reviewed-on: #64
2026-09-17 07:07:24 +00:00
Kalana B. Thilakarathna 49c112d188 triger erpo core workflow 2026-09-17 12:37:04 +05:30
kalana_Thilakarathna c836b57d54 Merge pull request 'gitea work flow update' (#63) from Dev into production
Reviewed-on: #63
2026-09-17 07:06:02 +00:00
Kalana B. Thilakarathna 3a583f80e5 gitea work flow update 2026-09-17 12:28:31 +05:30
ImanThiyanga 83e0335504 Merge pull request 'Dev' (#62) from Dev into production
Reviewed-on: #62
2026-09-17 05:58:25 +00:00
ImanThiyanga 513343b62a Merge pull request 'Revert/ci cd merges' (#61) from revert/ci-cd-merges into Dev
Reviewed-on: #61
2026-09-17 05:57:56 +00:00
ImanThiyanga 256e39ccff Revert "Merge pull request 'Dev' (#58) from Dev into production"
This reverts commit 821e96e011, reversing
changes made to 0ff39e9e58.
2026-09-17 11:25:33 +05:30
ImanThiyanga efb37fce7a Revert "Merge pull request 'Dev' (#60) from Dev into production"
This reverts commit 01fc2582b6, reversing
changes made to 821e96e011.
2026-09-17 11:25:33 +05:30
ImanThiyanga 01fc2582b6 Merge pull request 'Dev' (#60) from Dev into production
Reviewed-on: #60
2026-09-16 18:47:15 +00:00
ImanThiyanga 39a2dc60be Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
2026-09-16 18:30:42 +00:00
ImanThiyanga 3b87a6e421 Merge branch 'Dev' into feat/ci-cd-implementation
CI/CD - ERP Core / detect (pull_request) Waiting to run
CI/CD - ERP Core / backend (pull_request) Blocked by required conditions
CI/CD - ERP Core / frontend (pull_request) Blocked by required conditions
CI/CD - ERP Core / e2e-typecheck (pull_request) Blocked by required conditions
CI/CD - ERP Core / scripts (pull_request) Blocked by required conditions
CI/CD - ERP Core / ci-ok (pull_request) Blocked by required conditions
CI/CD - ERP Core / Build & Deploy (pull_request) Waiting to run
2026-09-16 18:30:22 +00:00
ImanThiyanga 2a7a44cc27 change in to merge
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
2026-09-16 23:48:07 +05:30
ImanThiyanga c7cf63e7de initiating connection fixes 2026-09-16 23:47:47 +05:30
ImanThiyanga d1044e4728 final fix 2026-09-16 13:53:57 +05:30
ImanThiyanga 821e96e011 Merge pull request 'Dev' (#58) from Dev into production
Reviewed-on: #58
2026-09-16 06:37:25 +00:00
ImanThiyanga d6f973202d Merge branch 'production' into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 10m38s
CI/CD - ERP Core / detect (pull_request) Failing after 11m30s
CI/CD - ERP Core / scripts (pull_request) Failing after 11m31s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 12m23s
CI/CD - ERP Core / frontend (pull_request) Failing after 13m14s
CI/CD - ERP Core / backend (pull_request) Failing after 14m6s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m59s
2026-09-16 06:36:19 +00:00
ImanThiyanga 1d5a5cf68e Merge pull request 'Feat/ci cd implementation' (#57) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / detect (pull_request) Has been cancelled
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Has been cancelled
Reviewed-on: #57
2026-09-16 06:24:55 +00:00
ImanThiyanga 8e472ac996 initialising for ci-cd pipeline
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
2026-09-16 11:53:55 +05:30
ImanThiyanga 666979cf22 initiated ci-cd pipeline for the erp core with fronend changes to detect changes on both front and back 2026-09-16 11:53:17 +05:30
ashan_rusiru eb36e0acfa Merge pull request 'Users and employees arrangements' (#56) from users-and-employees-arrangements into Dev
Reviewed-on: #56
2026-09-14 05:23:25 +00:00
ashan_rusiru 0ff39e9e58 Merge pull request 'Users and employees arrangements' (#53) from users-and-employees-arrangements into production
Reviewed-on: #53
2026-09-13 07:52:34 +00:00
ashan_rusiru cbb610fc8b Merge branch 'production' into users-and-employees-arrangements 2026-09-13 07:52:24 +00:00
ashan_rusiru afa9928dcc Merge pull request 'bug fixing' (#55) from bug-fixing into Dev
Reviewed-on: #55
2026-09-13 07:52:06 +00:00
Sasanka 99aa395fb1 updeted 2026-09-13 08:30:18 +05:30
Sasanka cb41d503c4 emloyee and user fixings 2026-09-11 10:32:47 +05:30
Sasanka 629f3d1f44 bug fixing 2026-09-09 14:38:53 +05:30
ImanThiyanga b3b6a25ce8 Merge pull request 'Branch crud' (#52) from branch-crud into Dev
Reviewed-on: #52
2026-09-08 07:13:47 +00:00
ImanThiyanga b4993b202d Merge pull request 'Dev' (#45) from Dev into production
Reviewed-on: #45
2026-08-13 06:52:56 +00:00
198 changed files with 64975 additions and 2752 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
+106
View File
@@ -0,0 +1,106 @@
# test
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
backend-build-and-deploy:
name: Build & Deploy ERP Core Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore backend
working-directory: .gitea/workflows
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop backend service
run: systemctl stop erpcore.service
- name: Copy backend to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix backend ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart backend service
run: systemctl restart erpcore.service
- name: Verify backend
run: |
systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"
frontend-build-and-deploy:
name: Build & Deploy ERP Core Frontend
runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch Production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build frontend Docker image
working-directory: .gitea/workflows
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system
- name: Stop & remove existing frontend container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start frontend container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old frontend images
run: docker image prune -f
- name: Verify frontend
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
private readonly IAuthRecoveryService _recovery; private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt; private readonly IAuthAltService _alt;
private readonly IRoleService _roles; private readonly IRoleService _roles;
private readonly IAuditService _audit;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles) public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{ {
_users = users; _users = users;
_recovery = recovery; _recovery = recovery;
_alt = alt; _alt = alt;
_roles = roles; _roles = roles;
_audit = audit;
} }
/// <summary> /// <summary>
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
{ {
var result = await _users.LoginAsync(request, ct); var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body); return Ok(result.Body);
} }
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
{ {
var result = await _users.VerifyOtpForLoginAsync(request, ct); var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn); AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body); return Ok(result.Body);
} }
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
[HttpPost("refresh-token")] [HttpPost("refresh-token")]
[AllowAnonymous] [AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
await _branches.SetStatusAsync(branchId, request.Status, ct); await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
{
await _branches.DeleteAsync(branchId, ct);
return NoContent();
}
} }
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
await _departments.SetStatusAsync(departmentId, request.Status, ct); await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
{
await _departments.DeleteAsync(departmentId, ct);
return NoContent();
}
} }
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
await _designations.SetStatusAsync(designationId, request.Status, ct); await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
{
await _designations.DeleteAsync(designationId, ct);
return NoContent();
}
} }
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[HttpPatch("{employeeId:int}/status")] [HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent(); return NoContent();
} }
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
{
await _employees.DeleteAsync(employeeId, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")] [HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct); await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
{
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
return NoContent();
}
} }
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct); await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
{
await _types.DeleteAsync(hrDocumentTypeId, ct);
return NoContent();
}
} }
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct); await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
{
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
return NoContent();
}
} }
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
await _components.SetStatusAsync(salaryComponentId, request.Status, ct); await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
{
await _components.DeleteAsync(salaryComponentId, ct);
return NoContent();
}
} }
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
await _shifts.SetStatusAsync(workShiftId, request.Status, ct); await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent(); return NoContent();
} }
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
{
await _shifts.DeleteAsync(workShiftId, ct);
return NoContent();
}
} }
+14 -2
View File
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
@@ -15,8 +16,9 @@ public sealed class UomsController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct) public async Task<ActionResult<PagedResponse<UomDto>>> List(
=> Ok(await _uoms.ListAsync(query, ct)); [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -26,4 +28,14 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct); var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto); return Created($"/api/v1/uoms/{dto.UomId}", dto);
} }
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
} }
+21 -3
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
@@ -16,12 +18,14 @@ public sealed class UsersController : ApiControllerBase
{ {
private readonly IUserManagementService _users; private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links; private readonly IEmployeeUserLinkService _links;
private readonly IAuditService _audit;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser) public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{ {
_users = users; _users = users;
_links = links; _links = links;
_audit = audit;
_currentUser = currentUser; _currentUser = currentUser;
} }
@@ -33,8 +37,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct) public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct)); => Ok(await _users.ListAsync(query, status, roleCode, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary> /// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")] [HttpGet("user-types")]
@@ -65,4 +69,18 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct) public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct)); => Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
} }
@@ -15,8 +15,9 @@ public sealed class WarehousesController : ApiControllerBase
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct) public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
=> Ok(await _warehouses.ListAsync(query, ct)); [FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")] [HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
+4 -2
View File
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login /// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an /// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link /// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted — /// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>. /// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
/// and cascades to every dependent HR record, blocked only if payroll history exists.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued): /// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's /// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset". /// year-scoping is the wrong shape for an identifier that must never look "reset".
+5
View File
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft; public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; } public int CreatedBy { get; set; }
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
/// admin can see a user's device/login history — AuthHex's own session listing only
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
/// active sessions" view (that would need AuthHex to expose one).
/// </summary>
public class LoginActivity
{
public int LoginActivityId { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
public string? DeviceLabel { get; set; }
public DateTime LoginAt { get; set; }
}
@@ -17,6 +17,11 @@ public class SalesInvoice
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft; public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
@@ -19,6 +19,11 @@ public class StockAdjustment
public int ReasonCodeId { get; set; } public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; } public ReasonCode? ReasonCode { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted; public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; } public int CreatedBy { get; set; }
@@ -16,6 +16,11 @@ public class StockCount
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public CountType CountType { get; set; } public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft; public CountStatus Status { get; set; } = CountStatus.Draft;
@@ -19,6 +19,11 @@ public class StockTransfer
public int DestWarehouseId { get; set; } public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; } public Warehouse? DestWarehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from src/dest warehouse.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft; public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; } public int CreatedBy { get; set; }
+6
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
@@ -10,4 +12,8 @@ public class Uom
{ {
public int UomId { get; set; } public int UomId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
} }
+4 -1
View File
@@ -5,5 +5,8 @@ public enum AuditAction
{ {
Create, Create,
Update, Update,
Delete Delete,
/// <summary>A successful sign-in — written explicitly (not via ErpDbContext's automatic
/// entity-change capture, since a login mutates nothing) by AuthController.</summary>
Login
} }
+4
View File
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary> /// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
public sealed record JournalEntryStubDto( public sealed record JournalEntryStubDto(
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount); int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
/// <summary>A captured past sign-in — device history, not a live "currently active sessions" view.</summary>
public sealed record LoginActivityDto(
int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
+5 -2
View File
@@ -15,13 +15,13 @@ public sealed record GrnLineDto(
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers); HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto( public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines); int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto( public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo); int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
@@ -81,6 +81,9 @@ public sealed class CreateGrnRequest
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary> /// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public int? VendorId { get; set; } public int? VendorId { get; set; }
[Required] public int WarehouseId { 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>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
} }
@@ -15,14 +15,14 @@ public sealed record SalesInvoiceTotalsDto(
public sealed record SalesInvoiceDto( public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, int BranchId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt, SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt, DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines); SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
public sealed record SalesInvoiceSummaryDto( public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType, string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo); SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record SalesInvoicePaymentDto( public sealed record SalesInvoicePaymentDto(
@@ -65,6 +65,9 @@ public sealed class CreateSalesInvoiceRequest
{ {
[Required] public int CustomerId { get; set; } [Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
} }
@@ -73,6 +76,9 @@ public sealed class UpdateSalesInvoiceRequest
{ {
[Required] public int CustomerId { get; set; } [Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C; [Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
} }
+5 -2
View File
@@ -8,13 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta); public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto( public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt, int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs); IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary> /// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto( public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo); int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ---------------------------------------------------------------------- // Requests ----------------------------------------------------------------------
@@ -31,6 +31,9 @@ public sealed class CreateAdjustmentLineInput
public sealed class CreateAdjustmentRequest public sealed class CreateAdjustmentRequest
{ {
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary> /// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public int? ReasonCodeId { get; set; } public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
+5 -2
View File
@@ -8,12 +8,12 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance); public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto( public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines); int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto( public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount); int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs); public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
@@ -23,6 +23,9 @@ public sealed record CountPostResultDto(int CountId, CountStatus Status, int? Ad
public sealed class CreateCountRequest public sealed class CreateCountRequest
{ {
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; } [Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new(); [Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
} }
+5 -2
View File
@@ -9,12 +9,12 @@ public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived); int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto( public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines); TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary> /// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto( public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount); TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost); public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
@@ -42,6 +42,9 @@ public sealed class CreateTransferRequest
{ {
[Required] public int SrcWarehouseId { get; set; } [Required] public int SrcWarehouseId { get; set; }
[Required] public int DestWarehouseId { get; set; } [Required] public int DestWarehouseId { get; set; }
/// <summary>Owning branch for this transfer — warehouses are shared, so this is what
/// actually scopes the document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
} }
+7 -1
View File
@@ -1,11 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Uoms; namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(int UomId, string Name); public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateUomRequest public sealed class CreateUomRequest
{ {
[Required, StringLength(50)] public string Name { get; set; } = string.Empty; [Required, StringLength(50)] public string Name { get; set; } = string.Empty;
} }
public sealed class UpdateUomStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+5
View File
@@ -53,5 +53,10 @@ public sealed class UpdateUserRoleRequest
[Required] public int RoleId { get; set; } [Required] public int RoleId { get; set; }
} }
public sealed class UpdateUserStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary> /// <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); public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -0,0 +1,39 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
/// login-device history — not a full parsing library, just enough to be readable
/// (docs call this out: exact device model detection is out of scope).
/// </summary>
public static class UserAgentLabeler
{
public static string Label(string? userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
var os = DetectOs(userAgent);
var browser = DetectBrowser(userAgent);
return $"{browser} on {os}";
}
private static string DetectOs(string ua)
{
if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
return "an unknown OS";
}
private static string DetectBrowser(string ua)
{
// Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
return "an unknown browser";
}
}
@@ -21,7 +21,7 @@ public static class AuditScribe
private static readonly HashSet<Type> Excluded = private static readonly HashSet<Type> Excluded =
[ [
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence), typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
typeof(StockLedger), typeof(StockLayer), typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
]; ];
private static readonly JsonSerializerOptions Json = new() private static readonly JsonSerializerOptions Json = new()
@@ -22,11 +22,13 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Branch).WithMany().HasForeignKey(g => g.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status); builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId); builder.HasIndex(g => g.PoId);
builder.HasIndex(g => g.BranchId);
} }
} }
@@ -0,0 +1,23 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LoginActivityConfiguration : IEntityTypeConfiguration<LoginActivity>
{
public void Configure(EntityTypeBuilder<LoginActivity> builder)
{
builder.ToTable("login_activities");
builder.HasKey(l => l.LoginActivityId);
builder.Property(l => l.IpAddress).HasMaxLength(64);
builder.Property(l => l.UserAgent).HasMaxLength(500);
builder.Property(l => l.DeviceLabel).HasMaxLength(200);
builder.Property(l => l.LoginAt).IsRequired();
builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(l => new { l.UserId, l.LoginAt });
}
}
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
builder.HasData( builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 }, new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 }, new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 }, // NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
// sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 }, new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 }, new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 }, new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
builder.HasData( builder.HasData(
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 }, new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 }, new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 }, // Moved under Procurement as a sub-item (2026-09-09) — same PermissionId (3), just
// repointed to the new SubNavItem, so any role already granted this permission keeps it.
new Permission { PermissionId = 3, Code = "NAV:procurement.vendors", SubNavItemId = 30 },
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 }, new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 }, new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 }, new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
@@ -67,7 +69,21 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 }, new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }, new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 }, new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 } new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 },
new Permission { PermissionId = 41, Code = "NAV:stock.ledger", SubNavItemId = 31 },
new Permission { PermissionId = 42, Code = "NAV:stock.enquiry", SubNavItemId = 32 },
new Permission { PermissionId = 43, Code = "NAV:stock.valuation", SubNavItemId = 33 },
new Permission { PermissionId = 44, Code = "NAV:stock.transfers", SubNavItemId = 34 },
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments", SubNavItemId = 35 },
new Permission { PermissionId = 46, Code = "NAV:stock.counts", SubNavItemId = 36 },
new Permission { PermissionId = 47, Code = "NAV:stock.reorder-alerts", SubNavItemId = 37 },
new Permission { PermissionId = 48, Code = "NAV:stock.wastage", SubNavItemId = 38 },
// Sales (NavItemId 13) children — SubNavItemId 23 had no permission row before now
// (mirrors SubNavItemConfiguration's rename of that row to sales.bundle-templates).
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
); );
} }
} }
@@ -30,6 +30,11 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
.HasForeignKey(x => x.WarehouseId) .HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Branch)
.WithMany()
.HasForeignKey(x => x.BranchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.InvoiceType) builder.Property(x => x.InvoiceType)
.HasConversion<string>().HasMaxLength(20).IsRequired() .HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceType.B2C); .HasDefaultValue(SalesInvoiceType.B2C);
@@ -47,6 +52,7 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
builder.HasIndex(x => x.Status); builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.InvoiceDate); builder.HasIndex(x => x.InvoiceDate);
builder.HasIndex(x => x.BranchId);
builder.HasMany(x => x.Lines) builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesInvoice) .WithOne(x => x.SalesInvoice)
@@ -21,7 +21,10 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Branch).WithMany().HasForeignKey(a => a.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict); builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => a.BranchId);
} }
} }
@@ -20,9 +20,11 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCoun
builder.Property(c => c.RowVersion).IsRowVersion(); builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Branch).WithMany().HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict); builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status); builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.BranchId);
} }
} }
@@ -20,9 +20,11 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockT
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Branch).WithMany().HasForeignKey(t => t.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict); builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status); builder.HasIndex(t => t.Status);
builder.HasIndex(t => t.BranchId);
} }
} }
@@ -35,10 +35,19 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 }, new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 }, new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 }, new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 }, // Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
// Procurement (NavItemId 4) children — mirror the hub page order. // Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below; // IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now. // these procurement rows were never actually migrated into the database before now.
// Vendors (SubNavItemId 30) reuses PermissionId 3, which used to point at the retired
// top-level "vendors" NavItem — so any role already granted this permission keeps it.
new SubNavItem { SubNavItemId = 30, NavItemId = 4, Code = "procurement.vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 0 },
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 }, 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 = 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 = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
@@ -64,7 +73,18 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
// FixProductionTemplatesSubNavItemIdConflict for the data repair. // FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 }, new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 }, new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 } new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 },
// Stock (NavItemId 6) children — mirror the sidebar order (AppSidebar.tsx). These rows
// never existed in the database before now, so "stock" is still bypassed frontend-side
// in AppSidebar.tsx until roles are actually granted these permissions.
new SubNavItem { SubNavItemId = 31, NavItemId = 6, Code = "stock.ledger", Label = "Stock Ledger", Href = "/dashboard/stock/ledger", SortOrder = 1 },
new SubNavItem { SubNavItemId = 32, NavItemId = 6, Code = "stock.enquiry", Label = "Stock Enquiry", Href = "/dashboard/stock/enquiry", SortOrder = 2 },
new SubNavItem { SubNavItemId = 33, NavItemId = 6, Code = "stock.valuation", Label = "Valuation", Href = "/dashboard/stock/valuation", SortOrder = 3 },
new SubNavItem { SubNavItemId = 34, NavItemId = 6, Code = "stock.transfers", Label = "Transfers", Href = "/dashboard/stock/transfers", SortOrder = 4 },
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 }
); );
} }
} }
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -13,5 +14,13 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
builder.Property(u => u.Name).IsRequired().HasMaxLength(50); builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
builder.HasIndex(u => u.Name).IsUnique(); builder.HasIndex(u => u.Name).IsUnique();
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Status);
} }
} }
@@ -114,6 +114,7 @@ public class ErpDbContext : DbContext
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) --- // --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>(); public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
public DbSet<LoginActivity> LoginActivities => Set<LoginActivity>();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) --- // --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet<Branch> Branches => Set<Branch>(); public DbSet<Branch> Branches => Set<Branch>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchIdToDocuments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_transfers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_counts",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_adjustments",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "sales_invoices",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "grns",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_grns_BranchId",
table: "grns",
column: "BranchId");
// Backfill from each document's existing warehouse — warehouses were the sole
// branch anchor until now, so this preserves current branch assignment exactly.
// (Warehouse.BranchId is NOT NULL, so every row is resolvable; the WHERE BranchId = 0
// guard just keeps this idempotent if the migration is ever re-run against partial state.)
migrationBuilder.Sql(@"
UPDATE grns g SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = g.""WarehouseId"" AND g.""BranchId"" = 0;
UPDATE sales_invoices x SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = x.""WarehouseId"" AND x.""BranchId"" = 0;
UPDATE stock_counts c SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = c.""WarehouseId"" AND c.""BranchId"" = 0;
UPDATE stock_adjustments a SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = a.""WarehouseId"" AND a.""BranchId"" = 0;
UPDATE stock_transfers t SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = t.""SrcWarehouseId"" AND t.""BranchId"" = 0;
");
migrationBuilder.AddForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns");
migrationBuilder.DropForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices");
migrationBuilder.DropForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments");
migrationBuilder.DropForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts");
migrationBuilder.DropForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts");
migrationBuilder.DropIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments");
migrationBuilder.DropIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_BranchId",
table: "grns");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_transfers");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_counts");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "BranchId",
table: "sales_invoices");
migrationBuilder.DropColumn(
name: "BranchId",
table: "grns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MoveVendorsUnderProcurement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Order matters: Permission.NavItemId cascades on delete, so the permission row must be
// repointed off NavItemId 3 (via the new sub_nav_item) before that nav_item is deleted —
// otherwise the delete cascades and removes the permission row first.
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 30, "procurement.vendors", "/dashboard/vendors", null, "Vendors", 4, 0 });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 3,
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
values: new object[] { "NAV:procurement.vendors", null, 30 });
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 30);
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder", "Status" },
values: new object[] { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3, "Active" });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 3,
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
values: new object[] { "NAV:vendors", 3, null });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddStockSubNavItems : 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[,]
{
{ 31, "stock.ledger", "/dashboard/stock/ledger", null, "Stock Ledger", 6, 1 },
{ 32, "stock.enquiry", "/dashboard/stock/enquiry", null, "Stock Enquiry", 6, 2 },
{ 33, "stock.valuation", "/dashboard/stock/valuation", null, "Valuation", 6, 3 },
{ 34, "stock.transfers", "/dashboard/stock/transfers", null, "Transfers", 6, 4 },
{ 35, "stock.adjustments", "/dashboard/stock/adjustments", null, "Adjustments", 6, 5 },
{ 36, "stock.counts", "/dashboard/stock/counts", null, "Counts", 6, 6 },
{ 37, "stock.reorder-alerts", "/dashboard/stock/reorder-alerts", null, "Reorder Alerts", 6, 7 },
{ 38, "stock.wastage", "/dashboard/stock/wastage", null, "Wastage", 6, 8 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 41, "NAV:stock.ledger", null, 31 },
{ 42, "NAV:stock.enquiry", null, 32 },
{ 43, "NAV:stock.valuation", null, 33 },
{ 44, "NAV:stock.transfers", null, 34 },
{ 45, "NAV:stock.adjustments", null, 35 },
{ 46, "NAV:stock.counts", null, 36 },
{ 47, "NAV:stock.reorder-alerts", null, 37 },
{ 48, "NAV:stock.wastage", null, 38 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 41);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 42);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 43);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 44);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 45);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 46);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 47);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 48);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 31);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 32);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 33);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 34);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 38);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesSubNavItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 49, "NAV:sales.bundle-templates", null, 23 });
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 23,
columns: new[] { "Code", "Href", "Label", "SortOrder" },
values: new object[] { "sales.bundle-templates", "/dashboard/sales/bundles/templates", "Bundle Templates", 2 });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 40, "sales.invoices", "/dashboard/sales/invoices", null, "Sales", 13, 1 },
{ 41, "sales.free-issues", "/dashboard/sales/free-issues", null, "Free Issues", 13, 3 },
{ 42, "sales.day-end", "/dashboard/sales/day-end", null, "Day End", 13, 4 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 50, "NAV:sales.invoices", null, 40 },
{ 51, "NAV:sales.free-issues", null, 41 },
{ 52, "NAV:sales.day-end", null, 42 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 49);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 50);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 51);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 52);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 40);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 41);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 42);
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 23,
columns: new[] { "Code", "Href", "Label", "SortOrder" },
values: new object[] { "sales.bundle-sales", "/dashboard/sales/bundles", "Bundle Sales", 1 });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddLoginActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "login_activities",
columns: table => new
{
LoginActivityId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
IpAddress = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
DeviceLabel = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
LoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_login_activities", x => x.LoginActivityId);
table.ForeignKey(
name: "FK_login_activities_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_login_activities_UserId_LoginAt",
table: "login_activities",
columns: new[] { "UserId", "LoginAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "login_activities");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddUomLifecycleColumnsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "uoms",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "uoms",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_uoms_Status",
table: "uoms",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_uoms_Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "uoms");
migrationBuilder.DropColumn(
name: "Status",
table: "uoms");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "uoms");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class changeMismatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1447,6 +1447,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -1493,6 +1496,8 @@ namespace ERPCore.Migrations
b.HasKey("GrnId"); b.HasKey("GrnId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
b.HasIndex("DocNo") b.HasIndex("DocNo")
@@ -2166,6 +2171,39 @@ namespace ERPCore.Migrations
b.ToTable("hr_loan_installments", (string)null); b.ToTable("hr_loan_installments", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
{
b.Property<int>("LoginActivityId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LoginActivityId"));
b.Property<string>("DeviceLabel")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("LoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("LoginActivityId");
b.HasIndex("UserId", "LoginAt");
b.ToTable("login_activities", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
{ {
b.Property<int>("NavItemId") b.Property<int>("NavItemId")
@@ -2229,15 +2267,6 @@ namespace ERPCore.Migrations
Status = "Active" Status = "Active"
}, },
new new
{
NavItemId = 3,
Code = "vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
SortOrder = 3,
Status = "Active"
},
new
{ {
NavItemId = 4, NavItemId = 4,
Code = "procurement", Code = "procurement",
@@ -2761,8 +2790,8 @@ namespace ERPCore.Migrations
new new
{ {
PermissionId = 3, PermissionId = 3,
Code = "NAV:vendors", Code = "NAV:procurement.vendors",
NavItemId = 3 SubNavItemId = 30
}, },
new new
{ {
@@ -2985,6 +3014,78 @@ namespace ERPCore.Migrations
PermissionId = 40, PermissionId = 40,
Code = "NAV:settings.branches", Code = "NAV:settings.branches",
SubNavItemId = 29 SubNavItemId = 29
},
new
{
PermissionId = 41,
Code = "NAV:stock.ledger",
SubNavItemId = 31
},
new
{
PermissionId = 42,
Code = "NAV:stock.enquiry",
SubNavItemId = 32
},
new
{
PermissionId = 43,
Code = "NAV:stock.valuation",
SubNavItemId = 33
},
new
{
PermissionId = 44,
Code = "NAV:stock.transfers",
SubNavItemId = 34
},
new
{
PermissionId = 45,
Code = "NAV:stock.adjustments",
SubNavItemId = 35
},
new
{
PermissionId = 46,
Code = "NAV:stock.counts",
SubNavItemId = 36
},
new
{
PermissionId = 47,
Code = "NAV:stock.reorder-alerts",
SubNavItemId = 37
},
new
{
PermissionId = 48,
Code = "NAV:stock.wastage",
SubNavItemId = 38
},
new
{
PermissionId = 49,
Code = "NAV:sales.bundle-templates",
SubNavItemId = 23
},
new
{
PermissionId = 50,
Code = "NAV:sales.invoices",
SubNavItemId = 40
},
new
{
PermissionId = 51,
Code = "NAV:sales.free-issues",
SubNavItemId = 41
},
new
{
PermissionId = 52,
Code = "NAV:sales.day-end",
SubNavItemId = 42
}); });
}); });
@@ -4187,6 +4288,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("numeric(18,4)"); .HasColumnType("numeric(18,4)");
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -4279,6 +4383,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceId"); b.HasKey("SalesInvoiceId");
b.HasIndex("BranchId");
b.HasIndex("CreatorUserId"); b.HasIndex("CreatorUserId");
b.HasIndex("CustomerId"); b.HasIndex("CustomerId");
@@ -4859,6 +4965,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -4896,6 +5005,8 @@ namespace ERPCore.Migrations
b.HasKey("AdjustmentId"); b.HasKey("AdjustmentId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
b.HasIndex("DocNo") b.HasIndex("DocNo")
@@ -4958,6 +5069,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("CountType") b.Property<string>("CountType")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
@@ -4990,6 +5104,8 @@ namespace ERPCore.Migrations
b.HasKey("CountId"); b.HasKey("CountId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
b.HasIndex("DocNo") b.HasIndex("DocNo")
@@ -5182,6 +5298,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -5212,6 +5331,8 @@ namespace ERPCore.Migrations
b.HasKey("TransferId"); b.HasKey("TransferId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
b.HasIndex("DestWarehouseId"); b.HasIndex("DestWarehouseId");
@@ -5477,15 +5598,55 @@ namespace ERPCore.Migrations
}, },
new new
{ {
SubNavItemId = 23, SubNavItemId = 40,
Code = "sales.bundle-sales", Code = "sales.invoices",
Href = "/dashboard/sales/bundles", Href = "/dashboard/sales/invoices",
Label = "Bundle Sales", Label = "Sales",
NavItemId = 13, NavItemId = 13,
SortOrder = 1, SortOrder = 1,
Status = "Active" Status = "Active"
}, },
new new
{
SubNavItemId = 23,
Code = "sales.bundle-templates",
Href = "/dashboard/sales/bundles/templates",
Label = "Bundle Templates",
NavItemId = 13,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 41,
Code = "sales.free-issues",
Href = "/dashboard/sales/free-issues",
Label = "Free Issues",
NavItemId = 13,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 42,
Code = "sales.day-end",
Href = "/dashboard/sales/day-end",
Label = "Day End",
NavItemId = 13,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 30,
Code = "procurement.vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
NavItemId = 4,
SortOrder = 0,
Status = "Active"
},
new
{ {
SubNavItemId = 17, SubNavItemId = 17,
Code = "procurement.requisitions", Code = "procurement.requisitions",
@@ -5664,6 +5825,86 @@ namespace ERPCore.Migrations
NavItemId = 14, NavItemId = 14,
SortOrder = 3, SortOrder = 3,
Status = "Active" Status = "Active"
},
new
{
SubNavItemId = 31,
Code = "stock.ledger",
Href = "/dashboard/stock/ledger",
Label = "Stock Ledger",
NavItemId = 6,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 32,
Code = "stock.enquiry",
Href = "/dashboard/stock/enquiry",
Label = "Stock Enquiry",
NavItemId = 6,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 33,
Code = "stock.valuation",
Href = "/dashboard/stock/valuation",
Label = "Valuation",
NavItemId = 6,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 34,
Code = "stock.transfers",
Href = "/dashboard/stock/transfers",
Label = "Transfers",
NavItemId = 6,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 35,
Code = "stock.adjustments",
Href = "/dashboard/stock/adjustments",
Label = "Adjustments",
NavItemId = 6,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 36,
Code = "stock.counts",
Href = "/dashboard/stock/counts",
Label = "Counts",
NavItemId = 6,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 37,
Code = "stock.reorder-alerts",
Href = "/dashboard/stock/reorder-alerts",
Label = "Reorder Alerts",
NavItemId = 6,
SortOrder = 7,
Status = "Active"
},
new
{
SubNavItemId = 38,
Code = "stock.wastage",
Href = "/dashboard/stock/wastage",
Label = "Wastage",
NavItemId = 6,
SortOrder = 8,
Status = "Active"
}); });
}); });
@@ -5759,16 +6000,31 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Active");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UomId"); b.HasKey("UomId");
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.HasIndex("Status");
b.ToTable("uoms", (string)null); b.ToTable("uoms", (string)null);
}); });
@@ -6380,6 +6636,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany() .WithMany()
.HasForeignKey("CreatedBy") .HasForeignKey("CreatedBy")
@@ -6408,6 +6670,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Branch");
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("PurchaseInvoice"); b.Navigation("PurchaseInvoice");
@@ -6585,6 +6849,17 @@ namespace ERPCore.Migrations
b.Navigation("PayrollRun"); b.Navigation("PayrollRun");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
@@ -7142,6 +7417,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany() .WithMany()
.HasForeignKey("CreatorUserId"); .HasForeignKey("CreatorUserId");
@@ -7158,6 +7439,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Branch");
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("Customer"); b.Navigation("Customer");
@@ -7423,6 +7706,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany() .WithMany()
.HasForeignKey("CreatedBy") .HasForeignKey("CreatedBy")
@@ -7441,6 +7730,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Branch");
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("ReasonCode"); b.Navigation("ReasonCode");
@@ -7484,6 +7775,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany() .WithMany()
.HasForeignKey("CreatedBy") .HasForeignKey("CreatedBy")
@@ -7496,6 +7793,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Branch");
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("Warehouse"); b.Navigation("Warehouse");
@@ -7603,6 +7902,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany() .WithMany()
.HasForeignKey("CreatedBy") .HasForeignKey("CreatedBy")
@@ -7621,6 +7926,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Branch");
b.Navigation("Creator"); b.Navigation("Creator");
b.Navigation("DestWarehouse"); b.Navigation("DestWarehouse");
@@ -6,7 +6,7 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "http://localhost:5224", "applicationUrl": "http://localhost:6701",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -16,7 +16,7 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "https://localhost:7112;http://localhost:5224", "applicationUrl": "https://localhost:7112;http://localhost:6701",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
+10 -4
View File
@@ -32,6 +32,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IStockMutator _mutator; private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly string _glInventoryAccountCode;
@@ -41,7 +42,7 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService( public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator, IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration) IGeneralLedgerService gl, IConfiguration configuration)
{ {
_adjustments = adjustments; _adjustments = adjustments;
@@ -52,6 +53,7 @@ public sealed class AdjustmentService : IAdjustmentService
_mutator = mutator; _mutator = mutator;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty; _glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -105,14 +107,15 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%")); q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
} }
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId); // Warehouses are shared across branches — the adjustment carries its own owning BranchId.
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId); if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId) var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto( .Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo)) a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
.ToListAsync(ct); .ToListAsync(ct);
@@ -145,6 +148,8 @@ public sealed class AdjustmentService : IAdjustmentService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct) var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Adjustment) if (reason.Context != ReasonContext.Adjustment)
@@ -169,6 +174,7 @@ public sealed class AdjustmentService : IAdjustmentService
{ {
DocNo = docNo, DocNo = docNo,
WarehouseId = request.WarehouseId, WarehouseId = request.WarehouseId,
BranchId = branchId,
ReasonCodeId = request.ReasonCodeId.Value, ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted, Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
@@ -196,7 +202,7 @@ public sealed class AdjustmentService : IAdjustmentService
} }
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new( private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt, a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId) a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
+56 -1
View File
@@ -1,7 +1,10 @@
using System.Text.Json; using System.Text.Json;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit; using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,11 +15,19 @@ public sealed class AuditService : IAuditService
{ {
private readonly IRepository<AuditLog> _logs; private readonly IRepository<AuditLog> _logs;
private readonly IRepository<JournalEntryStub> _journal; private readonly IRepository<JournalEntryStub> _journal;
private readonly IRepository<User> _users;
private readonly IRepository<LoginActivity> _loginActivities;
private readonly IUnitOfWork _uow;
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal) public AuditService(
IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal, IRepository<User> users,
IRepository<LoginActivity> loginActivities, IUnitOfWork uow)
{ {
_logs = logs; _logs = logs;
_journal = journal; _journal = journal;
_users = users;
_loginActivities = loginActivities;
_uow = uow;
} }
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync( public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
@@ -55,4 +66,48 @@ public sealed class AuditService : IAuditService
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
} }
public async Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default)
{
var userId = await _users.Query().AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => (int?)u.UserId)
.FirstOrDefaultAsync(ct);
// JIT provisioning may not have created the local shadow row yet on a brand-new
// account's very first login — nothing to attach the log to in that case.
if (userId is null) return;
var now = DateTime.UtcNow;
await _logs.AddAsync(new AuditLog
{
UserId = userId.Value,
EntityType = "User",
EntityId = userId.Value,
Action = AuditAction.Login,
ChangeSet = "{}",
CreatedAt = now
}, ct);
await _loginActivities.AddAsync(new LoginActivity
{
UserId = userId.Value,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceLabel = UserAgentLabeler.Label(userAgent),
LoginAt = now
}, ct);
await _uow.SaveChangesAsync(ct);
}
public async Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default)
{
var q = _loginActivities.Query().AsNoTracking().Where(l => l.UserId == userId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LoginActivityId)
.Skip(query.Skip).Take(query.PageSize)
.Select(l => new LoginActivityDto(l.LoginActivityId, l.IpAddress, l.DeviceLabel, l.LoginAt))
.ToListAsync(ct);
return PagedResponse<LoginActivityDto>.Create(rows, query.Page, query.PageSize, total);
}
} }
+11 -4
View File
@@ -34,6 +34,7 @@ public sealed class CountService : ICountService
private readonly IStockMutator _mutator; private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly string _glInventoryAccountCode;
@@ -43,7 +44,7 @@ public sealed class CountService : ICountService
public CountService( public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration) IGeneralLedgerService gl, IConfiguration configuration)
{ {
_counts = counts; _counts = counts;
@@ -55,6 +56,7 @@ public sealed class CountService : ICountService
_mutator = mutator; _mutator = mutator;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty; _glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -108,13 +110,14 @@ public sealed class CountService : ICountService
} }
if (status is not null) q = q.Where(c => c.Status == status); if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId); // Warehouses are shared across branches — the count carries its own owning BranchId.
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId) var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto( .Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count)) c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct); .ToListAsync(ct);
@@ -133,6 +136,8 @@ public sealed class CountService : ICountService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var itemIds = request.ItemIds.Distinct().ToList(); var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds) foreach (var id in itemIds)
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct)) if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
@@ -153,6 +158,7 @@ public sealed class CountService : ICountService
{ {
DocNo = docNo, DocNo = docNo,
WarehouseId = request.WarehouseId, WarehouseId = request.WarehouseId,
BranchId = branchId,
CountType = request.CountType, CountType = request.CountType,
Status = CountStatus.Draft, Status = CountStatus.Draft,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
@@ -222,6 +228,7 @@ public sealed class CountService : ICountService
{ {
DocNo = docNo, DocNo = docNo,
WarehouseId = count.WarehouseId, WarehouseId = count.WarehouseId,
BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId, ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted, Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
@@ -250,7 +257,7 @@ public sealed class CountService : ICountService
} }
private static CountDto Map(StockCount c) => new( private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt, c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId) c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList()); .Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
} }
+2 -2
View File
@@ -62,11 +62,11 @@ public sealed class DashboardService : IDashboardService
.ToList(); .ToList();
var grns = _grns.Query().AsNoTracking(); var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId); if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId);
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct); var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
var counts = _counts.Query().AsNoTracking(); var counts = _counts.Query().AsNoTracking();
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId); if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct); var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet // PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
+11 -7
View File
@@ -38,6 +38,7 @@ public sealed class GrnService : IGrnService
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly string _glInventoryAccountCode;
@@ -49,8 +50,8 @@ public sealed class GrnService : IGrnService
IRepository<Item> items, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IGeneralLedgerService gl, IConfiguration configuration) IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{ {
_grns = grns; _grns = grns;
_pos = pos; _pos = pos;
@@ -65,6 +66,7 @@ public sealed class GrnService : IGrnService
_fifo = fifo; _fifo = fifo;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty; _glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
@@ -86,15 +88,14 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId); if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId); if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// GRN carries no BranchId of its own — it inherits branch scope transitively via its // Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism"). if (branchId is not null) q = q.Where(g => g.BranchId == branchId);
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId) var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto( .Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null)) g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct); .ToListAsync(ct);
@@ -116,6 +117,8 @@ public sealed class GrnService : IGrnService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve vendor + PO context. // Resolve vendor + PO context.
PurchaseOrder? po = null; PurchaseOrder? po = null;
int vendorId; int vendorId;
@@ -211,6 +214,7 @@ public sealed class GrnService : IGrnService
PoId = request.PoId, PoId = request.PoId,
VendorId = vendorId, VendorId = vendorId,
WarehouseId = request.WarehouseId, WarehouseId = request.WarehouseId,
BranchId = branchId,
Status = GrnStatus.Draft, Status = GrnStatus.Draft,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -448,7 +452,7 @@ public sealed class GrnService : IGrnService
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate); l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
private static GrnDto Map(Grn g) => new( private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt, g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo, g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto( g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
@@ -123,5 +123,21 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int branchId, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
_branches.Remove(branch);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This branch is referenced by other records and cannot be deleted.", 409);
}
}
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt); private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
} }
@@ -127,6 +127,22 @@ public sealed class DepartmentService : IDepartmentService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int departmentId, CancellationToken ct = default)
{
var dept = await _departments.GetByIdAsync(departmentId, ct)
?? throw new NotFoundException($"Department {departmentId} was not found.");
_departments.Remove(dept);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This department is referenced by other records (sub-departments or employees) and cannot be deleted.", 409);
}
}
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary> /// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct) private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
{ {
@@ -101,5 +101,21 @@ public sealed class DesignationService : IDesignationService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int designationId, CancellationToken ct = default)
{
var designation = await _designations.GetByIdAsync(designationId, ct)
?? throw new NotFoundException($"Designation {designationId} was not found.");
_designations.Remove(designation);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This designation is referenced by other records and cannot be deleted.", 409);
}
}
private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt); private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt);
} }
@@ -3,6 +3,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
@@ -21,17 +22,31 @@ public sealed class EmployeeService : IEmployeeService
private readonly IRepository<Employee> _employees; private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails; private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IRepository<EmployeeDocument> _documents;
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
private readonly IRepository<EmployeeLoan> _loans;
private readonly IRepository<LeaveBalance> _leaveBalances;
private readonly IRepository<PayrollLine> _payrollLines;
private readonly IFileStorageService _fileStorage;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess; private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public EmployeeService( public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails, IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow) IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
{ {
_employees = employees; _employees = employees;
_users = users; _users = users;
_bankDetails = bankDetails; _bankDetails = bankDetails;
_documents = documents;
_salaryStructures = salaryStructures;
_loans = loans;
_leaveBalances = leaveBalances;
_payrollLines = payrollLines;
_fileStorage = fileStorage;
_branchConfig = branchConfig; _branchConfig = branchConfig;
_branchAccess = branchAccess; _branchAccess = branchAccess;
_uow = uow; _uow = uow;
@@ -197,6 +212,48 @@ public sealed class EmployeeService : IEmployeeService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
// A linked system-user account survives the employee row (Employee.UserId → User is
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
// with no staff record behind it. The User row itself is never removed.
if (employee.UserId is not null)
{
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
}
// Bank details and documents cascade at the DB level, but documents also have a
// physical file on disk that cascading the row would silently orphan — delete those
// explicitly first. Salary structures, loans, and leave balances are Restrict FKs, so
// they must be removed before the employee row itself will delete.
var documents = await _documents.Query().Where(d => d.EmployeeId == employeeId).ToListAsync(ct);
foreach (var doc in documents)
{
await _fileStorage.DeleteAsync(doc.RelativePath, ct);
_documents.Remove(doc);
}
var salaryStructures = await _salaryStructures.Query().Where(s => s.EmployeeId == employeeId).ToListAsync(ct);
foreach (var structure in salaryStructures) _salaryStructures.Remove(structure);
var loans = await _loans.Query().Where(l => l.EmployeeId == employeeId).ToListAsync(ct);
foreach (var loan in loans) _loans.Remove(loan);
var leaveBalances = await _leaveBalances.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
foreach (var balance in leaveBalances) _leaveBalances.Remove(balance);
_employees.Remove(employee);
await _uow.SaveChangesAsync(ct);
}
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default) public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
{ {
return await _bankDetails.Query().AsNoTracking() return await _bankDetails.Query().AsNoTracking()
@@ -101,5 +101,21 @@ public sealed class EmploymentTypeService : IEmploymentTypeService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int employmentTypeId, CancellationToken ct = default)
{
var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct)
?? throw new NotFoundException($"Employment type {employmentTypeId} was not found.");
_employmentTypes.Remove(et);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This employment type is referenced by other records and cannot be deleted.", 409);
}
}
private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt); private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt);
} }
@@ -108,6 +108,22 @@ public sealed class HrDocumentTypeService : IHrDocumentTypeService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(hrDocumentTypeId, ct)
?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found.");
_types.Remove(type);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This document type is referenced by other records and cannot be deleted.", 409);
}
}
private static HrDocumentTypeDto Map(HrDocumentType t) => new( private static HrDocumentTypeDto Map(HrDocumentType t) => new(
t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt); t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt);
} }
@@ -43,6 +43,11 @@ public sealed class LeaveRequestService : ILeaveRequestService
var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable(); var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable();
if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId); if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId);
if (status is not null) q = q.Where(r => r.Status == status); if (status is not null) q = q.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%") || EF.Functions.ILike(r.Employee!.FullName, $"%{term}%"));
}
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.CreatedAt) var rows = await q.OrderByDescending(r => r.CreatedAt)
@@ -113,6 +113,22 @@ public sealed class LeaveTypeService : ILeaveTypeService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int leaveTypeId, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(leaveTypeId, ct)
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
_types.Remove(type);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This leave type is referenced by other records and cannot be deleted.", 409);
}
}
private static LeaveTypeDto Map(LeaveType t) => new( private static LeaveTypeDto Map(LeaveType t) => new(
t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear, t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear,
t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt); t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt);
@@ -107,6 +107,22 @@ public sealed class SalaryComponentService : ISalaryComponentService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int salaryComponentId, CancellationToken ct = default)
{
var component = await _components.GetByIdAsync(salaryComponentId, ct)
?? throw new NotFoundException($"Salary component {salaryComponentId} was not found.");
_components.Remove(component);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This salary component is referenced by other records and cannot be deleted.", 409);
}
}
private static SalaryComponentDto Map(SalaryComponent c) => new( private static SalaryComponentDto Map(SalaryComponent c) => new(
c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt); c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt);
} }
@@ -120,6 +120,22 @@ public sealed class WorkShiftService : IWorkShiftService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
public async Task DeleteAsync(int workShiftId, CancellationToken ct = default)
{
var shift = await _shifts.GetByIdAsync(workShiftId, ct)
?? throw new NotFoundException($"Work shift {workShiftId} was not found.");
_shifts.Remove(shift);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This work shift is referenced by other records and cannot be deleted.", 409);
}
}
private static WorkShiftDto Map(WorkShift w) => new( private static WorkShiftDto Map(WorkShift w) => new(
w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight, w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight,
w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask, w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask,
@@ -5,7 +5,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary> /// <summary>
/// Read access to the audit trail and GL-ready journal stubs (auditor role, /// Read access to the audit trail and GL-ready journal stubs (auditor role,
/// 02-SECURITY B.2.3). Both are append-only; no write API. /// 02-SECURITY B.2.3). Both are append-only — entity mutations are captured
/// automatically by ErpDbContext.SaveChangesAsync and never written here directly.
/// The one deliberate exception is <see cref="RecordLoginAsync"/>: a successful
/// sign-in changes no entity, so nothing would otherwise appear in the trail for it.
/// </summary> /// </summary>
public interface IAuditService public interface IAuditService
{ {
@@ -14,4 +17,12 @@ public interface IAuditService
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync( Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default); string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
/// <summary>Writes a Login audit row and a login-device history entry for the local User backing this
/// AuthHex identity, if one exists yet.</summary>
Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default);
/// <summary>Past logins for a user — a history ERPCore captures itself, not a live "currently active
/// sessions" view (AuthHex has no admin-scoped call to inspect another user's sessions).</summary>
Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default);
} }
@@ -13,4 +13,5 @@ public interface IBranchService
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default); Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int branchId, CancellationToken ct = default);
} }
@@ -13,4 +13,5 @@ public interface IDepartmentService
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default); Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int departmentId, CancellationToken ct = default);
} }
@@ -12,4 +12,5 @@ public interface IDesignationService
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default); Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int designationId, CancellationToken ct = default);
} }
@@ -15,6 +15,14 @@ public interface IEmployeeService
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default); Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default); Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
/// <summary>
/// Permanently deletes the employee and every dependent record (bank details, documents
/// incl. their stored files, salary structure history, loans, leave balances). Blocked with
/// 409 if the employee has any payroll history, since that's financial/audit record that
/// must never be destroyed.
/// </summary>
Task DeleteAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default); Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default); Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
} }
@@ -12,4 +12,5 @@ public interface IEmploymentTypeService
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default); Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int employmentTypeId, CancellationToken ct = default);
} }
@@ -13,4 +13,5 @@ public interface IHrDocumentTypeService
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default); Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default);
} }
@@ -12,4 +12,5 @@ public interface ILeaveTypeService
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default); Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int leaveTypeId, CancellationToken ct = default);
} }
@@ -12,4 +12,5 @@ public interface ISalaryComponentService
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default); Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int salaryComponentId, CancellationToken ct = default);
} }
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
@@ -6,6 +7,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary> /// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public interface IUomService public interface IUomService
{ {
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default); Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
} }
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
@@ -10,11 +11,21 @@ namespace ERPCore.Services.Interfaces;
/// </summary> /// </summary>
public interface IUserManagementService public interface IUserManagementService
{ {
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default); Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default); Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>
/// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the
/// credential itself stays live upstream; this only revokes the account's
/// standing inside ERPCore.
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary> /// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default); Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
} }
@@ -6,7 +6,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary> /// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public interface IWarehouseService public interface IWarehouseService
{ {
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default); Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default); Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default); Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
@@ -12,4 +12,5 @@ public interface IWorkShiftService
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default); Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default); Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default); Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int workShiftId, CancellationToken ct = default);
} }
@@ -26,6 +26,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly ISalesMappingService _mapping; private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow; private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
@@ -33,7 +34,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items, IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping, IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow, ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow) ICurrentUser currentUser, IBranchConfigService branchConfig, INumberSequenceService numbers, IUnitOfWork uow)
{ {
_invoices = invoices; _invoices = invoices;
_customers = customers; _customers = customers;
@@ -45,6 +46,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_mapping = mapping; _mapping = mapping;
_workflow = workflow; _workflow = workflow;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig;
_numbers = numbers; _numbers = numbers;
_uow = uow; _uow = uow;
} }
@@ -60,9 +62,8 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (status is not null) q = q.Where(x => x.Status == status); if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId); if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId); if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Sales carries no BranchId of its own — it inherits branch scope transitively via // Warehouses are shared across branches — the invoice carries its own owning BranchId.
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch"). if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -83,6 +84,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default) public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{ {
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct); await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct); var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -96,6 +98,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
InvoiceDate = DateTime.UtcNow, InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId, CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId, WarehouseId = request.WarehouseId,
BranchId = branchId,
InvoiceType = request.InvoiceType, InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft, Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
@@ -119,6 +122,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct); await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId; invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId; invoice.WarehouseId = request.WarehouseId;
invoice.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
invoice.InvoiceType = request.InvoiceType; invoice.InvoiceType = request.InvoiceType;
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct); invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct); invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -201,5 +205,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo); x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
} }
@@ -31,7 +31,7 @@ public sealed class SalesMappingService : ISalesMappingService
public SalesInvoiceDto MapInvoice(SalesInvoice invoice) public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new( => new(
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId, invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, invoice.BranchId,
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt, invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
invoice.GlJournalNo, invoice.GlPostedAt, invoice.GlJournalNo, invoice.GlPostedAt,
MapInvoiceTotals(invoice), MapInvoiceTotals(invoice),
+10 -6
View File
@@ -34,11 +34,13 @@ public sealed class TransferService : ITransferService
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public TransferService( public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, IUnitOfWork uow)
{ {
_transfers = transfers; _transfers = transfers;
_warehouses = warehouses; _warehouses = warehouses;
@@ -46,6 +48,7 @@ public sealed class TransferService : ITransferService
_fifo = fifo; _fifo = fifo;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow; _uow = uow;
} }
@@ -62,15 +65,14 @@ public sealed class TransferService : ITransferService
if (status is not null) q = q.Where(t => t.Status == status); if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId); if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId); if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
// A transfer touches two branches when it crosses one — show it on either side // Warehouses are shared across branches — the transfer carries its own owning BranchId.
// rather than only the branch that happens to hold the source warehouse. if (branchId is not null) q = q.Where(t => t.BranchId == branchId);
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId) var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto( .Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count)) t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct); .ToListAsync(ct);
@@ -90,6 +92,7 @@ public sealed class TransferService : ITransferService
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422); throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
await EnsureWarehouseAsync(request.SrcWarehouseId, ct); await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct); await EnsureWarehouseAsync(request.DestWarehouseId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
foreach (var line in request.Lines) foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
@@ -103,6 +106,7 @@ public sealed class TransferService : ITransferService
DocNo = docNo, DocNo = docNo,
SrcWarehouseId = request.SrcWarehouseId, SrcWarehouseId = request.SrcWarehouseId,
DestWarehouseId = request.DestWarehouseId, DestWarehouseId = request.DestWarehouseId,
BranchId = branchId,
Status = TransferStatus.Draft, Status = TransferStatus.Draft,
CreatedBy = actor, CreatedBy = actor,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -227,7 +231,7 @@ public sealed class TransferService : ITransferService
} }
private static TransferDto Map(StockTransfer t) => new( private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt, t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
} }
+16 -4
View File
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
@@ -20,7 +21,7 @@ public sealed class UomService : IUomService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default) public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{ {
var q = _uoms.Query().AsNoTracking(); var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -28,11 +29,12 @@ public sealed class UomService : IUomService
var term = query.Q.Trim(); var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%")); q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
} }
if (status is not null) q = q.Where(u => u.Status == status);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Name) var rows = await q.OrderBy(u => u.Name)
.Skip(query.Skip).Take(query.PageSize) .Skip(query.Skip).Take(query.PageSize)
.Select(u => new UomDto(u.UomId, u.Name)) .Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -44,10 +46,20 @@ public sealed class UomService : IUomService
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct)) if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
throw new ConflictException($"A UOM named '{name}' already exists."); throw new ConflictException($"A UOM named '{name}' already exists.");
var uom = new Uom { Name = name }; var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
await _uoms.AddAsync(uom, ct); await _uoms.AddAsync(uom, ct);
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return new UomDto(uom.UomId, uom.Name); return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
}
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
{
var uom = await _uoms.GetByIdAsync(uomId, ct)
?? throw new NotFoundException($"UOM {uomId} was not found.");
uom.Status = status;
uom.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
} }
} }
@@ -18,27 +18,25 @@ public sealed class UserManagementService : IUserManagementService
private readonly IRepository<User> _users; private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles; private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees; private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links; private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService; private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
public UserManagementService( public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers, IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow) IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{ {
_users = users; _users = users;
_roles = roles; _roles = roles;
_employees = employees; _employees = employees;
_authUsers = authUsers;
_authHex = authHex; _authHex = authHex;
_links = links; _links = links;
_employeeService = employeeService; _employeeService = employeeService;
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default) public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{ {
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role); IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -46,6 +44,8 @@ public sealed class UserManagementService : IUserManagementService
var term = query.Q.Trim(); var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%")); q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
} }
if (status is not null) q = q.Where(u => u.Status == status);
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username) var rows = await q.OrderBy(u => u.Username)
@@ -93,8 +93,13 @@ public sealed class UserManagementService : IUserManagementService
var authUserId = Guid.NewGuid(); var authUserId = Guid.NewGuid();
// Source of truth: AuthHex creates the credential + emails it (registerUser, // Source of truth: AuthHex creates the credential + emails it (registerUser,
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs). // ERP_Auth_Service/Services/UserManager/UserManagerService.cs). Calls the raw
await _authUsers.RegisterAsync(new RegisterRequest // AuthHex client directly rather than IAuthUserService.RegisterAsync — that
// wrapper is shaped for self-registration (AuthController.Register) and demands
// a session (access/refresh tokens) in the response, which makes no sense here:
// an admin creating an account for someone else never logs in as them, so
// AuthHex's registerUser response for this case carries no tokens to demand.
await _authHex.RegisterAsync(new RegisterRequest
{ {
UserId = authUserId, UserId = authUserId,
RoleId = role.AuthRoleId, RoleId = role.AuthRoleId,
@@ -167,6 +172,17 @@ public sealed class UserManagementService : IUserManagementService
return Map(user); return Map(user);
} }
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
user.Status = status;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default) public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{ {
var userTypes = await _authHex.ListUserTypesAsync(ct); var userTypes = await _authHex.ListUserTypesAsync(ct);
+2 -1
View File
@@ -28,7 +28,7 @@ public sealed class WarehouseService : IWarehouseService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default) public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default)
{ {
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch); IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -36,6 +36,7 @@ public sealed class WarehouseService : IWarehouseService
var term = query.Q.Trim(); var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%")); q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
} }
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code) var rows = await q.OrderBy(w => w.Code)
@@ -47,6 +47,7 @@ public static class ErrorCodes
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP"; public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4) // Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE"; public const string TemplateInUse = "TEMPLATE_IN_USE";
+1 -1
View File
@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive" "DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5011" "BaseUrl": "http://localhost:5011"
@@ -1,7 +1,4 @@
{ {
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": { "Jwt": {
"SigningKey": "" "SigningKey": ""
}, },
+3 -2
View File
@@ -6,8 +6,9 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root" "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
}, },
"Urls": "http://0.0.0.0:6701",
"Auth": { "Auth": {
"Issuer": "AuthHex", "Issuer": "AuthHex",
"Audience": "AuthHexClient", "Audience": "AuthHexClient",
@@ -16,7 +17,7 @@
"RequiredRoleCode": "" "RequiredRoleCode": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5602" "BaseUrl": "http://localhost:6703"
}, },
"FileStorage": { "FileStorage": {
"RootPath": "App_Data/hr-documents", "RootPath": "App_Data/hr-documents",
+18
View File
@@ -0,0 +1,18 @@
node_modules
.next
out
build
coverage
.vercel
.env
.env.*
!.env.local.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
Dockerfile
.dockerignore

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