Compare commits

...

86 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna 0052725b88 feat: add user and role view sheets with detailed information display
- Created UserViewSheet component to display user details including username, full name, role, status, and timestamps.
- Created RoleViewSheet component to display role details including name, level, status, creation date, and associated permissions.
- Introduced permissions configuration in a new permissions.ts file, defining categories and permission codes for better management.
- Implemented middleware for authentication, ensuring access control for protected routes.
2026-10-18 23:11:01 +05:30
Kalana B. Thilakarathna 558afc6f9d auth layer implemented 2026-10-09 09:58:45 +05:30
Kalana B. Thilakarathna 5b2ac8cc14 Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-10-09 09:37:38 +05:30
Kalana B. Thilakarathna 5cb32bee95 Revert "auth layer implemented"
This reverts commit 9adfff48a7.
2026-10-09 09:37:28 +05:30
Kalana B. Thilakarathna 9adfff48a7 auth layer implemented 2026-10-09 09:37:05 +05:30
DeepnaPooja 74f33d8979 Merge pull request 'Refactor/main flow' (#56) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Successful in 1m42s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m55s
Reviewed-on: #56
2026-10-08 07:35:34 +00:00
DeepnaPooja dd8f50f54c Update docs for procurement and shop PO processes, including GRN rework functionality 2026-10-08 12:59:08 +05:30
DeepnaPooja 25b21d6fa8 Refactor GRN configuration page layout 2026-10-08 12:57:48 +05:30
DeepnaPooja 106ed57897 Implement GRN tax settings UI and calculations for VAT and NBT 2026-10-08 12:55:45 +05:30
DeepnaPooja 0451a0f418 Add documentation for GRN costing, taxes, and item tracking configuration 2026-10-08 12:54:38 +05:30
DeepnaPooja b12f3c8d08 Add NbtPercent column to GoodsReceipts and GoodsReceiptItems 2026-10-08 12:44:28 +05:30
DeepnaPooja 702efba62e Add GetNextSerialSequence functionality to Stock services and repository 2026-10-08 12:42:50 +05:30
DeepnaPooja 55aa35308a Add NBT support to GRN processing and pricing calculations 2026-10-08 12:41:44 +05:30
DeepnaPooja 40e890d4ff Fix GRN UI 2026-10-08 10:40:43 +05:30
DeepnaPooja 9908a552d1 Fix GRN pricing 2026-10-08 10:37:19 +05:30
DeepnaPooja 23fb7361a4 Merge pull request 'Refactor/main flow' (#54) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m8s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m25s
Reviewed-on: #54
2026-10-07 05:16:58 +00:00
DeepnaPooja ff6f3d3218 Add product reports 2026-10-07 10:41:04 +05:30
DeepnaPooja 92a311e1cc Add region manager assignment endpoints 2026-10-07 10:39:17 +05:30
DeepnaPooja 3538bb5b0e Assign a region manager for the route 2026-10-06 15:40:43 +05:30
DeepnaPooja b75cd01e90 Add region code support 2026-10-06 15:15:04 +05:30
DeepnaPooja c52ecfdebc Add region code 2026-10-06 15:09:22 +05:30
DeepnaPooja edb5fddc3a Add item tracking and the configuration docs 2026-10-06 13:32:37 +05:30
DeepnaPooja 05d44ab9f0 check stock lookup methods 2026-10-06 13:31:19 +05:30
DeepnaPooja c358764f4e Add item tracking migrations 2026-10-06 13:30:12 +05:30
DeepnaPooja 92f1aadb81 Add item and GRN tracking forms 2026-10-06 13:28:37 +05:30
DeepnaPooja a8725bc473 Add GRN batch and serial tracking 2026-10-06 13:26:17 +05:30
DeepnaPooja d578cbad20 fix routing 2026-10-02 14:18:39 +05:30
DeepnaPooja e1db9b1b69 add shared card and toggle components 2026-10-02 13:19:02 +05:30
DeepnaPooja 9707ef596c UI changes 2026-10-02 13:17:32 +05:30
DeepnaPooja bfd3e5cded add product configuration UI 2026-10-02 13:07:45 +05:30
DeepnaPooja d28d996295 Add product configurations 2026-10-02 13:06:22 +05:30
DeepnaPooja 4e493f51f8 Add item tracking and warranty settings 2026-10-02 13:03:45 +05:30
DeepnaPooja 63c1c4ad1a Merge pull request 'fix' (#52) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m12s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m26s
Reviewed-on: #52
2026-09-29 12:14:19 +00:00
DeepnaPooja e3af6100ec fix 2026-09-29 17:38:39 +05:30
DeepnaPooja df0eb3cd5a Merge pull request 'Refactor/main flow' (#50) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m16s
Reviewed-on: #50
2026-09-29 12:04:26 +00:00
DeepnaPooja d8ba956817 fix loading sheet UI 2026-09-29 17:29:02 +05:30
DeepnaPooja 89c0c17be7 ui fix 2026-09-29 17:07:16 +05:30
DeepnaPooja cc72e22664 Merge pull request 'Refactor/main flow' (#49) from refactor/main_flow into dev
Reviewed-on: #49
2026-09-29 09:45:51 +00:00
DeepnaPooja ef4e0e0873 Add item reorder level migration 2026-09-29 12:32:22 +05:30
DeepnaPooja bd90a3d68e dashboard design 2026-09-29 12:08:54 +05:30
DeepnaPooja 7dac429a1a Add low stock endpoint 2026-09-29 11:48:37 +05:30
DeepnaPooja 3dbfe6db23 Add item reorder level to DTOs 2026-09-29 11:46:11 +05:30
DeepnaPooja a6de58e2e9 Add invoice payment summary 2026-09-29 11:45:08 +05:30
DeepnaPooja 681cf821a6 Add delivery task listing endpoint 2026-09-29 11:43:10 +05:30
DeepnaPooja a4f5d12234 Merge pull request 'Refactor/main flow' (#47) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m10s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m24s
Reviewed-on: #47
2026-09-28 07:39:07 +00:00
DeepnaPooja ee50f0932d fix open add product in new tab 2026-09-28 13:03:11 +05:30
DeepnaPooja 7dd012f926 Add PO confirmation btutton and the status badge 2026-09-28 12:36:21 +05:30
DeepnaPooja aebfa6e3cc Add search to dropdown 2026-09-28 12:00:59 +05:30
DeepnaPooja d6fd685b3c Merge pull request 'Add loading sheet edit flow' (#45) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m57s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m8s
Reviewed-on: #45
2026-09-25 08:37:39 +00:00
DeepnaPooja a892aec527 Add loading sheet edit flow 2026-09-25 14:02:24 +05:30
DeepnaPooja aaf6e5f12d Merge pull request 'Refactor/main flow' (#43) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m36s
Reviewed-on: #43
2026-09-24 12:00:17 +00:00
DeepnaPooja 75b0a802c3 update the migrations 2026-09-24 17:24:43 +05:30
DeepnaPooja e8692b2474 fix the loading sheet view 2026-09-24 17:23:31 +05:30
DeepnaPooja be38777ebb Fixes for the route 2026-09-24 15:50:35 +05:30
DeepnaPooja 41f992b824 store page fixes 2026-09-24 15:05:48 +05:30
DeepnaPooja 1ec5426d15 PO shops fe fixes 2026-09-24 15:04:42 +05:30
DeepnaPooja 3b0a5f0d3f remove store kyc features 2026-09-24 14:56:17 +05:30
DeepnaPooja 96e12972b9 fix store profile fields 2026-09-24 14:18:52 +05:30
DeepnaPooja 556dedb8a3 Merge pull request 'Refactor/main flow' (#41) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m49s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m58s
Reviewed-on: #41
2026-09-23 08:36:57 +00:00
DeepnaPooja 80db899091 Fix user role bug for invoice actions 2026-09-23 14:01:32 +05:30
DeepnaPooja c290a1e778 Add user fallback for invoice actions 2026-09-23 13:59:49 +05:30
DeepnaPooja 6f72f7a0e9 Merge pull request 'Refactor/main flow' (#39) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m8s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m21s
Reviewed-on: #39
2026-09-23 07:23:19 +00:00
DeepnaPooja ee672da9f7 Add user selection to Shop PO flow 2026-09-23 12:47:53 +05:30
DeepnaPooja 4b95e03e05 Add user fallback for Shop PO actions 2026-09-23 12:47:08 +05:30
DeepnaPooja ae302d0c9c Fix loading sheet creation flow 2026-09-23 12:46:23 +05:30
DeepnaPooja 53dcdf929a Merge pull request 'Refactor/main flow' (#37) from refactor/main_flow into dev
Reviewed-on: #37
2026-09-22 11:43:52 +00:00
DeepnaPooja 35fa3b19e3 Add loading sheet item selection
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m56s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m12s
2026-09-22 17:03:24 +05:30
DeepnaPooja b7db6a83e6 Add Shop PO management actions 2026-09-22 17:00:38 +05:30
DeepnaPooja 8bb3b3d192 Update loading sheet filters and orders 2026-09-22 16:59:38 +05:30
DeepnaPooja e5dadef863 Add invoice print and finalize flow 2026-09-22 16:58:38 +05:30
DeepnaPooja 44e335d542 Add ride cancellation details for the fe 2026-09-22 12:12:42 +05:30
DeepnaPooja d20e4a532f Add cancel ride type code and the cancel reason 2026-09-22 12:10:37 +05:30
DeepnaPooja 52845b5d87 Use client-side pagination for rides 2026-09-22 11:24:21 +05:30
Kalana B. Thilakarathna 3c17047550 Improve filter handling in SalesClient to reset values for empty selections
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m24s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m17s
2026-09-21 09:35:05 +05:30
Kalana B. Thilakarathna 34ffc7f97a Fix ride selection logic to handle empty values in LoadingSheetsClient 2026-09-21 09:30:57 +05:30
kalana_Thilakarathna b3840b47ce Merge pull request 'Update sales pages' (#34) from refactor/FE-fixes into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m57s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m42s
Reviewed-on: #34
2026-09-21 03:46:58 +00:00
DeepnaPooja a9a201cce1 Update sales pages 2026-09-20 21:33:44 +05:30
Kalana B. Thilakarathna 3c2dee5b4a Add build argument for NEXT_PUBLIC_API_URL in Dockerfile and CI/CD workflow
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m32s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m25s
2026-09-20 16:51:13 +05:30
Kalana B. Thilakarathna 1579936baf Add Dockerfile and .dockerignore; configure Next.js output settings
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m51s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m44s
2026-09-20 16:36:33 +05:30
Kalana B. Thilakarathna a8b6503a8a docker log
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m55s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m3s
2026-09-20 16:32:48 +05:30
Kalana B. Thilakarathna dc32ae7ea6 pipline update
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m27s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m34s
2026-09-20 16:23:16 +05:30
Kalana B. Thilakarathna 6a31ad0190 build fix
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m55s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m48s
2026-09-20 16:12:04 +05:30
Kalana B. Thilakarathna 502bf25cc0 Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m11s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m39s
2026-09-20 16:03:00 +05:30
Kalana B. Thilakarathna 484aa3fece Update appsettings.json URLs and add CI/CD workflow for ERP Distribution 2026-09-20 16:02:53 +05:30
DeepnaPooja 1c9ca179df Merge pull request 'Refactor/ride lifecycle' (#26) from refactor/ride_lifecycle into dev
Reviewed-on: #26
2026-09-18 06:51:51 +00:00
Kalana B. Thilakarathna c8c04c357d refactor plan created 2026-09-18 12:18:00 +05:30
269 changed files with 101829 additions and 2765 deletions
+89
View File
@@ -0,0 +1,89 @@
name: CI/CD - ERP Distribution
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy 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.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Restore
run: dotnet restore Distribution/Distribution.csproj
- name: Publish
run: |
rm -rf /tmp/erpdistribution-deploy
mkdir -p /tmp/erpdistribution-deploy
dotnet publish Distribution/Distribution.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpdistribution-deploy
- name: Stop service
run: systemctl stop erpdistribution.service
- name: Copy to deploy folder
run: |
mkdir -p /home/distribution/core/publish
rm -rf /home/distribution/core/publish/*
cp -r /tmp/erpdistribution-deploy/* /home/distribution/core/publish/
- name: Fix ownership and permissions
run: |
chown -R distributionuser:distributionuser /home/distribution/core/publish
find /home/distribution/core/publish -type d -exec chmod 755 {} +
find /home/distribution/core/publish -type f -exec chmod 644 {} +
chmod +x /home/distribution/core/publish/Distribution
- name: Restart service
run: systemctl restart erpdistribution.service
- name: Verify
run: |
systemctl is-active erpdistribution.service && echo "✅ Service running" || echo "❌ Service failed"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
--build-arg NEXT_PUBLIC_API_URL=https://distribution.hexdive.com/api \
-t erpdistributionfe:latest \
./distribution_fe
- name: Stop & remove existing container
run: |
docker stop erpdistributionfe 2>/dev/null || true
docker rm erpdistributionfe 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpdistributionfe \
-p 8801:3000 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpdistributionfe:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpdistributionfe --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpdistributionfe && echo "✅ Container running" || echo "❌ Container failed"
@@ -18,6 +18,7 @@ public class RegionServiceTests
public RegionServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
@@ -148,6 +149,123 @@ public class RegionServiceTests
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
// Region code
[Fact]
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0001", result.Code);
}
[Fact]
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0010", result.Code);
}
[Fact]
public async Task CreateAsync_StoresProvidedCode_AsEntered()
{
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
Assert.Equal("nth-1", result.Code);
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
}
[Fact]
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
{
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task GetNextCodeAsync_ReturnsSuggestion()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
var result = await _sut.GetNextCodeAsync();
Assert.Equal("REG-0004", result.Code);
}
[Fact]
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
Assert.Equal("north-1", result!.Code);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
Assert.Equal("REG-0001", result!.Code);
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
}
[Fact]
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task UpdateAsync_AllowsResubmittingOwnCode()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
Assert.Equal("REG-0001", result!.Code);
}
[Fact]
public async Task GetByIdAsync_ReturnsCode()
{
var region = ActiveRegion();
region.Code = "REG-0007";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.GetByIdAsync(region.Id);
Assert.Equal("REG-0007", result!.Code);
}
// DeleteAsync
[Fact]
@@ -195,4 +313,93 @@ public class RegionServiceTests
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
}
// Region manager
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
{
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
Role = new Role { Name = role, Level = 50 }
};
[Fact]
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
}
[Fact]
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
{
var region = ActiveRegion();
var user = ManagerUser("SalesRep");
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
{
var region = ActiveRegion();
var user = ManagerUser();
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
Assert.Equal(user.Id, result!.UserId);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
{
var region = ActiveRegion();
var user = ManagerUser();
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
}
[Fact]
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
{
var region = ActiveRegion();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
Assert.False(await _sut.RemoveManagerAsync(region.Id));
}
[Fact]
public async Task GetManagerAsync_ReturnsManager()
{
var region = ActiveRegion();
var user = ManagerUser();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
var result = await _sut.GetManagerAsync(region.Id);
Assert.Equal("mgr", result!.Username);
}
}
+116 -3
View File
@@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Uom> Uoms { get; set; }
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<ItemBatch> ItemBatches { get; set; }
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
@@ -31,7 +33,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
public DbSet<StockBalance> StockBalances { get; set; }
public DbSet<StockMovement> StockMovements { get; set; }
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
public DbSet<PriceList> PriceLists { get; set; }
public DbSet<PriceListItem> PriceListItems { get; set; }
public DbSet<Supplier> Suppliers { get; set; }
@@ -48,6 +49,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<GoodsReceiptItemBatch> GoodsReceiptItemBatches { get; set; }
public DbSet<GoodsReceiptItemSerial> GoodsReceiptItemSerials { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
@@ -84,8 +87,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Unique indexes ---
// NOTE: MySQL treats each NULL as distinct in a unique index, so this does not by itself
// prevent duplicate rows for untracked items (both FK columns null) — StockRepository's
// get-then-update pattern is what keeps those to one row per (Item, Location), same as before.
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
.IsUnique();
modelBuilder.Entity<ItemSerialNumber>()
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
@@ -293,6 +303,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Supplier)
.WithMany()
.HasForeignKey(gr => gr.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse)
.WithMany()
@@ -311,12 +328,92 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- ItemBatch / ItemSerialNumber ---
modelBuilder.Entity<ItemBatch>()
.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ItemBatch>()
.HasIndex(b => new { b.FK_ItemId, b.BatchNumber });
modelBuilder.Entity<ItemSerialNumber>()
.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- StockBalance / StockMovement batch & serial links ---
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemBatch)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemSerialNumber)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemBatch)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemSerialNumber)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- GoodsReceiptItemBatch / GoodsReceiptItemSerial ---
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.GoodsReceiptItem)
.WithMany(i => i.Batches)
.HasForeignKey(b => b.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.ItemBatch)
.WithMany()
.HasForeignKey(b => b.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.GoodsReceiptItem)
.WithMany(i => i.Serials)
.HasForeignKey(s => s.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.ItemSerialNumber)
.WithMany()
.HasForeignKey(s => s.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>()
@@ -447,6 +544,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
// --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -479,7 +585,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
@@ -488,5 +600,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
Seed.AdminSeed.Apply(modelBuilder);
}
}
+25
View File
@@ -0,0 +1,25 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class AdminSeed
{
private static readonly DateTime SeedDate = new(2026, 10, 13);
// Same GUID as the AuthHex user — the sub claim from the JWT maps to this Id
internal static readonly Guid SuperAdminUserId = new("00000001-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(new User
{
Id = SuperAdminUserId,
Username = "superadmin",
FullName = "Super Admin",
FK_RoleId = RoleAndPermissionSeed.SuperAdminRoleId,
IsActive = true,
CreatedAt = SeedDate
});
}
}
+14 -18
View File
@@ -7,10 +7,6 @@ public static class MasterDataSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 20);
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
// Implementation Notes in the plan; not derivable from HasData at migration time.
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
@@ -84,8 +80,8 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Models.Route>().HasData(
@@ -96,12 +92,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Store>().HasData(
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Warehouse>().HasData(
@@ -115,12 +111,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<User>().HasData(
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
new User { Id = Supervisor1UserId, Username = "sunil.fernando",FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera",FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva",FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya",FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara",FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<DriverProfile>().HasData(
@@ -27,8 +27,6 @@ public static class OperationsExtrasSeed
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
@@ -45,7 +43,7 @@ public static class OperationsExtrasSeed
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<Uom>().HasData(
@@ -83,12 +81,6 @@ public static class OperationsExtrasSeed
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
);
modelBuilder.Entity<StoreKycDocument>().HasData(
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
);
modelBuilder.Entity<DriverComplianceConfig>().HasData(
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
);
+181 -12
View File
@@ -7,22 +7,191 @@ public static class RoleAndPermissionSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 18);
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
internal static readonly Guid SuperAdminRoleId = new("10000000-0000-0000-0000-000000000007");
// ── Legacy broad permissions (nav.ts uses these for sidebar visibility) ────
private static readonly Guid PermOrdersView = new("20000000-0000-0000-0000-000000000001");
private static readonly Guid PermRidesView = new("20000000-0000-0000-0000-000000000002");
private static readonly Guid PermLoadingView = new("20000000-0000-0000-0000-000000000003");
private static readonly Guid PermStoresView = new("20000000-0000-0000-0000-000000000004");
private static readonly Guid PermSalesVisitsCheckin = new("20000000-0000-0000-0000-000000000005");
private static readonly Guid PermItemsView = new("20000000-0000-0000-0000-000000000006");
private static readonly Guid PermCategoriesView = new("20000000-0000-0000-0000-000000000007");
private static readonly Guid PermWarehousesView = new("20000000-0000-0000-0000-000000000008");
private static readonly Guid PermPurchaseOrdersView = new("20000000-0000-0000-0000-000000000009");
private static readonly Guid PermPricelistsView = new("20000000-0000-0000-0000-000000000010");
private static readonly Guid PermSchemesView = new("20000000-0000-0000-0000-000000000011");
private static readonly Guid PermSuppliersView = new("20000000-0000-0000-0000-000000000012");
private static readonly Guid PermDriverProfilesView = new("20000000-0000-0000-0000-000000000013");
private static readonly Guid PermComplianceManage = new("20000000-0000-0000-0000-000000000014");
private static readonly Guid PermUsersView = new("20000000-0000-0000-0000-000000000015");
private static readonly Guid PermRolesView = new("20000000-0000-0000-0000-000000000016");
private static readonly Guid PermRegionsView = new("20000000-0000-0000-0000-000000000017");
private static readonly Guid PermRoutesView = new("20000000-0000-0000-0000-000000000018");
private static readonly Guid PermVehiclesView = new("20000000-0000-0000-0000-000000000019");
// ── Granular API permissions (used in [Authorize("...")] on controller actions) ─
private static readonly Guid PermClaimView = new("20000000-0000-0000-0000-000000000020");
private static readonly Guid PermClaimManage = new("20000000-0000-0000-0000-000000000021");
private static readonly Guid PermDeliveryTaskView = new("20000000-0000-0000-0000-000000000022");
private static readonly Guid PermDeliveryTaskManage = new("20000000-0000-0000-0000-000000000023");
private static readonly Guid PermDriverView = new("20000000-0000-0000-0000-000000000024");
private static readonly Guid PermDriverManage = new("20000000-0000-0000-0000-000000000025");
private static readonly Guid PermGrnView = new("20000000-0000-0000-0000-000000000026");
private static readonly Guid PermGrnManage = new("20000000-0000-0000-0000-000000000027");
private static readonly Guid PermLoadingSheetView = new("20000000-0000-0000-0000-000000000028");
private static readonly Guid PermLoadingSheetManage = new("20000000-0000-0000-0000-000000000029");
private static readonly Guid PermPriceListView = new("20000000-0000-0000-0000-000000000030");
private static readonly Guid PermPriceListManage = new("20000000-0000-0000-0000-000000000031");
private static readonly Guid PermPurchaseOrderView = new("20000000-0000-0000-0000-000000000032");
private static readonly Guid PermPurchaseOrderManage= new("20000000-0000-0000-0000-000000000033");
private static readonly Guid PermPurchaseReturnManage=new("20000000-0000-0000-0000-000000000034");
private static readonly Guid PermRegionView = new("20000000-0000-0000-0000-000000000035");
private static readonly Guid PermRegionManage = new("20000000-0000-0000-0000-000000000036");
private static readonly Guid PermReturnView = new("20000000-0000-0000-0000-000000000037");
private static readonly Guid PermReturnManage = new("20000000-0000-0000-0000-000000000038");
private static readonly Guid PermRouteView = new("20000000-0000-0000-0000-000000000039");
private static readonly Guid PermRouteManage = new("20000000-0000-0000-0000-000000000040");
private static readonly Guid PermSchemeView = new("20000000-0000-0000-0000-000000000041");
private static readonly Guid PermSchemeManage = new("20000000-0000-0000-0000-000000000042");
private static readonly Guid PermStockView = new("20000000-0000-0000-0000-000000000043");
private static readonly Guid PermStockManage = new("20000000-0000-0000-0000-000000000044");
private static readonly Guid PermStoreView = new("20000000-0000-0000-0000-000000000045");
private static readonly Guid PermStoreManage = new("20000000-0000-0000-0000-000000000046");
private static readonly Guid PermSupplierClaimView = new("20000000-0000-0000-0000-000000000047");
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Role>().HasData(
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SuperAdminRoleId, Name = "SuperAdmin", Level = 100, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Permission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new Permission { Id = PermOrdersView, Code = "orders.view", Description = "View shop POs, invoices, payments and returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRidesView, Code = "rides.view", Description = "View and manage delivery rides and delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingView, Code = "loading.view", Description = "View and manage loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoresView, Code = "stores.view", Description = "View store accounts, credit limits and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSalesVisitsCheckin, Code = "sales_visits.checkin", Description = "Check in and out of scheduled store visits", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermItemsView, Code = "items.view", Description = "View and manage catalogue products", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermCategoriesView, Code = "categories.view", Description = "View and manage product categories and brands", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermWarehousesView, Code = "warehouses.view", Description = "View stock balances, movements, warehouses and reports", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrdersView, Code = "purchase_orders.view", Description = "View and manage purchase orders, GRNs and supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPricelistsView, Code = "pricelists.view", Description = "View and manage regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemesView, Code = "schemes.view", Description = "View and manage pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSuppliersView, Code = "suppliers.view", Description = "View and manage supplier accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverProfilesView, Code = "driver_profiles.view", Description = "View and manage driver profiles and licensing", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermComplianceManage, Code = "compliance.manage", Description = "Manage driver compliance rules", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermUsersView, Code = "users.view", Description = "View and manage staff user accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRolesView, Code = "roles.view", Description = "View and manage roles and their permission sets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionsView, Code = "regions.view", Description = "View and manage regions and region configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRoutesView, Code = "routes.view", Description = "View and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermVehiclesView, Code = "vehicles.view", Description = "View and manage fleet vehicles", CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new Permission { Id = PermClaimView, Code = "claim.view", Description = "View agency claims and settlement status", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermClaimManage, Code = "claim.manage", Description = "Create and settle agency claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskView, Code = "delivery-task.view", Description = "View delivery tasks within a ride", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskManage, Code = "delivery-task.manage", Description = "Confirm and fail delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverView, Code = "driver.view", Description = "View driver profiles and license details", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverManage, Code = "driver.manage", Description = "Create and update driver profiles", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnView, Code = "grn.view", Description = "View goods receipt notes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnManage, Code = "grn.manage", Description = "Create and confirm goods receipts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetView, Code = "loading-sheet.view", Description = "View loading sheets and their items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetManage, Code = "loading-sheet.manage", Description = "Create, edit, and confirm loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListView, Code = "price-list.view", Description = "View regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListManage, Code = "price-list.manage", Description = "Create and manage item price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderView, Code = "purchase-order.view", Description = "View purchase orders and line items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderManage,Code = "purchase-order.manage", Description = "Create, confirm, and close purchase orders", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseReturnManage,Code= "purchase-return.manage",Description = "Create and manage purchase returns to suppliers", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionView, Code = "region.view", Description = "View regions and their configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionManage, Code = "region.manage", Description = "Create and update regions, targets, and budgets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnView, Code = "return.view", Description = "View order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnManage, Code = "return.manage", Description = "Create and manage order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteView, Code = "route.view", Description = "View delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteManage, Code = "route.manage", Description = "Create and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeView, Code = "scheme.view", Description = "View pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeManage, Code = "scheme.manage", Description = "Create and configure pricing schemes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockView, Code = "stock.view", Description = "View stock balances and movements", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockManage, Code = "stock.manage", Description = "Transfer stock and record damage or adjustments", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreView, Code = "store.view", Description = "View store accounts and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreManage, Code = "store.manage", Description = "Create and update store accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
);
// SuperAdmin gets every permission
modelBuilder.Entity<RolePermission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRidesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoresView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSalesVisitsCheckin, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermItemsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermCategoriesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermWarehousesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPricelistsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSuppliersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverProfilesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermComplianceManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermUsersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRolesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRoutesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermVehiclesView, CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseReturnManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
);
}
}
+73 -10
View File
@@ -1,3 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -6,32 +8,93 @@ namespace Distribution.Features.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : ControllerBase
{
private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
{
var result = await service.LoginAsync(request, ct);
if (!result.Success || result.Data is not { } data)
return ToActionResult(result);
string? token = null;
TimeSpan maxAge = TimeSpan.FromHours(1);
if (data.TryGetProperty("accessToken", out var tokenEl))
token = tokenEl.GetString();
if (data.TryGetProperty("expiresIn", out var expEl))
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
if (!string.IsNullOrEmpty(token))
{
Response.Cookies.Append("access-token", token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = maxAge,
Secure = Request.IsHttps,
});
}
MeResponse? profile = null;
if (!string.IsNullOrEmpty(token))
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
if (Guid.TryParse(userIdStr, out var userId))
{
var user = await userRepo.GetByIdAsync(userId);
if (user is not null)
profile = new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0);
}
}
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken ct)
public async Task<IActionResult> Me()
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
if (currentUser.UserId is null) return Unauthorized();
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value);
if (user is null) return Unauthorized();
return Ok(new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
[AllowAnonymous]
public IActionResult Logout()
{
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
Response.Cookies.Delete("access-token", new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
Secure = Request.IsHttps,
});
return Ok(new { success = true });
}
}
+13 -2
View File
@@ -1,5 +1,16 @@
namespace Distribution.Features.Auth;
public record LoginRequest(string Username, string Password);
public record LoginRequest(
string Identifier,
string Password,
Guid? UserTypeId = null,
string DeviceName = "Unknown Device");
public record RegisterRequest(string Username, string Password, string? FullName);
public record RegisterRequest(string Username, string email, string Password, string? FullName);
public record MeResponse(
Guid UserId,
string Username,
Guid RoleId,
string RoleCode,
int RoleLevel);
@@ -1,12 +1,11 @@
using System.Net.Http.Json;
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates;
/// this app never sees or stores a plaintext/hashed password.
/// </summary>
public class AuthHexClient(HttpClient http) : IAuthHexClient
{
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
public record AuthHexResponse(
+8 -4
View File
@@ -1,17 +1,21 @@
using Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates.
/// </summary>
public class AuthService(IAuthHexClient authHex)
{
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
authHex.CallAsync("loginUser", new
{
identifier = request.Identifier,
password = request.Password,
userTypeId = request.UserTypeId,
deviceName = request.DeviceName
}, reference: Guid.NewGuid().ToString(), ct: ct);
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
@@ -1,20 +0,0 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -1,11 +0,0 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -1,4 +1,4 @@
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
public interface IAuthHexClient
{
@@ -1,16 +0,0 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken ct);
}
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Claims;
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims")]
[Authorize("claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/claims/{id:guid}")]
[Authorize("claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims")]
[Authorize("claim.manage")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
@@ -29,6 +33,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPut("api/claims/{id:guid}")]
[Authorize("claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
{
try
@@ -40,6 +45,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize("claim.manage")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.SettledByUserId;
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
@@ -7,6 +9,7 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
@@ -20,13 +23,26 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks")]
//[Authorize("delivery-task.view")]
public async Task<IActionResult> GetAll(
[FromQuery] DeliveryTaskStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, page, pageSize);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -34,6 +50,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
@@ -46,6 +63,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
@@ -58,6 +76,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
@@ -26,6 +26,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
.OrderBy(t => t.Sequence)
.ToListAsync();
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var query = context.DeliveryTasks
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.OrderByDescending(t => t.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
{
var query = context.DeliveryTasks.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.CountAsync();
}
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
@@ -1,3 +1,4 @@
using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
@@ -10,6 +11,7 @@ public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
@@ -32,8 +34,52 @@ public class DeliveryTaskService(
i.UnitPrice)).ToList(),
t.CreatedAt);
public Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default) =>
throw new NotImplementedException("Delivery task auto-generation is deferred — route-based dispatch not yet implemented.");
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue);
if (confirmedSheets.Count == 0)
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
var shopPOIds = confirmedSheets
.SelectMany(s => s.Orders)
.Select(o => o.FK_ShopPOId)
.Distinct()
.ToList();
var existingTasks = await repo.GetByRideIdAsync(rideId);
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
var newTasks = new List<DeliveryTask>();
foreach (var shopPOId in shopPOIds)
{
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
newTasks.Add(new DeliveryTask
{
FK_RideId = rideId,
FK_ShopPOId = shopPOId,
Sequence = nextSequence++,
Status = DeliveryTaskStatus.Pending
});
}
if (newTasks.Count > 0)
{
await uow.BeginAsync(ct);
try
{
foreach (var task in newTasks) await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
}
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
@@ -41,6 +87,13 @@ public class DeliveryTaskService(
return tasks.Select(ToResponse).ToList();
}
public async Task<PagedResponse<DeliveryTaskResponse>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var items = await repo.GetAllAsync(status, page, pageSize);
var total = await repo.GetCountAsync(status);
return new PagedResponse<DeliveryTaskResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
@@ -6,6 +6,8 @@ public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize);
Task<int> GetCountAsync(DeliveryTaskStatus? status);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
@@ -1,5 +1,6 @@
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
@@ -12,6 +13,7 @@ public class DriverProfileController(
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("driver.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
@@ -19,6 +21,7 @@ public class DriverProfileController(
}
[HttpGet("{id:guid}")]
[Authorize("driver.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -26,6 +29,7 @@ public class DriverProfileController(
}
[HttpPost]
[Authorize("driver.manage")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
try
@@ -44,6 +48,7 @@ public class DriverProfileController(
}
[HttpPut("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -51,6 +56,7 @@ public class DriverProfileController(
}
[HttpDelete("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
@@ -58,6 +64,7 @@ public class DriverProfileController(
}
[HttpGet("compliance")]
[Authorize("driver.view")]
public async Task<IActionResult> GetCompliance()
{
var result = await complianceService.GetAsync();
@@ -65,6 +72,7 @@ public class DriverProfileController(
}
[HttpPut("compliance")]
[Authorize("driver.manage")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+24 -2
View File
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
@@ -7,6 +8,7 @@ namespace Distribution.Features.Grn;
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1,
@@ -17,6 +19,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpGet("api/goods-receipts/{id:guid}")]
[Authorize("grn.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -24,6 +27,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpPost("api/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -32,18 +36,36 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
/// <summary>Goods received without a purchase order.</summary>
[HttpPost("api/goods-receipts/direct")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateDirectAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
+76 -7
View File
@@ -1,35 +1,104 @@
namespace Distribution.Features.Grn;
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
/// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary>
public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemRequest(
// Set for a PO-based receipt; null for a direct receipt (ItemId is used instead).
Guid? PurchaseOrderItemId,
int AcceptedQuantity,
int RejectedQuantity,
// Populated only when the item has TracksBatches and/or TracksExpiryDate set.
List<GrnBatchLineRequest>? Batches = null,
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order).
Guid? ItemId = null,
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
decimal? UnitCost = null,
// Null falls back to the GRN-wide percentage.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0,
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
decimal? NbtPercent = null);
public record CreateGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest(
Guid PurchaseOrderId,
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null);
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
Guid? SupplierId = null,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
Guid? PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
int RejectedQuantity);
int RejectedQuantity,
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers,
decimal UnitCost,
decimal DiscountPercent,
decimal VatPercent,
// Accepted qty x unit cost, before discount.
decimal GrossAmount,
decimal DiscountAmount,
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
public record GrnResponse(
Guid Id,
Guid PurchaseOrderId,
Guid? PurchaseOrderId,
Guid ReceivedByUserId,
Guid WarehouseId,
string WarehouseName,
Guid? SupplierId,
string? SupplierName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items);
List<GrnItemResponse> Items,
decimal DiscountPercent,
decimal VatPercent,
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
+37
View File
@@ -0,0 +1,37 @@
namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary>
public static class GrnPricing
{
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{
var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m);
var net = gross - discount;
var nbt = Round(net * nbtPercent / 100m);
var vat = Round((net + nbt) * vatPercent / 100m);
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
}
public static void ValidatePercent(decimal value, string label)
{
if (value < 0 || value > 100)
throw new InvalidOperationException($"{label} must be between 0 and 100.");
}
public static void ValidateCost(decimal value, string label)
{
if (value < 0)
throw new InvalidOperationException($"{label} cannot be negative.");
}
}
+17 -5
View File
@@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
+382 -41
View File
@@ -1,5 +1,6 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -9,15 +10,88 @@ public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item;
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
i.AcceptedQuantity, i.RejectedQuantity,
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
i.NbtPercent, a.Nbt);
}
private static GrnResponse Map(Models.GoodsReceipt gr) =>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
private static GrnResponse Map(Models.GoodsReceipt gr)
{
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
var nbt = items.Sum(i => i.NbtAmount);
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
gr.NbtPercent, nbt);
}
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
/// <summary>
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
/// forced to 0 whatever the client sent.
/// </summary>
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
{
var discount = discountPercent ?? 0m;
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
}
/// <summary>
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
/// </summary>
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
{
if (mode == TaxMode.Disabled) return 0m;
var value = lineValue ?? headerValue;
if (value is null)
{
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
return 0m;
}
GrnPricing.ValidatePercent(value.Value, label);
return value.Value;
}
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
GrnHeader header, GrnTaxSettings tax, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
}
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
@@ -36,10 +110,61 @@ public class GrnService(
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an
/// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for
/// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not
/// yet linked into the ledger (follow-up work).
/// </summary>
private static void ValidateLineShape(Models.Item item, GrnItemRequest line)
{
if (item.TracksSerialNumbers)
{
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
throw new InvalidOperationException(
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
}
else if (line.SerialNumbers is { Count: > 0 })
{
throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers.");
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
if (StockQty(line) > 0)
{
if (line.Batches is null || line.Batches.Count == 0)
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
}
foreach (var b in line.Batches ?? [])
{
if (b.Quantity <= 0)
throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive.");
if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber))
throw new InvalidOperationException($"Item {item.Sku} requires a batch number.");
if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber))
throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers.");
if (item.TracksExpiryDate && b.ExpiryDate is null)
throw new InvalidOperationException($"Item {item.Sku} requires an expiry date.");
}
}
else if (line.Batches is { Count: > 0 })
{
throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates.");
}
}
/// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
/// units don't count against the PO's ordered quantity), creates
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
@@ -56,37 +181,63 @@ public class GrnService(
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now
ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
foreach (var line in request.Items)
{
if (line.PurchaseOrderItemId is null)
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
ValidateLineShape(poItem.Item, line);
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
}
}
poItem.ReceivedQuantity += line.AcceptedQuantity;
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
var entity = new Models.GoodsReceiptItem
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity
});
};
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
}
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
@@ -94,48 +245,238 @@ public class GrnService(
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived;
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
}
/// <summary>
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
/// </summary>
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = null,
FK_SupplierId = request.SupplierId,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>();
foreach (var line in request.Items)
{
if (line.ItemId is null)
throw new InvalidOperationException("Each line of a direct receipt needs an ItemId.");
var item = await itemRepo.GetByIdAsync(line.ItemId.Value);
if (item is null || !item.IsActive)
throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
ValidateLineShape(item, line);
if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (!serialsInRequest.Add((item.Id, serialNumber)))
throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}.");
if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}.");
}
}
var entity = new Models.GoodsReceiptItem
{
FK_ItemId = item.Id,
FreeQuantity = line.FreeQuantity,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item));
}
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct);
}
/// <summary>
/// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO
/// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts.
/// </summary>
private async Task<GrnResponse> PostReceiptAsync(
Models.GoodsReceipt goodsReceipt,
List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines,
Guid warehouseId,
Guid receivedByUserId,
Models.PurchaseOrder? po,
CancellationToken ct)
{
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(goodsReceipt);
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
{
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
if (balance is null)
if (item.TracksSerialNumbers)
{
await stockRepo.AddBalanceAsync(new StockBalance
foreach (var serialNumber in line.SerialNumbers!)
{
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
var serial = new ItemSerialNumber
{
FK_ItemId = item.Id,
SerialNumber = serialNumber,
Status = SerialStatus.InStock,
CurrentLocationId = warehouseId,
CurrentLocationType = LocationType.Warehouse,
ReceivedAt = goodsReceipt.ReceivedAt
};
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
// stays null until the sale/delivery flow sets it.
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
await stockRepo.AddItemSerialNumberAsync(serial);
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
foreach (var b in line.Batches!)
{
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
: null;
if (batch is null)
{
batch = new ItemBatch
{
FK_ItemId = item.Id,
BatchNumber = b.BatchNumber,
ExpiryDate = b.ExpiryDate
};
await stockRepo.AddItemBatchAsync(batch);
}
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity
});
}
else
{
balance.Quantity += b.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
Quantity = StockQty(line)
});
}
else
{
balance.Quantity += StockQty(line);
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = poItem.FK_ItemId,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = StockQty(line),
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
poRepo.Update(po);
if (po is not null) poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -6,8 +6,9 @@ public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync();
Task AddAsync(Models.Invoice invoice);
void Update(Models.Invoice invoice);
}
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service) : ControllerBase
public class InvoiceController(InvoiceService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
@@ -13,10 +14,18 @@ public class InvoiceController(InvoiceService service) : ControllerBase
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status,
[FromQuery] PaymentStatus? paymentStatus,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
var result = await service.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return Ok(result);
}
[HttpGet("outstanding-summary")]
public async Task<IActionResult> GetOutstandingSummary()
{
var result = await service.GetOutstandingSummaryAsync();
return Ok(result);
}
@@ -26,4 +35,36 @@ public class InvoiceController(InvoiceService service) : ControllerBase
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request?.PrintedByUserId;
if (userId is null) return BadRequest(new { error = "PrintedByUserId is required while auth is unavailable" });
try
{
var result = await service.PrintAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/finalize")]
public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request?.FinalizedByUserId;
if (userId is null) return BadRequest(new { error = "FinalizedByUserId is required while auth is unavailable" });
try
{
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
+4 -1
View File
@@ -4,7 +4,8 @@ namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment> Adjustments);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
public record InvoiceResponse(
Guid Id,
@@ -20,3 +21,5 @@ public record InvoiceResponse(
decimal TotalAmount,
bool IsActive,
DateTime CreatedAt);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -9,6 +9,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
@@ -17,27 +18,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) =>
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status))
&& (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) =>
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status));
&& (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus));
public async Task<List<Models.Invoice>> GetOutstandingAsync() =>
await context.Invoices
.Include(i => i.Payments.Where(p => p.IsActive))
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
.ToListAsync();
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
@@ -24,10 +24,75 @@ public class InvoiceService(
return invoice is null ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize)
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, status);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync()
{
var invoices = await repo.GetOutstandingAsync();
var count = invoices.Count;
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount));
return new OutstandingPaymentsResponse(count, outstanding);
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedBy = printedBy;
await uow.BeginAsync(ct);
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
}
public async Task<InvoiceResponse> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var adjustments = request.Adjustments ?? [];
if (adjustments.Count > 0)
{
var poItems = invoice.ShopPO.Items.Where(i => i.IsActive).ToList();
decimal total = 0;
foreach (var item in poItems)
{
var adjustment = adjustments.FirstOrDefault(a => a.ShopPOItemId == item.Id);
var billedQuantity = adjustment is not null ? adjustment.AdjustedQuantity : item.Quantity;
if (billedQuantity < 0 || billedQuantity > item.Quantity)
throw new InvalidOperationException(
$"Adjusted quantity for item {item.Id} must be between 0 and {item.Quantity}");
total += billedQuantity * item.UnitPrice;
}
invoice.TotalAmount = total;
}
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy;
await uow.BeginAsync(ct);
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
}
}
+30 -2
View File
@@ -11,7 +11,14 @@ public record CreateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
List<UomConversionRequest>? Conversions);
List<UomConversionRequest>? Conversions,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
public record UpdateItemRequest(
string Name,
@@ -21,7 +28,14 @@ public record UpdateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
bool IsActive = true);
bool IsActive = true,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
@@ -40,6 +54,13 @@ public record ItemSummaryResponse(
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt);
@@ -58,6 +79,13 @@ public record ItemDetailResponse(
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt,
+61 -3
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Item;
public class ItemService(IItemRepository repo, IUnitOfWork uow)
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
{
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
@@ -13,7 +13,10 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description,
@@ -21,9 +24,44 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt,
i.Conversions.Select(MapConversion).ToList());
/// <summary>
/// Rejects per-item tracking flags that are off system-wide, and warranty-period values that
/// don't make sense given HasWarranty/AllowCustomWarrantyPeriod.
/// </summary>
private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches,
bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod)
{
if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync())
throw new InvalidOperationException("Serial number tracking is disabled system-wide.");
if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync())
throw new InvalidOperationException("Batch tracking is disabled system-wide.");
if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync())
throw new InvalidOperationException("Expiry date tracking is disabled system-wide.");
if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync())
throw new InvalidOperationException("Warranty tracking is disabled system-wide.");
if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync())
throw new InvalidOperationException("Custom warranty period override is disabled system-wide.");
if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod))
throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true.");
if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod)
throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true.");
if (warrantyPeriodDays is < 0)
throw new InvalidOperationException("WarrantyPeriodDays cannot be negative.");
}
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
.Select(MapSummary)
@@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
if (await repo.SkuExistsAsync(request.Sku))
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
var item = new Models.Item
{
Sku = request.Sku,
@@ -51,6 +92,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
FK_BrandId = request.BrandId,
FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice,
ReorderLevel = request.ReorderLevel,
TracksSerialNumbers = request.TracksSerialNumbers,
TracksBatches = request.TracksBatches,
TracksExpiryDate = request.TracksExpiryDate,
HasWarranty = request.HasWarranty,
WarrantyPeriodDays = request.WarrantyPeriodDays,
AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod,
SyncSource = Models.SyncSource.Manual
};
@@ -77,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
if (item is null) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
item.Name = request.Name;
item.Description = request.Description;
item.FK_CategoryId = request.CategoryId;
@@ -84,6 +135,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
item.FK_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice;
item.ReorderLevel = request.ReorderLevel;
item.TracksSerialNumbers = request.TracksSerialNumbers;
item.TracksBatches = request.TracksBatches;
item.TracksExpiryDate = request.TracksExpiryDate;
item.HasWarranty = request.HasWarranty;
item.WarrantyPeriodDays = request.WarrantyPeriodDays;
item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod;
item.IsActive = request.IsActive;
await uow.BeginAsync(ct);
@@ -6,10 +6,12 @@ public interface ILoadingSheetRepository
{
Task<LoadingSheet?> GetByIdAsync(Guid id);
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds);
Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds);
}
@@ -1,25 +1,47 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
public class LoadingSheetController(
LoadingSheetService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
//[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
[FromQuery] Guid? routeId,
[FromQuery] Guid? regionId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(rideId, status, page, pageSize);
if (routeId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, routeId.Value))
return StatusCode(403);
}
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -27,12 +49,16 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.PopulatedByUserId;
if (userId is null) return BadRequest(new { error = "PopulatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId, ct);
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -40,6 +66,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/items")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -52,12 +79,16 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
try
{
var result = await service.ConfirmAsync(id, userId, ct);
var result = await service.ConfirmAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -21,11 +21,16 @@ public record LoadingSheetItemResponse(
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
Guid? FK_ShopPOItemId);
Guid? FK_ShopPOItemId,
Guid? ShopPOId = null,
Guid? StoreId = null,
string? StoreName = null);
public record LoadingSheetOrderResponse(Guid ShopPOId);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null);
@@ -10,6 +10,7 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
.Include(s => s.Orders)
.FirstOrDefaultAsync(s => s.Id == id);
@@ -17,24 +18,29 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
await context.LoadingSheets
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
.Include(s => s.Orders)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
(status == null || s.Status == status) &&
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId) =>
await context.LoadingSheets
.CountAsync(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status));
(status == null || s.Status == status) &&
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)));
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
@@ -47,4 +53,25 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
await context.LoadingSheetItems.AddRangeAsync(items);
public async Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds) =>
await context.ShopPOItems
.Where(i => shopPOItemIds.Contains(i.Id))
.Select(i => i.FK_ShopPOId)
.Distinct()
.ToListAsync();
public async Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds)
{
var current = await context.LoadingSheetOrders
.Where(o => o.FK_LoadingSheetId == loadingSheetId)
.ToListAsync();
context.LoadingSheetOrders.RemoveRange(current);
await context.LoadingSheetOrders.AddRangeAsync(shopPOIds.Select(shopPOId => new LoadingSheetOrder
{
FK_LoadingSheetId = loadingSheetId,
FK_ShopPOId = shopPOId
}));
}
}
@@ -11,10 +11,10 @@ public class LoadingSheetService(
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
{
var total = await repo.GetCountAsync(rideId, status);
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
var total = await repo.GetCountAsync(rideId, status, routeId, regionId);
var items = await repo.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -64,18 +64,54 @@ public class LoadingSheetService(
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
// The request is the full desired item list: a row matching (item, warehouse,
// shop PO item) gets its quantity updated in place, a new combination is added,
// and rows for combinations not present in the request are removed.
await uow.BeginAsync(ct);
try
{
repo.RemoveItems(sheet.Items);
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
var newItems = new List<LoadingSheetItem>();
var keptItems = new List<LoadingSheetItem>();
foreach (var input in request.Items)
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
FK_WarehouseId = i.FK_WarehouseId,
FK_ShopPOItemId = i.FK_ShopPOItemId,
QuantityDispatched = i.Quantity
}));
var existing = sheet.Items.FirstOrDefault(i =>
i.FK_ItemId == input.FK_ItemId &&
i.FK_WarehouseId == input.FK_WarehouseId &&
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
if (existing is not null)
{
existing.QuantityDispatched = input.Quantity;
keptItems.Add(existing);
}
else
newItems.Add(new LoadingSheetItem
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = input.FK_ItemId,
FK_WarehouseId = input.FK_WarehouseId,
FK_ShopPOItemId = input.FK_ShopPOItemId,
QuantityDispatched = input.Quantity
});
}
var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList();
if (removedItems.Count > 0)
repo.RemoveItems(removedItems);
if (newItems.Count > 0)
await repo.AddItemsAsync(newItems);
var shopPOItemIds = keptItems.Select(i => i.FK_ShopPOItemId)
.Concat(newItems.Select(i => i.FK_ShopPOItemId))
.Where(x => x.HasValue)
.Select(x => x!.Value)
.Distinct()
.ToList();
var shopPOIds = shopPOItemIds.Count > 0
? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds)
: new List<Guid>();
await repo.ReplaceOrdersAsync(sheet.Id, shopPOIds);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -118,28 +154,39 @@ public class LoadingSheetService(
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
{
// Vehicle balances created during this confirm are only tracked, not yet saved, so a
// later row for the same item would not find them via the repository and would try to
// insert a duplicate (item, vehicle) balance.
var createdVehicleBalances = new Dictionary<Guid, StockBalance>();
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {item.FK_ItemId}");
?? throw new InvalidOperationException(
$"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'");
if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException($"Insufficient warehouse stock for item {item.FK_ItemId}");
throw new InvalidOperationException(
$"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " +
$"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})");
warehouseBalance.Quantity -= item.QuantityDispatched;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var vehicleBalance = createdVehicleBalances.GetValueOrDefault(item.FK_ItemId)
?? await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
var created = new StockBalance
{
FK_ItemId = item.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched
});
};
createdVehicleBalances[item.FK_ItemId] = created;
await stockRepo.AddBalanceAsync(created);
}
else
{
@@ -169,6 +216,9 @@ public class LoadingSheetService(
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId)).ToList(),
i.QuantityDispatched, i.FK_ShopPOItemId,
i.ShopPOItem?.FK_ShopPOId,
i.ShopPOItem?.ShopPO?.FK_StoreId,
i.ShopPOItem?.ShopPO?.Store?.StoreName)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
}
@@ -1,16 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
public class PriceListController(PriceListService service) : ControllerBase
public class PriceListController(
PriceListService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[Authorize("price-list.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId)
{
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
return Ok(await service.GetAllAsync(regionId));
}
[HttpGet("resolve")]
[Authorize("price-list.view")]
public async Task<IActionResult> Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate)
{
var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate);
@@ -18,6 +33,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpGet("{id:guid}")]
[Authorize("price-list.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,6 +41,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPost]
[Authorize("price-list.manage")]
public async Task<IActionResult> Create(CreatePriceListRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -32,6 +49,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> Update(Guid id, UpdatePriceListRequest request, CancellationToken ct)
{
try
@@ -46,6 +64,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -53,6 +72,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPut("{id:guid}/items/{sku}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> UpsertItem(Guid id, string sku, UpsertPriceListItemRequest request, CancellationToken ct)
{
try
@@ -67,6 +87,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpDelete("{id:guid}/items/{sku}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
{
try
@@ -0,0 +1,18 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record SalesAggregate(Guid ItemId, string Sku, string Name, decimal? CostPrice,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled, decimal SalesValue);
public record ReturnAggregate(Guid ItemId, string Sku, string Name, ReturnReasonCode Reason,
decimal Qty, decimal Value);
public interface IProductReportRepository
{
Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize);
Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize);
Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f);
Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds);
Task<decimal> GetTotalSalesAsync(ProductReportFilter f);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ProductReport;
[ApiController]
[Route("api/reports/products")]
public class ProductReportController(ProductReportService service) : ControllerBase
{
private IActionResult? Check(ProductReportFilter f, int page, int pageSize)
{
var error = ProductReportService.Validate(f, page, pageSize);
return error is null ? null : BadRequest(new { message = error });
}
[HttpGet("stock")]
public async Task<IActionResult> Stock(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? warehouseId, [FromQuery] bool activeOnly = true, [FromQuery] bool lowStockOnly = false,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, warehouseId, null, null, null,
null, null, activeOnly, lowStockOnly, search);
return Check(f, page, pageSize) ?? Ok(await service.GetStockAsync(f, page, pageSize));
}
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetSalesAsync(f, page, pageSize));
}
[HttpGet("returns")]
public async Task<IActionResult> Returns(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetReturnsAsync(f, page, pageSize));
}
[HttpGet("summary")]
public async Task<IActionResult> Summary(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var f = new ProductReportFilter(brandId, categoryId, null, warehouseId, null, null, null,
from, to, true, false, null);
return Check(f, 1, 1) ?? Ok(await service.GetSummaryAsync(f));
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record ProductStockRow(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string? SubCategory, string Uom,
decimal? SalePrice, decimal? CostPrice, decimal? ReorderLevel,
decimal WarehouseQty, decimal VehicleQty, decimal TotalQty, decimal StockValue,
bool IsLowStock, bool IsActive);
public record ProductSalesRow(
Guid ItemId, string Sku, string Name,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled,
decimal SalesValue, decimal? CostValue, decimal? Margin, decimal? MarginPercent);
public record ProductReturnRow(
Guid ItemId, string Sku, string Name,
decimal ReturnedQty, decimal ReturnedValue, decimal SoldQty, decimal? ReturnPercent,
Dictionary<ReturnReasonCode, decimal> QtyByReason);
public record ProductReportSummary(int ItemCount, decimal TotalQty, decimal TotalValue, decimal TotalSales);
public record ProductReportFilter(
Guid? BrandId, Guid? CategoryId, Guid? SubCategoryId, Guid? WarehouseId,
Guid? RegionId, Guid? RouteId, Guid? StoreId,
DateTime? From, DateTime? To, bool ActiveOnly, bool LowStockOnly, string? Search);
@@ -0,0 +1,156 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ProductReport;
public class ProductReportRepository(AppDBContext db) : IProductReportRepository
{
public async Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var balances = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
balances = balances.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
var items = db.Items.AsNoTracking().AsQueryable();
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) items = items.Where(i => i.FK_SubCategoryId == f.SubCategoryId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
}
var withQty = items.Select(i => new
{
Item = i,
Wh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Warehouse)
.Sum(b => b.Quantity),
Veh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Vehicle)
.Sum(b => b.Quantity),
});
if (f.LowStockOnly)
withQty = withQty.Where(x => x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel);
var total = await withQty.CountAsync();
var page_ = await withQty
.OrderBy(x => x.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.Item.Id, x.Item.Sku, x.Item.Name,
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
Category = x.Item.Category.Name,
SubCategory = x.Item.SubCategory != null ? x.Item.SubCategory.Name : null,
Uom = x.Item.BaseUom.Name,
x.Item.SalePrice, x.Item.CostPrice, x.Item.ReorderLevel, x.Item.IsActive,
x.Wh, x.Veh,
})
.ToListAsync();
var rows = page_.Select(r => new ProductStockRow(
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.SubCategory, r.Uom,
r.SalePrice, r.CostPrice, r.ReorderLevel,
r.Wh, r.Veh, r.Wh + r.Veh, (r.Wh + r.Veh) * (r.CostPrice ?? 0m),
r.ReorderLevel != null && r.Wh <= r.ReorderLevel,
r.IsActive)).ToList();
return (rows, total);
}
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
{
var lines =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
select li;
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
lines = lines.Where(l => l.Item.Name.Contains(s) || l.Item.Sku.Contains(s));
}
return lines;
}
public async Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var query = SoldLines(f)
.GroupBy(l => new { l.FK_ItemId, l.Item.Sku, l.Item.Name, l.Item.CostPrice })
.Select(g => new
{
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
});
var total = await query.CountAsync();
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
// Distinct stores per item is counted separately: MySQL can't run the correlated
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
var ids = page_.Select(r => r.FK_ItemId).ToList();
var storePairs = await SoldLines(f)
.Where(l => ids.Contains(l.FK_ItemId))
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
.Distinct()
.ToListAsync();
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
return (rows, total);
}
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
{
var ids = itemIds.ToList();
return await SoldLines(f with { Search = null })
.Where(l => ids.Contains(l.FK_ItemId))
.GroupBy(l => l.FK_ItemId)
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
.ToDictionaryAsync(x => x.Key, x => x.Qty);
}
public Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f)
{
var q = db.SaleReturnItems.AsNoTracking().AsQueryable();
if (f.From.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt >= f.From.Value);
if (f.To.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt < f.To.Value.Date.AddDays(1));
if (f.BrandId.HasValue) q = q.Where(r => r.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) q = q.Where(r => r.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
q = q.Where(r => r.Item.Name.Contains(s) || r.Item.Sku.Contains(s));
}
return q.GroupBy(r => new { r.FK_ItemId, r.Item.Sku, r.Item.Name, r.ReasonCode })
.Select(g => new ReturnAggregate(g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.ReasonCode,
g.Sum(x => x.Quantity), g.Sum(x => x.Quantity * x.UnitPrice)))
.ToListAsync();
}
}
@@ -0,0 +1,72 @@
using Distribution.Features.Stock;
namespace Distribution.Features.ProductReport;
public class ProductReportService(IProductReportRepository repo)
{
public const int MaxRangeDays = 366;
public static string? Validate(ProductReportFilter f, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (f.From.HasValue && f.To.HasValue)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To.Value - f.From.Value).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
}
return null;
}
public async Task<PagedResponse<ProductStockRow>> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetStockAsync(f, page, pageSize);
return new PagedResponse<ProductStockRow>(page, pageSize, total, rows);
}
public async Task<PagedResponse<ProductSalesRow>> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetSalesAsync(f, page, pageSize);
var mapped = rows.Select(r =>
{
decimal? cost = r.CostPrice.HasValue ? r.CostPrice.Value * r.QtyFulfilled : null;
decimal? margin = cost.HasValue ? r.SalesValue - cost.Value : null;
decimal? pct = margin.HasValue && r.SalesValue != 0 ? Math.Round(margin.Value / r.SalesValue * 100, 2) : null;
return new ProductSalesRow(r.ItemId, r.Sku, r.Name, r.Orders, r.Stores, r.QtyOrdered, r.QtyFulfilled,
r.SalesValue, cost, margin, pct);
}).ToList();
return new PagedResponse<ProductSalesRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<ProductReturnRow>> GetReturnsAsync(ProductReportFilter f, int page, int pageSize)
{
var agg = await repo.GetReturnsAsync(f);
var grouped = agg.GroupBy(a => new { a.ItemId, a.Sku, a.Name })
.Select(g => new
{
g.Key,
Qty = g.Sum(x => x.Qty),
Value = g.Sum(x => x.Value),
ByReason = g.ToDictionary(x => x.Reason, x => x.Qty),
})
.OrderByDescending(x => x.Value).ThenBy(x => x.Key.Name)
.ToList();
var pageRows = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var sold = await repo.GetSoldQtyAsync(f, pageRows.Select(r => r.Key.ItemId));
var rows = pageRows.Select(r =>
{
var soldQty = sold.GetValueOrDefault(r.Key.ItemId);
decimal? pct = soldQty > 0 ? Math.Round(r.Qty / soldQty * 100, 2) : null;
return new ProductReturnRow(r.Key.ItemId, r.Key.Sku, r.Key.Name, r.Qty, r.Value, soldQty, pct, r.ByReason);
}).ToList();
return new PagedResponse<ProductReturnRow>(page, pageSize, grouped.Count, rows);
}
public async Task<ProductReportSummary> GetSummaryAsync(ProductReportFilter f)
{
var (rows, total) = await repo.GetStockAsync(f, 1, int.MaxValue);
var totalSales = await repo.GetTotalSalesAsync(f);
return new ProductReportSummary(total, rows.Sum(r => r.TotalQty), rows.Sum(r => r.StockValue), totalSales);
}
}
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
@@ -9,10 +10,12 @@ namespace Distribution.Features.Po;
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
Ok(await service.GetAllAsync(supplierId, status));
[HttpGet("{id:guid}")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -20,6 +23,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -35,6 +39,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPut("{id:guid}")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
{
try
@@ -46,6 +51,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/close")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
{
try
@@ -57,6 +63,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/cancel")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
{
try
@@ -68,6 +75,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
{
try
@@ -79,6 +87,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/match-invoice")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
{
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
@@ -86,6 +95,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/resolve-variance")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
{
try
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
@@ -7,6 +8,7 @@ namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -14,10 +16,12 @@ public class PurchaseReturnController(PurchaseReturnService service, ICurrentUse
}
[HttpGet("api/purchase-orders/{id:guid}/returns")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/returns")]
[Authorize("purchase-return.manage")]
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -4,6 +4,14 @@ public interface IRegionRepository
{
Task<Models.Region?> GetByIdAsync(Guid id);
Task<List<Models.Region>> GetAllAsync();
Task<List<string>> GetAllCodesAsync();
Task<bool> CodeExistsAsync(string code, Guid? excludeId = null);
Task<Models.User?> GetUserWithRoleAsync(Guid userId);
Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId);
Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId);
Task AddRegionScopeAsync(Models.UserRegionScope scope);
void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes);
Task AddAsync(Models.Region entity);
void Update(Models.Region entity);
void Remove(Models.Region entity);
@@ -1,23 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Region;
[ApiController]
[Route("api/regions")]
public class RegionController(RegionService service) : ControllerBase
public class RegionController(
RegionService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("region.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("next-code")]
[Authorize("region.view")]
public async Task<IActionResult> GetNextCode() =>
Ok(await service.GetNextCodeAsync());
[HttpGet("{id:guid}")]
[Authorize("region.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("region.manage")]
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -25,6 +41,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -32,14 +49,46 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
//targets/budgets/agencies/tax-rule.
[HttpGet("{id:guid}/manager")]
[Authorize("region.view")]
public async Task<IActionResult> GetManager(Guid id)
{
var result = await service.GetManagerAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/manager")]
[Authorize("region.manage")]
public async Task<IActionResult> SetManager(Guid id, SetRegionManagerRequest request, CancellationToken ct)
{
try
{
var result = await service.SetManagerAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/manager")]
[Authorize("region.manage")]
public async Task<IActionResult> RemoveManager(Guid id, CancellationToken ct)
{
var removed = await service.RemoveManagerAsync(id, ct);
return removed ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/manage")]
[Authorize("region.manage")]
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
{
var result = await service.ManageAsync(id, request, ct);
@@ -47,6 +96,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/hierarchy")]
[Authorize("region.view")]
public async Task<IActionResult> GetHierarchy(Guid id)
{
var result = await service.GetHierarchyAsync(id);
@@ -54,18 +104,20 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize("region.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
[HttpGet("manage-entries")]
[Authorize("region.view")]
public async Task<IActionResult> GetManageEntries() =>
Ok(await service.GetManageEntriesAsync());
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
{
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
+13 -3
View File
@@ -1,10 +1,14 @@
namespace Distribution.Features.Region;
public record CreateRegionRequest(string Name);
// Code is optional: blank/null auto-generates the next REG-#### code; otherwise stored as entered (trimmed).
public record CreateRegionRequest(string Name, string? Code = null);
public record UpdateRegionRequest(string Name);
public record NextRegionCodeResponse(string Code);
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
// Code is auto-generated on create; on update it is optional (null/blank keeps the current code).
public record UpdateRegionRequest(string Name, string? Code = null);
public record RegionResponse(Guid Id, string Code, string Name, bool IsActive, DateTime CreatedAt);
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
@@ -61,3 +65,9 @@ public record RegionManageEntryResponse(
string? RuleConfig,
Guid? AgencyUserId,
DateTime CreatedAt);
// Region manager = the single active user with the RegionalManager role scoped to the region
// (UserRegionScope). Users themselves are owned by the auth service; this only links one to a region.
public record SetRegionManagerRequest(Guid UserId);
public record RegionManagerResponse(Guid RegionId, Guid UserId, string Username, string FullName);
@@ -11,6 +11,32 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
public async Task<List<Models.Region>> GetAllAsync() =>
await context.Regions.ToListAsync();
// Includes soft-deleted regions: the unique index on Code doesn't know about IsActive.
public async Task<List<string>> GetAllCodesAsync() =>
await context.Regions.Select(r => r.Code).ToListAsync();
public async Task<bool> CodeExistsAsync(string code, Guid? excludeId = null) =>
await context.Regions.AnyAsync(r => r.Code == code && (excludeId == null || r.Id != excludeId));
public async Task<Models.User?> GetUserWithRoleAsync(Guid userId) =>
await context.Users.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == userId);
public async Task<List<Models.UserRegionScope>> GetManagerScopesAsync(Guid regionId) =>
await context.UserRegionScopes
.Include(s => s.User).ThenInclude(u => u.Role)
.Where(s => s.FK_RegionId == regionId && s.User.IsActive && s.User.Role.Name == "RegionalManager")
.ToListAsync();
public async Task<Models.UserRegionScope?> GetRegionScopeAsync(Guid userId, Guid regionId) =>
await context.UserRegionScopes.FirstOrDefaultAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId);
public async Task AddRegionScopeAsync(Models.UserRegionScope scope) =>
await context.UserRegionScopes.AddAsync(scope);
// Scope rows are hard-deleted (composite key user+region), same as UserRepository.ReplaceRegionScopesAsync.
public void RemoveRegionScopes(IEnumerable<Models.UserRegionScope> scopes) =>
context.UserRegionScopes.RemoveRange(scopes);
public async Task AddAsync(Models.Region entity) =>
await context.Regions.AddAsync(entity);
+97 -5
View File
@@ -6,6 +6,7 @@ namespace Distribution.Features.Region;
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RegionalAgencyRoleLevel = 60;
private const string CodePrefix = "REG-";
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
@@ -14,19 +15,42 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
public async Task<List<RegionResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RegionResponse(r.Id, r.Name, r.IsActive, r.CreatedAt))
.Select(r => new RegionResponse(r.Id, r.Code, r.Name, r.IsActive, r.CreatedAt))
.ToList();
public async Task<RegionResponse?> GetByIdAsync(Guid id)
{
var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null;
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
// Next code = highest existing REG-#### number + 1 (soft-deleted regions included so codes are never reused).
public async Task<NextRegionCodeResponse> GetNextCodeAsync() => new(await GenerateCodeAsync());
private async Task<string> GenerateCodeAsync()
{
var max = (await repo.GetAllCodesAsync())
.Where(c => c.StartsWith(CodePrefix, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(c[CodePrefix.Length..], out _))
.Select(c => int.Parse(c[CodePrefix.Length..]))
.DefaultIfEmpty(0)
.Max();
return $"{CodePrefix}{max + 1:D4}";
}
public async Task<RegionResponse> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
{
var region = new Models.Region { Name = request.Name };
string code;
if (string.IsNullOrWhiteSpace(request.Code))
code = await GenerateCodeAsync();
else
{
code = request.Code.Trim();
if (await repo.CodeExistsAsync(code))
throw new InvalidOperationException($"Region code '{code}' is already in use.");
}
var region = new Models.Region { Name = request.Name, Code = code };
await uow.BeginAsync(ct);
try
{
@@ -38,7 +62,7 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
public async Task<RegionResponse?> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
@@ -46,6 +70,13 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
var region = await repo.GetByIdAsync(id);
if (region is null || !region.IsActive) return null;
region.Name = request.Name;
if (!string.IsNullOrWhiteSpace(request.Code))
{
var code = request.Code.Trim();
if (code != region.Code && await repo.CodeExistsAsync(code, id))
throw new InvalidOperationException($"Region code '{code}' is already in use.");
region.Code = code;
}
await uow.BeginAsync(ct);
try
{
@@ -57,7 +88,68 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUser
await uow.RollbackAsync(ct);
throw;
}
return new RegionResponse(region.Id, region.Name, region.IsActive, region.CreatedAt);
return new RegionResponse(region.Id, region.Code, region.Name, region.IsActive, region.CreatedAt);
}
private const string RegionalManagerRole = "RegionalManager";
public async Task<RegionManagerResponse?> GetManagerAsync(Guid regionId)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var scope = (await repo.GetManagerScopesAsync(regionId)).FirstOrDefault();
return scope is null ? null : new RegionManagerResponse(regionId, scope.FK_UserId, scope.User.Username, scope.User.FullName);
}
// A region has exactly one manager: assigning replaces any existing RegionalManager scoped to it.
// Returns null when the region does not exist; throws InvalidOperationException for an invalid user.
public async Task<RegionManagerResponse?> SetManagerAsync(Guid regionId, SetRegionManagerRequest request, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return null;
var user = await repo.GetUserWithRoleAsync(request.UserId);
if (user is null || !user.IsActive)
throw new InvalidOperationException("User not found.");
if (user.Role?.Name != RegionalManagerRole)
throw new InvalidOperationException($"User must have the {RegionalManagerRole} role to manage a region.");
await uow.BeginAsync(ct);
try
{
var existing = await repo.GetManagerScopesAsync(regionId);
repo.RemoveRegionScopes(existing.Where(s => s.FK_UserId != user.Id));
if (await repo.GetRegionScopeAsync(user.Id, regionId) is null)
await repo.AddRegionScopeAsync(new Models.UserRegionScope { FK_UserId = user.Id, FK_RegionId = regionId });
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return new RegionManagerResponse(regionId, user.Id, user.Username, user.FullName);
}
public async Task<bool> RemoveManagerAsync(Guid regionId, CancellationToken ct = default)
{
var region = await repo.GetByIdAsync(regionId);
if (region is null || !region.IsActive) return false;
var existing = await repo.GetManagerScopesAsync(regionId);
if (existing.Count == 0) return false;
await uow.BeginAsync(ct);
try
{
repo.RemoveRegionScopes(existing);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Returns;
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns")]
[Authorize("return.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/returns/{id:guid}")]
[Authorize("return.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
}
[HttpPatch("api/returns/{id:guid}/reasons")]
[Authorize("return.manage")]
public async Task<IActionResult> UpdateReasons(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct)
{
try
@@ -29,10 +33,12 @@ public class ReturnController(ReturnService service, ICurrentUserService current
}
[HttpGet("api/shop-pos/{id:guid}/returns")]
[Authorize("return.view")]
public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByShopPOIdAsync(id));
[HttpPost("api/shop-pos/{id:guid}/returns")]
[Authorize("return.manage")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.CreatedByUserId;
+2
View File
@@ -13,6 +13,8 @@ public record RideResponse(
int Meterend,
DateTime? StartedAt,
DateTime? ClosedAt,
CancelRequestType? CancelTypeCode,
string? CancelReason,
DateTime CreatedAt);
public record CreateRideRequest(
+3 -1
View File
@@ -109,6 +109,8 @@ public class RideService(
throw new InvalidOperationException($"Cannot cancel a ride in {ride.Status} status");
ride.Status = RideStatus.Cancelled;
ride.CancelTypeCode = request.TypeCode;
ride.CancelReason = request.Reason;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
@@ -149,5 +151,5 @@ public class RideService(
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
r.FK_DriverProfileId, r.DriverProfile?.User?.FullName ?? string.Empty,
r.Meterstart, r.Meterend,
r.StartedAt, r.ClosedAt, r.CreatedAt);
r.StartedAt, r.ClosedAt, r.CancelTypeCode, r.CancelReason, r.CreatedAt);
}
@@ -5,4 +5,8 @@ public interface IRoleRepository
Task<Models.Role?> GetByIdAsync(Guid id);
Task<List<Models.Role>> GetAllAsync();
Task<List<Models.RolePermission>> GetPermissionsAsync(Guid roleId);
Task AddAsync(Models.Role role);
void Update(Models.Role role);
void Remove(Models.Role role);
Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds);
}
@@ -23,4 +23,32 @@ public class RoleController(RoleService service) : ControllerBase
var result = await service.GetPermissionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateRoleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateRoleRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/permissions")]
public async Task<IActionResult> AssignPermissions(Guid id, AssignRolePermissionsRequest request, CancellationToken ct)
{
var result = await service.AssignPermissionsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var deactivated = await service.DeactivateAsync(id, ct);
return deactivated ? NoContent() : NotFound();
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
namespace Distribution.Features.Role;
public record RoleResponse(Guid Id, string Name, int Level, bool IsActive, DateTime CreatedAt);
public record RolePermissionResponse(Guid PermissionId, string Code, string Description);
public record CreateRoleRequest(string Name, int Level);
public record UpdateRoleRequest(string Name, int Level);
public record AssignRolePermissionsRequest(List<Guid> PermissionIds);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Role;
@@ -16,4 +17,29 @@ public class RoleRepository(AppDBContext context) : IRoleRepository
.Include(rp => rp.Permission)
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.ToListAsync();
public async Task AddAsync(Models.Role role) =>
await context.Roles.AddAsync(role);
public void Update(Models.Role role) =>
context.Roles.Update(role);
public void Remove(Models.Role role)
{
role.IsActive = false;
context.Roles.Update(role);
}
public async Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds)
{
var existing = await context.RolePermissions
.Where(rp => rp.FK_RoleId == roleId)
.ToListAsync();
context.RolePermissions.RemoveRange(existing);
context.RolePermissions.AddRange(permissionIds.Select(pid => new RolePermission
{
FK_RoleId = roleId,
FK_PermissionId = pid
}));
}
}
+81 -3
View File
@@ -1,18 +1,23 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Role;
public class RoleService(IRoleRepository repo)
public class RoleService(IRoleRepository repo, IUnitOfWork uow)
{
private static RoleResponse ToResponse(Models.Role r) =>
new(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt);
public async Task<List<RoleResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RoleResponse(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt))
.Select(ToResponse)
.ToList();
public async Task<RoleResponse?> GetByIdAsync(Guid id)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
return new RoleResponse(role.Id, role.Name, role.Level, role.IsActive, role.CreatedAt);
return ToResponse(role);
}
public async Task<List<RolePermissionResponse>?> GetPermissionsAsync(Guid roleId)
@@ -23,4 +28,77 @@ public class RoleService(IRoleRepository repo)
.Select(rp => new RolePermissionResponse(rp.FK_PermissionId, rp.Permission.Code, rp.Permission.Description))
.ToList();
}
public async Task<RoleResponse> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
{
var role = new Models.Role { Name = request.Name, Level = request.Level };
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(role);
}
public async Task<RoleResponse?> UpdateAsync(Guid id, UpdateRoleRequest request, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
role.Name = request.Name;
role.Level = request.Level;
await uow.BeginAsync(ct);
try
{
repo.Update(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(role);
}
public async Task<bool> DeactivateAsync(Guid id, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return false;
await uow.BeginAsync(ct);
try
{
repo.Remove(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<List<RolePermissionResponse>?> AssignPermissionsAsync(Guid id, AssignRolePermissionsRequest request, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
await uow.BeginAsync(ct);
try
{
await repo.ReplacePermissionsAsync(id, request.PermissionIds);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return await GetPermissionsAsync(id);
}
}
+21 -2
View File
@@ -1,23 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Route;
[ApiController]
[Route("api/routes")]
public class RouteController(RouteService service) : ControllerBase
public class RouteController(
RouteService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("route.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize("route.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("route.manage")]
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -25,6 +36,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("route.manage")]
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -32,6 +44,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}/region")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
@@ -39,6 +52,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("route.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -46,6 +60,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/stores")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
{
var result = await service.AssignStoresToRouteAsync(id, request, ct);
@@ -53,6 +68,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}/owner")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
{
var result = await service.AssignOwnerAsync(id, request, ct);
@@ -60,10 +76,12 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/optimize")]
[Authorize("route.manage")]
public IActionResult OptimizeSequence(Guid id) =>
StatusCode(StatusCodes.Status501NotImplemented);
[HttpPost("{id:guid}/visits/check-in")]
[Authorize("route.manage")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
try
@@ -75,6 +93,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/visits/check-out")]
[Authorize("route.manage")]
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
{
var result = await service.RecordCheckOutAsync(id, request, ct);
+3 -3
View File
@@ -1,10 +1,10 @@
namespace Distribution.Features.Route;
public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record CreateRouteRequest(Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length);
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record UpdateRouteRequest(Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length, bool? IsActive = null);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string? StartPoint, string? EndPoint, decimal? Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
public record AssignRouteToRegionRequest(Guid RegionId);
+9 -4
View File
@@ -10,8 +10,12 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
private async Task<bool> CanManageRouteAsync(Guid routeId)
{
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
// TEMPORARY: role-floor and route-scope checks bypassed so routes can be assigned
// while auth doesn't resolve the current user (testbug.md #28). Restore the original:
// if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
// return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
await Task.CompletedTask;
return true;
}
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
@@ -36,7 +40,6 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
public async Task<List<RouteResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
.ToList();
@@ -74,12 +77,14 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
// Inactive routes stay editable so they can be reactivated.
if (route is null) return null;
route.FK_RegionId = request.RegionId;
route.RouteName = request.RouteName;
route.StartPoint = request.StartPoint;
route.EndPoint = request.EndPoint;
route.Length = request.Length;
if (request.IsActive.HasValue) route.IsActive = request.IsActive.Value;
await uow.BeginAsync(ct);
try
{
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Scheme;
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
[Authorize("scheme.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPost]
[Authorize("scheme.manage")]
public async Task<IActionResult> Create(CreateSchemeRequest request, CancellationToken ct)
{
try
@@ -32,6 +36,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
{
try
@@ -46,6 +51,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -53,6 +59,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}/free-goods-rule")]
[Authorize("scheme.manage")]
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
{
try
@@ -67,6 +74,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}/free-goods-rule")]
[Authorize("scheme.manage")]
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
{
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
@@ -74,6 +82,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
{
try
@@ -88,6 +97,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
{
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
@@ -95,6 +105,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("eligible")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
{
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
@@ -102,6 +113,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("{id:guid}/spend")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetSpend(Guid id)
{
var result = await service.GetSpendAsync(id);
@@ -109,6 +121,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("{id:guid}/roi")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetROI(Guid id)
{
var result = await service.GetROIAsync(id);
@@ -121,6 +134,7 @@ public class SchemeController(SchemeService service) : ControllerBase
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
[Authorize("scheme.manage")]
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
{
try
@@ -5,11 +5,12 @@ namespace Distribution.Features.ShopPO;
public interface IShopPORepository
{
Task<Models.ShopPO?> GetByIdAsync(Guid id);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed);
Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status);
Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId);
Task<List<Models.ShopPOItem>> GetActiveItemsByShopPOIdAsync(Guid shopPOId);
Task<List<Models.ShopPO>> GetByStoreIdAsync(Guid storeId);
Task<Dictionary<Guid, (string Name, string Role)>> GetUserInfoAsync(IEnumerable<Guid> userIds);
Task AddAsync(Models.ShopPO entity);
void Update(Models.ShopPO entity);
void Remove(Models.ShopPO entity);
@@ -1,21 +1,25 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ShopPO;
[ApiController]
[Route("api/shop-pos")]
public class ShopPOController(ShopPOService service) : ControllerBase
public class ShopPOController(ShopPOService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? regionId,
[FromQuery] Guid? shopId,
[FromQuery] bool? confirmed,
[FromQuery] ShopPOStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
var result = await service.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
return Ok(result);
}
@@ -25,4 +29,79 @@ public class ShopPOController(ShopPOService service) : ControllerBase
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.RaisedByUserId;
if (userId is null) return BadRequest(new { error = "RaisedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> UpdateItems(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, ConfirmShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
try
{
var result = await service.ConfirmAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-and-create-invoice")]
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, ConfirmShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
try
{
var result = await service.ConfirmAndCreateInvoiceAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelShopPORequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CancelledByUserId;
if (userId is null) return BadRequest(new { error = "CancelledByUserId is required while auth is unavailable" });
try
{
var result = await service.CancelAsync(id, userId.Value, request.Reason, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
+15 -2
View File
@@ -2,9 +2,11 @@
namespace Distribution.Features.ShopPO;
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items);
public record CreateShopPORequest(Guid StoreId, List<ShopPOItemRequest> Items, Guid? RaisedByUserId = null);
public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record UpdateShopPOItemsRequest(List<ShopPOItemRequest> Items);
public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null);
public record CancelShopPORequest(string Reason, Guid? CancelledByUserId = null);
public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive);
@@ -12,6 +14,8 @@ public record ShopPOResponse(
Guid Id,
Guid StoreId,
string StoreName,
string? StoreAddress,
string? StoreContactNumber,
Guid RaisedById,
Guid? InvoiceId,
decimal TotalAmount,
@@ -21,4 +25,13 @@ public record ShopPOResponse(
DateTime? FulfilledAt,
List<ShopPOItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
DateTime CreatedAt,
string? RaisedByName = null,
string? RaisedByRole = null,
string? ConfirmedByName = null,
string? ConfirmedByRole = null,
Guid? CancelledBy = null,
DateTime? CancelledAt = null,
string? CancelReason = null,
string? CancelledByName = null,
string? CancelledByRole = null);
@@ -13,27 +13,32 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize) =>
public async Task<List<Models.ShopPO>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Store).ThenInclude(s => s.Route)
.Include(p => p.Items.Where(i => i.IsActive)).ThenInclude(i => i.Item)
.Where(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (regionId == null || p.Store.Route.FK_RegionId == regionId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)))
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))
&& (status == null || p.Status == status))
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed) =>
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status) =>
await context.ShopPOs
.Include(p => p.Store)
.Include(p => p.Store).ThenInclude(s => s.Route)
.CountAsync(p => p.IsActive
&& (agentId == null || p.RaisedById == agentId)
&& (routeId == null || p.Store.FK_RouteId == routeId)
&& (regionId == null || p.Store.Route.FK_RegionId == regionId)
&& (shopId == null || p.FK_StoreId == shopId)
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null)));
&& (confirmed == null || (confirmed == true ? p.ConfirmedAt != null : p.ConfirmedAt == null))
&& (status == null || p.Status == status));
public async Task<List<Models.ShopPO>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId) =>
await context.ShopPOs
@@ -59,6 +64,16 @@ public class ShopPORepository(AppDBContext context) : IShopPORepository
.Where(p => p.FK_StoreId == storeId && p.IsActive)
.ToListAsync();
public async Task<Dictionary<Guid, (string Name, string Role)>> GetUserInfoAsync(IEnumerable<Guid> userIds)
{
var ids = userIds.Distinct().ToList();
var users = await context.Users
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.FullName, RoleName = u.Role.Name })
.ToListAsync();
return users.ToDictionary(u => u.Id, u => (u.FullName, u.RoleName));
}
public async Task AddAsync(Models.ShopPO entity) =>
await context.ShopPOs.AddAsync(entity);
+212 -8
View File
@@ -1,4 +1,6 @@
using Distribution.Features.Stock;
using Distribution.Features.Invoice;
using Distribution.Features.PriceList;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -6,27 +8,229 @@ namespace Distribution.Features.ShopPO;
public class ShopPOService(
IShopPORepository repo,
IInvoiceRepository invoiceRepo,
PriceListService priceListService,
IUnitOfWork uow)
{
private static ShopPOResponse ToResponse(Models.ShopPO p) =>
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i =>
new ShopPOItemResponse(i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.Quantity, i.QuantityFulfilled, i.UnitPrice, i.IsActive)).ToList(),
p.IsActive, p.CreatedAt);
p.IsActive, p.CreatedAt,
users != null && users.TryGetValue(p.RaisedById, out var u) ? u.Name : null,
users != null && users.TryGetValue(p.RaisedById, out var r) ? r.Role : null,
users != null && p.ConfirmedBy is Guid cb && users.TryGetValue(cb, out var c) ? c.Name : null,
users != null && p.ConfirmedBy is Guid cb2 && users.TryGetValue(cb2, out var c2) ? c2.Role : null,
p.CancelledBy, p.CancelledAt, p.CancelReason,
users != null && p.CancelledBy is Guid xb && users.TryGetValue(xb, out var x) ? x.Name : null,
users != null && p.CancelledBy is Guid xb2 && users.TryGetValue(xb2, out var x2) ? x2.Role : null);
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, bool? confirmed, int page, int pageSize)
private static IEnumerable<Guid> UserIds(Models.ShopPO p)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, confirmed);
var items = await repo.GetAllAsync(agentId, routeId, shopId, confirmed, page, pageSize);
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
yield return p.RaisedById;
if (p.ConfirmedBy is Guid cb) yield return cb;
if (p.CancelledBy is Guid xb) yield return xb;
}
private async Task<ShopPOResponse> ToResponseWithUserAsync(Models.ShopPO p) =>
ToResponse(p, await repo.GetUserInfoAsync(UserIds(p)));
public async Task<PagedResponse<ShopPOResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? regionId, Guid? shopId, bool? confirmed, ShopPOStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, regionId, shopId, confirmed, status);
var items = await repo.GetAllAsync(agentId, routeId, regionId, shopId, confirmed, status, page, pageSize);
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
return new PagedResponse<ShopPOResponse>(page, pageSize, total, items.Select(p => ToResponse(p, users)).ToList());
}
public async Task<ShopPOResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
return po is null ? null : ToResponse(po);
if (po is null) return null;
var users = await repo.GetUserInfoAsync(UserIds(po));
return ToResponse(po, users);
}
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
{
if (request.Items.Count == 0)
throw new InvalidOperationException("At least one item is required");
var items = new List<ShopPOItem>();
decimal total = 0;
foreach (var line in request.Items)
{
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, request.StoreId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
items.Add(new ShopPOItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
var po = new Models.ShopPO
{
FK_StoreId = request.StoreId,
RaisedById = raisedById,
TotalAmount = total,
Status = ShopPOStatus.Pending
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(po);
foreach (var item in items) item.FK_ShopPOId = po.Id;
await repo.AddItemsAsync(items);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(po.Id);
return await ToResponseWithUserAsync(saved!);
}
public async Task<ShopPOResponse> UpdateItemsAsync(Guid id, UpdateShopPOItemsRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot edit a cancelled Shop PO");
if (po.ConfirmedAt is not null)
throw new InvalidOperationException("Items can only be changed before the Shop PO is confirmed");
if (request.Items.Count == 0)
throw new InvalidOperationException("At least one item is required");
var newItems = new List<ShopPOItem>();
decimal total = 0;
foreach (var line in request.Items)
{
var unitPrice = line.UnitPrice
?? (await priceListService.ResolveByStoreAsync(line.ItemId, po.FK_StoreId, DateTime.Now))?.Price
?? throw new InvalidOperationException($"No resolvable price for item {line.ItemId}");
newItems.Add(new ShopPOItem
{
FK_ShopPOId = id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
QuantityFulfilled = 0,
UnitPrice = unitPrice
});
total += line.Quantity * unitPrice;
}
po.TotalAmount = total;
await uow.BeginAsync(ct);
try
{
await repo.ReplaceItemsAsync(id, newItems);
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!);
}
public async Task<ShopPOResponse> ConfirmAsync(Guid id, Guid confirmedBy, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is not null)
throw new InvalidOperationException("Shop PO is already confirmed");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
po.ConfirmedAt = DateTime.Now;
po.ConfirmedBy = confirmedBy;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return await ToResponseWithUserAsync(po);
}
public async Task<ShopPOResponse> ConfirmAndCreateInvoiceAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is not null)
throw new InvalidOperationException("Shop PO is already confirmed");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot confirm a cancelled Shop PO");
po.ConfirmedAt = DateTime.Now;
po.ConfirmedBy = userId;
var invoice = new Models.Invoice
{
FK_ShopPOId = po.Id,
Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending,
CreatedBy = userId,
TotalAmount = po.TotalAmount
};
await uow.BeginAsync(ct);
try
{
await invoiceRepo.AddAsync(invoice);
po.InvoiceId = invoice.Id;
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return await ToResponseWithUserAsync(saved!);
}
public async Task<ShopPOResponse> CancelAsync(Guid id, Guid cancelledBy, string reason, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(reason))
throw new InvalidOperationException("A cancellation reason is required");
var po = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Shop PO is already cancelled");
if (po.Status is ShopPOStatus.PartiallyFulfilled or ShopPOStatus.Fulfilled)
throw new InvalidOperationException($"Cannot cancel a Shop PO in {po.Status} status");
po.Status = ShopPOStatus.Cancelled;
po.CancelledAt = DateTime.Now;
po.CancelledBy = cancelledBy;
po.CancelReason = reason.Trim();
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return await ToResponseWithUserAsync(po);
}
}
@@ -6,10 +6,12 @@ public interface IStockRepository
{
// Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId);
Task<List<StockBalance>> GetLowStockAsync(Guid? locationId);
Task AddBalanceAsync(StockBalance balance);
void UpdateBalance(StockBalance balance);
@@ -19,4 +21,14 @@ public interface IStockRepository
void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
Task AddItemBatchAsync(ItemBatch batch);
// Serial numbers
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
Task AddItemSerialNumberAsync(ItemSerialNumber serial);
}
+29 -3
View File
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Stock;
@@ -8,9 +9,10 @@ namespace Distribution.Features.Stock;
[Route("api/stock")]
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
{
// ── Query ──────────────────────────────────────────────────────────────
// ── Query ──────────────────────────────────────────────────────────────────
[HttpGet("balances")]
[Authorize("stock.view")]
public async Task<IActionResult> GetBalances(
[FromQuery] Guid? locationId,
[FromQuery] LocationType? locationType,
@@ -22,7 +24,16 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
[HttpGet("low-stock")]
[Authorize("stock.view")]
public async Task<IActionResult> GetLowStock([FromQuery] Guid? locationId)
{
var result = await service.GetLowStockAsync(locationId);
return Ok(result);
}
[HttpGet("balances/{itemId}/total")]
[Authorize("stock.view")]
public async Task<IActionResult> GetItemTotal(Guid itemId)
{
var result = await service.GetItemTotalAsync(itemId);
@@ -30,7 +41,17 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
/// <summary>Next free number for auto-generated serials (e.g. after ...-0005 the answer is 6).</summary>
[HttpGet("serials/next-sequence")]
[Authorize("stock.view")]
public async Task<IActionResult> GetNextSerialSequence([FromQuery] Guid itemId, [FromQuery] string prefix = "")
{
var next = await service.GetNextSerialSequenceAsync(itemId, prefix);
return Ok(new { next });
}
[HttpGet("movements")]
//[Authorize("stock.view")]
public async Task<IActionResult> GetMovements(
[FromQuery] Guid? locationId,
[FromQuery] Guid? itemId,
@@ -44,9 +65,10 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────
// ── Write ──────────────────────────────────────────────────────────────────
[HttpPost("transfer")]
[Authorize("stock.manage")]
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -59,6 +81,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("damage")]
[Authorize("stock.manage")]
public async Task<IActionResult> RecordDamage(DamageRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -71,6 +94,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("customer-return")]
[Authorize("stock.manage")]
public async Task<IActionResult> RecordCustomerReturn(CustomerReturnRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -83,6 +107,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("adjustment")]
[Authorize("stock.manage")]
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -98,6 +123,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPatch("movements/{id}/reason")]
[Authorize("stock.manage")]
public async Task<IActionResult> UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct)
{
try
+8
View File
@@ -21,6 +21,14 @@ public record StockTotalResponse(
decimal TotalQuantity,
List<StockBalanceResponse> Breakdown);
public record LowStockItemResponse(
Guid ItemId,
string ItemName,
Guid LocationId,
string LocationName,
decimal Quantity,
decimal ReorderLevel);
// Movement
public record StockMovementResponse(
Guid Id,
+51 -1
View File
@@ -9,7 +9,14 @@ public class StockRepository(AppDBContext db) : IStockRepository
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId);
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) =>
db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
db.StockBalances
@@ -41,6 +48,17 @@ public class StockRepository(AppDBContext db) : IStockRepository
.Where(b => b.FK_ItemId == itemId)
.ToListAsync();
public Task<List<StockBalance>> GetLowStockAsync(Guid? locationId)
{
var query = db.StockBalances
.Include(b => b.Item)
.Where(b => b.LocationType == LocationType.Warehouse
&& b.Item.ReorderLevel != null
&& b.Quantity < b.Item.ReorderLevel);
if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value);
return query.OrderBy(b => b.Quantity).ToListAsync();
}
public async Task AddBalanceAsync(StockBalance balance) =>
await db.StockBalances.AddAsync(balance);
@@ -79,4 +97,36 @@ public class StockRepository(AppDBContext db) : IStockRepository
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.CountAsync();
}
public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
public async Task AddItemBatchAsync(ItemBatch batch) =>
await db.ItemBatches.AddAsync(batch);
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
public async Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix)
{
// Only serials of the form <prefix><digits> count; anything else (a supplier's own serial that
// happens to start with the prefix) is ignored for numbering.
var serials = await db.ItemSerialNumbers
.AsNoTracking()
.Where(s => s.FK_ItemId == itemId && s.SerialNumber.StartsWith(prefix))
.Select(s => s.SerialNumber)
.ToListAsync();
var max = 0L;
foreach (var serial in serials)
{
var suffix = serial[prefix.Length..];
if (suffix.Length is > 0 and <= 9 && suffix.All(char.IsDigit) && long.TryParse(suffix, out var n))
max = Math.Max(max, n);
}
return (int)max + 1;
}
public async Task AddItemSerialNumberAsync(ItemSerialNumber serial) =>
await db.ItemSerialNumbers.AddAsync(serial);
}
@@ -87,6 +87,19 @@ public class StockService(
items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
}
public async Task<List<LowStockItemResponse>> GetLowStockAsync(Guid? locationId)
{
var balances = await repo.GetLowStockAsync(locationId);
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
return balances.Select(b => new LowStockItemResponse(
b.FK_ItemId, b.Item.Name, b.LocationId, names.GetValueOrDefault(b.LocationId, "—"),
b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
}
/// <summary>The next unused sequence number for generating serials like "&lt;prefix&gt;0001" for an item.</summary>
public Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix) =>
repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty);
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
{
var item = await itemRepo.GetByIdAsync(itemId);
@@ -7,8 +7,8 @@ public interface IStoreRepository
Task<Models.Store?> GetByIdAsync(Guid id);
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
Task<List<Models.Store>> GetAllAsync();
Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize);
Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search);
Task<List<Models.Store>> GetAllAsync(Guid? routeId, string? search, int page, int pageSize);
Task<int> GetCountAsync(Guid? routeId, string? search);
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
@@ -1,7 +0,0 @@
namespace Distribution.Features.Store.Kyc;
public interface IStoreKycDocumentRepository
{
Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.StoreKycDocument entity);
}
@@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store.Kyc;
[ApiController]
[Route("api/stores/{storeId:guid}/kyc-documents")]
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetByStoreId(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
{
var result = await service.UploadAsync(storeId, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,5 +0,0 @@
namespace Distribution.Features.Store.Kyc;
public record UploadKycDocumentRequest(string DocumentType, IFormFile File);
public record StoreKycDocumentResponse(Guid Id, Guid StoreId, string DocumentType, string FileReference, DateTime UploadedAt);
@@ -1,13 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Store.Kyc;
public class StoreKycDocumentRepository(AppDBContext context) : IStoreKycDocumentRepository
{
public async Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId) =>
await context.StoreKycDocuments.Where(d => d.FK_StoreId == storeId && d.IsActive).ToListAsync();
public async Task AddAsync(Models.StoreKycDocument entity) =>
await context.StoreKycDocuments.AddAsync(entity);
}
@@ -1,71 +0,0 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Store.Kyc;
public class StoreKycDocumentService(
IStoreKycDocumentRepository repo,
IStoreRepository storeRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IWebHostEnvironment env)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private static StoreKycDocumentResponse ToResponse(Models.StoreKycDocument d) =>
new(d.Id, d.FK_StoreId, d.DocumentType, d.FileReference, d.UploadedAt);
public async Task<List<StoreKycDocumentResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var docs = await repo.GetByStoreIdAsync(storeId);
return docs.Select(ToResponse).ToList();
}
public async Task<StoreKycDocumentResponse?> UploadAsync(Guid storeId, UploadKycDocumentRequest request, CancellationToken ct = default)
{
var store = await storeRepo.GetByIdAsync(storeId);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(storeId)) return null;
var uploadsRoot = Path.Combine(env.ContentRootPath, "wwwroot", "uploads", "kyc", storeId.ToString());
Directory.CreateDirectory(uploadsRoot);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(request.File.FileName)}";
var filePath = Path.Combine(uploadsRoot, fileName);
await using (var stream = new FileStream(filePath, FileMode.Create))
{
await request.File.CopyToAsync(stream, ct);
}
var fileReference = $"uploads/kyc/{storeId}/{fileName}";
var doc = new Models.StoreKycDocument
{
FK_StoreId = storeId,
DocumentType = request.DocumentType,
FileReference = fileReference,
UploadedAt = DateTime.Now
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(doc);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(doc);
}
}
+24 -18
View File
@@ -1,29 +1,39 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store;
[ApiController]
[Route("api/stores")]
public class StoreController(StoreService service) : ControllerBase
public class StoreController(
StoreService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("store.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? routeId,
[FromQuery] KycStatus? kycStatus,
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(routeId, kycStatus, search, page, pageSize));
Ok(await service.GetAllAsync(routeId, search, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize("store.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("store.manage")]
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -31,6 +41,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("store.manage")]
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,6 +49,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("store.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -45,6 +57,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize("store.manage")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
@@ -52,6 +65,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize("store.manage")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
@@ -59,20 +73,15 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize("store.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/kyc/verify")]
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/segment")]
[Authorize("store.manage")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
@@ -80,6 +89,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/visit-history")]
[Authorize("store.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
@@ -87,6 +97,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize("store.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
@@ -94,20 +105,15 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize("store.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/kyc-status")]
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/status")]
[Authorize("store.manage")]
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
{
var result = await service.SetActiveStatusAsync(id, request.IsActive, ct);
+18 -18
View File
@@ -4,29 +4,34 @@ namespace Distribution.Features.Store;
public record CreateStoreRequest(
Guid RouteId,
string OwnerName,
string StoreName,
string StoreAddress,
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount);
public record UpdateStoreRequest(
Guid RouteId,
string OwnerName,
string? OwnerName,
string StoreName,
string StoreAddress,
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
bool IsActive = true);
decimal CreditLimit = 0,
string? ContactNumber = null);
public record UpdateStoreRequest(
Guid RouteId,
string? OwnerName,
string StoreName,
string StoreAddress,
decimal Latitude,
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
bool IsActive = true,
decimal? CreditLimit = null,
string? ContactNumber = null);
public record StoreResponse(
Guid Id,
Guid RouteId,
string OwnerName,
string? OwnerName,
string? ContactNumber,
string StoreName,
string StoreAddress,
decimal Latitude,
@@ -36,7 +41,6 @@ public record StoreResponse(
decimal CreditLimit,
decimal OutstandingBalance,
string? Segment,
KycStatus KycStatus,
bool IsActive,
DateTime CreatedAt);
@@ -44,8 +48,6 @@ public record UpdateCreditLimitRequest(decimal NewLimit, Guid ApproverId);
public record UpdateOutstandingBalanceRequest(decimal NewBalance);
public record VerifyKycRequest(KycStatus Status, string? Notes);
public record ClassifySegmentRequest(string Segment);
public record OutstandingBalanceResponse(Guid StoreId, decimal CreditLimit, decimal OutstandingBalance, decimal AvailableCredit);
@@ -54,8 +56,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchKycStatusRequest(KycStatus KycStatus);
public record PatchStoreStatusRequest(bool IsActive);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
@@ -18,23 +18,22 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
await context.Stores.ToListAsync();
private static IQueryable<Models.Store> ApplyFilters(
IQueryable<Models.Store> query, Guid? routeId, KycStatus? kycStatus, string? search) =>
IQueryable<Models.Store> query, Guid? routeId, string? search) =>
query
.Where(s => routeId == null || s.FK_RouteId == routeId)
.Where(s => kycStatus == null || s.KycStatus == kycStatus)
.Where(s => string.IsNullOrWhiteSpace(search)
|| s.StoreName.Contains(search)
|| s.OwnerName.Contains(search));
|| (s.OwnerName != null && s.OwnerName.Contains(search)));
public async Task<List<Models.Store>> GetAllAsync(Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize) =>
await ApplyFilters(context.Stores, routeId, kycStatus, search)
public async Task<List<Models.Store>> GetAllAsync(Guid? routeId, string? search, int page, int pageSize) =>
await ApplyFilters(context.Stores, routeId, search)
.OrderBy(s => s.StoreName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? routeId, KycStatus? kycStatus, string? search) =>
await ApplyFilters(context.Stores, routeId, kycStatus, search).CountAsync();
public async Task<int> GetCountAsync(Guid? routeId, string? search) =>
await ApplyFilters(context.Stores, routeId, search).CountAsync();
public async Task AddAsync(Models.Store entity) =>
await context.Stores.AddAsync(entity);
+13 -30
View File
@@ -11,7 +11,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasGlobalScopeAsync() => await currentUser.GetRoleLevelAsync() >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
@@ -21,16 +21,16 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
}
private static StoreResponse ToResponse(Models.Store s) =>
new(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.KycStatus, s.IsActive, s.CreatedAt);
new(s.Id, s.FK_RouteId, s.OwnerName, s.ContactNumber, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.IsActive, s.CreatedAt);
public async Task<PagedResponse<StoreResponse>> GetAllAsync(
Guid? routeId, KycStatus? kycStatus, string? search, int page, int pageSize)
Guid? routeId, string? search, int page, int pageSize)
{
// Deactivated stores stay visible in the list showing Inactive — deactivate never
// removes the row, only marks it (same rule as Orders, see testbug.md #42).
var total = await repo.GetCountAsync(routeId, kycStatus, search);
var stores = await repo.GetAllAsync(routeId, kycStatus, search, page, pageSize);
var total = await repo.GetCountAsync(routeId, search);
var stores = await repo.GetAllAsync(routeId, search, page, pageSize);
return new PagedResponse<StoreResponse>(page, pageSize, total, stores.Select(ToResponse).ToList());
}
@@ -48,12 +48,14 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
{
FK_RouteId = request.RouteId,
OwnerName = request.OwnerName,
ContactNumber = request.ContactNumber,
StoreName = request.StoreName,
StoreAddress = request.StoreAddress,
Latitude = request.Latitude,
Longitude = request.Longitude,
OneTimeAmount = request.OneTimeAmount,
CreditAmount = request.CreditAmount
CreditAmount = request.CreditAmount,
CreditLimit = request.CreditLimit
};
await uow.BeginAsync(ct);
try
@@ -76,12 +78,14 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
if (!await HasStoreScopeAsync(id)) return null;
store.FK_RouteId = request.RouteId;
store.OwnerName = request.OwnerName;
store.ContactNumber = request.ContactNumber;
store.StoreName = request.StoreName;
store.StoreAddress = request.StoreAddress;
store.Latitude = request.Latitude;
store.Longitude = request.Longitude;
store.OneTimeAmount = request.OneTimeAmount;
store.CreditAmount = request.CreditAmount;
if (request.CreditLimit is not null) store.CreditLimit = request.CreditLimit.Value;
store.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try
@@ -118,7 +122,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
@@ -139,7 +143,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
@@ -165,27 +169,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
if (!await HasStoreScopeAsync(id)) return null;
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
}
public async Task<StoreResponse?> VerifyKycAsync(Guid id, VerifyKycRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.KycStatus = request.Status;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
@@ -7,10 +8,12 @@ namespace Distribution.Features.SupplierClaims;
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/supplier-claims/{id:guid}")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,10 +21,12 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpGet("api/purchase-orders/{id:guid}/claims")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/claims")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -37,6 +42,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPut("api/supplier-claims/{id:guid}")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateSupplierClaimRequest request, CancellationToken ct)
{
try
@@ -48,6 +54,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ApprovedByUserId;
@@ -62,6 +69,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.RejectedByUserId;
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SysConfig;
@@ -9,11 +10,12 @@ namespace Distribution.Features.SysConfig;
public class SysConfigController(SysConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Typed shortcut — must be declared before {key} to take precedence
[HttpGet("invoice-creation-mode")]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetInvoiceCreationMode()
{
var mode = await service.GetInvoiceCreationModeAsync();
@@ -21,6 +23,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpPut("invoice-creation-mode")]
[Authorize("sys-config.manage")]
public async Task<IActionResult> SetInvoiceCreationMode(SetInvoiceCreationModeRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -33,6 +36,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpGet("{key}")]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetByKey(string key)
{
var result = await service.GetByKeyAsync(key);
@@ -40,12 +44,14 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpPut("{key}")]
[Authorize("sys-config.manage")]
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var userId = currentUser.UserId ?? request.UserId;
if (userId is null) return BadRequest(new { error = "UserId is required while auth is unavailable" });
try
{
var result = await service.SetAsync(key, request.Value, userId, ct);
var result = await service.SetAsync(key, request.Value, userId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
@@ -4,6 +4,9 @@ namespace Distribution.Features.SysConfig;
public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy);
public record SetSysConfigRequest(string Value);
public record SetSysConfigRequest(string Value, Guid? UserId);
public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode);
/// <summary>Which taxes apply to a GRN, whether each must be filled in, and the rate new GRNs start with.</summary>
public record GrnTaxSettings(TaxMode VatMode, decimal VatDefaultRate, TaxMode NbtMode, decimal NbtDefaultRate);
@@ -7,6 +7,30 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
{
public const string KeyInvoiceCreationMode = "InvoiceCreationMode";
public const string KeySerialNumberTrackingEnabled = "Feature.SerialNumberTracking";
public const string KeyBatchTrackingEnabled = "Feature.BatchTracking";
public const string KeyExpiryDateTrackingEnabled = "Feature.ExpiryDateTracking";
public const string KeyWarrantyTrackingEnabled = "Feature.WarrantyTracking";
public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride";
public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays";
public const string KeyGrnVatMode = "Grn.Tax.Vat.Mode";
public const string KeyGrnVatRate = "Grn.Tax.Vat.DefaultRate";
public const string KeyGrnNbtMode = "Grn.Tax.Nbt.Mode";
public const string KeyGrnNbtRate = "Grn.Tax.Nbt.DefaultRate";
private static readonly string[] TaxModeKeys = [KeyGrnVatMode, KeyGrnNbtMode];
private static readonly string[] TaxRateKeys = [KeyGrnVatRate, KeyGrnNbtRate];
private static readonly string[] BooleanFeatureKeys =
[
KeySerialNumberTrackingEnabled,
KeyBatchTrackingEnabled,
KeyExpiryDateTrackingEnabled,
KeyWarrantyTrackingEnabled,
KeyCustomWarrantyOverrideEnabled,
];
public async Task<List<SysConfigResponse>> GetAllAsync()
{
var configs = await repo.GetAllAsync();
@@ -55,11 +79,71 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow)
return Enum.TryParse<InvoiceCreationMode>(config.Value, out var mode) ? mode : InvoiceCreationMode.ModeB;
}
public async Task<bool> GetSerialNumberTrackingEnabledAsync() => await GetBoolAsync(KeySerialNumberTrackingEnabled);
public async Task<bool> GetBatchTrackingEnabledAsync() => await GetBoolAsync(KeyBatchTrackingEnabled);
public async Task<bool> GetExpiryDateTrackingEnabledAsync() => await GetBoolAsync(KeyExpiryDateTrackingEnabled);
public async Task<bool> GetWarrantyTrackingEnabledAsync() => await GetBoolAsync(KeyWarrantyTrackingEnabled);
public async Task<bool> GetCustomWarrantyOverrideEnabledAsync() => await GetBoolAsync(KeyCustomWarrantyOverrideEnabled);
public async Task<int> GetDefaultWarrantyPeriodDaysAsync()
{
var config = await repo.GetByKeyAsync(KeyDefaultWarrantyPeriodDays);
return config is not null && int.TryParse(config.Value, out var days) ? days : 0;
}
/// <summary>
/// GRN tax settings. Unconfigured, VAT is Optional (how GRNs behaved before this setting existed)
/// and NBT is Disabled.
/// </summary>
public async Task<GrnTaxSettings> GetGrnTaxSettingsAsync() => new(
await GetTaxModeAsync(KeyGrnVatMode, TaxMode.Optional),
await GetRateAsync(KeyGrnVatRate),
await GetTaxModeAsync(KeyGrnNbtMode, TaxMode.Disabled),
await GetRateAsync(KeyGrnNbtRate));
private async Task<TaxMode> GetTaxModeAsync(string key, TaxMode fallback)
{
var config = await repo.GetByKeyAsync(key);
return config is not null && Enum.TryParse<TaxMode>(config.Value, out var mode) && Enum.IsDefined(mode)
? mode
: fallback;
}
private async Task<decimal> GetRateAsync(string key)
{
var config = await repo.GetByKeyAsync(key);
return config is not null
&& decimal.TryParse(config.Value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var rate)
? rate
: 0m;
}
private async Task<bool> GetBoolAsync(string key)
{
var config = await repo.GetByKeyAsync(key);
return config is not null && bool.TryParse(config.Value, out var enabled) && enabled;
}
private static void ValidateValue(string key, string value)
{
if (key == KeyInvoiceCreationMode && !Enum.TryParse<InvoiceCreationMode>(value, out _))
throw new InvalidOperationException(
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<InvoiceCreationMode>())}.");
if (BooleanFeatureKeys.Contains(key) && !bool.TryParse(value, out _))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Allowed values: true, false.");
if (TaxModeKeys.Contains(key) && (!Enum.TryParse<TaxMode>(value, out var taxMode) || !Enum.IsDefined(taxMode)))
throw new InvalidOperationException(
$"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames<TaxMode>())}.");
if (TaxRateKeys.Contains(key)
&& (!decimal.TryParse(value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var rate)
|| rate < 0 || rate > 100))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a percentage from 0 to 100.");
if (key == KeyDefaultWarrantyPeriodDays && (!int.TryParse(value, out var days) || days < 0))
throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a non-negative integer.");
}
private static SysConfigResponse ToResponse(Models.SysConfig c) =>
+3 -1
View File
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Distribution.Features.User;
public record CreateUserRequest(
string Username,
[EmailAddress] string Username,
string Password,
string FullName,
Guid RoleId,
+21 -5
View File
@@ -1,12 +1,10 @@
using Distribution.Features.Auth;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.Identity;
namespace Distribution.Features.User;
public class UserService(IUserRepository repo, IUnitOfWork uow)
public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient authHex)
{
private readonly PasswordHasher<Models.User> _hasher = new();
private static UserResponse ToResponse(Models.User u) =>
new(u.Id, u.Username, u.FullName, u.FK_RoleId, u.Role?.Name ?? string.Empty,
u.FK_ReportsToUserId, u.IsActive, u.CreatedAt, u.UpdatedAt);
@@ -33,12 +31,30 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
FK_RoleId = request.RoleId,
FK_ReportsToUserId = request.ReportsToUserId
};
user.PasswordHash = _hasher.HashPassword(user, request.Password);
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(user);
var authResult = await authHex.CallAsync("registerUser", new
{
userId = user.Id,
userName = request.Username,
email = request.Username,
fullname = request.FullName,
password = request.Password,
roleId = request.RoleId,
userTypeId = "00000001-0000-0000-0000-200000000000",
sendCredentialsEmail = false
}, reference: user.Id.ToString(), ct: ct);
if (!authResult.Success)
{
await uow.RollbackAsync(ct);
throw new InvalidOperationException($"AuthHex registration failed: {authResult.Message}");
}
await uow.CommitAsync(ct);
}
catch
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Distribution.Infra.Authorization;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticatedAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
context.Result = new UnauthorizedResult();
}
}
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Infra.Authorization;
@@ -17,6 +17,14 @@ public class AuthorizationController(
return Ok(new PermissionCheckResponse(result));
}
[HttpGet("my-permissions")]
public async Task<IActionResult> GetMyPermissions()
{
if (currentUser.RoleId is not { } roleId) return Unauthorized();
var permissions = await permissionService.GetRolePermissionsAsync(roleId);
return Ok(new RolePermissionsResponse(permissions));
}
[HttpGet("scopes/stores/{storeId:guid}")]
public async Task<IActionResult> CheckStoreScope(Guid storeId)
{
@@ -49,3 +57,15 @@ public class AuthorizationController(
return Ok(new AccessibleStoresResponse(result));
}
}
[ApiController]
[Route("api/permissions")]
public class PermissionController(IPermissionService permissionService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var permissions = await permissionService.GetAllAsync();
return Ok(permissions.Select(p => new { permissionId = p.Id, p.Code, p.Description }));
}
}
@@ -2,6 +2,8 @@ namespace Distribution.Infra.Authorization;
public record PermissionCheckResponse(bool HasPermission);
public record RolePermissionsResponse(List<string> Permissions);
public record StoreScopeCheckResponse(bool HasScope);
public record RouteScopeCheckResponse(bool HasScope);
@@ -1,46 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace Distribution.Infra.Authorization;
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
private System.Security.Claims.ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public Guid? UserId
{
get
{
var value = User?.FindFirst("sub")?.Value ?? User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public Guid? RoleId
{
get
{
var value = User?.FindFirst("role_id")?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public string? Username => User?.FindFirst("username")?.Value;
public int RoleLevel
{
get
{
var value = User?.FindFirst("role_level")?.Value;
return int.TryParse(value, out var level) ? level : 0;
}
}
}
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Distribution.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Distribution.Infra.Authorization;
public class CurrentUserService(IHttpContextAccessor http, AppDBContext db, IMemoryCache cache) : ICurrentUserService
{
private ClaimsPrincipal? User => http.HttpContext?.User;
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public Guid? UserId => Guid.TryParse(User?.FindFirst("UserId")?.Value, out var id) ? id : null;
public Guid? RoleId => Guid.TryParse(User?.FindFirst("RoleId")?.Value, out var id) ? id : null;
public async Task<string?> GetUsernameAsync()
{
if (UserId is not { } id) return null;
var key = $"user_rolename_{id}";
if (cache.TryGetValue(key, out string? cached)) return cached;
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
cache.Set(key, user?.Username, TimeSpan.FromMinutes(10));
return user?.Username;
}
public async Task<string?> GetRoleCodeAsync()
{
if (RoleId is not { } id) return null;
var key = $"role_name_{id}";
if (cache.TryGetValue(key, out string? cached)) return cached;
var role = await db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id);
cache.Set(key, role?.Name, TimeSpan.FromMinutes(10));
return role?.Name;
}
public async Task<int> GetRoleLevelAsync()
{
if (RoleId is not { } id) return 0;
var key = $"role_level_{id}";
if (cache.TryGetValue(key, out int cached)) return cached;
var role = await db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id);
var level = role?.Level ?? 0;
cache.Set(key, level, TimeSpan.FromMinutes(10));
return level;
}
}
@@ -4,7 +4,8 @@ public interface ICurrentUserService
{
Guid? UserId { get; }
Guid? RoleId { get; }
string? Username { get; }
int RoleLevel { get; }
bool IsAuthenticated { get; }
Task<string?> GetUsernameAsync();
Task<string?> GetRoleCodeAsync();
Task<int> GetRoleLevelAsync();
}
@@ -1,32 +0,0 @@
using System.Security.Cryptography;
using System.Xml.Linq;
namespace Distribution.Infra.Authorization;
/// <summary>
/// Parses an XML-format RSA public key (`&lt;RSAKeyValue&gt;...&lt;/RSAKeyValue&gt;`, as issued by
/// AuthHex) into an <see cref="RSA"/> instance. Written by hand instead of relying on
/// RSA.FromXmlString, which is Windows-only in .NET (throws PlatformNotSupportedException on
/// Linux/macOS) — this keeps token validation portable across hosts.
/// </summary>
public static class RsaXmlKeyReader
{
public static RSA ReadPublicKey(string xml)
{
var root = XElement.Parse(xml);
var modulus = root.Element("Modulus")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Modulus>.");
var exponent = root.Element("Exponent")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Exponent>.");
var parameters = new RSAParameters
{
Modulus = Convert.FromBase64String(modulus),
Exponent = Convert.FromBase64String(exponent)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return rsa;
}
}
@@ -1,10 +0,0 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>Stand-in for OSMCore's OMSCore.Common.Models.IOs.ApiResponse&lt;T&gt;, which has no equivalent in this project.</summary>
public sealed record ApiResponse<T>
{
public int StatusCode { get; init; }
public bool Success { get; init; }
public string? Message { get; init; }
public T? Data { get; init; }
}
@@ -1,13 +0,0 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/JwtOptions.cs. Binds to the same "Jwt" config
/// section already used by Program.cs's AddJwtBearer setup (Jwt:PublicKey/Audience/Issuer) — no
/// new config keys needed.
/// </summary>
public sealed record JwtOptions
{
public string PublicKey { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string Issuer { get; init; } = string.Empty;
}
@@ -1,6 +1,10 @@
using Distribution.Models;
namespace Distribution.Infra.Authorization;
public interface IPermissionRepository
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
Task<List<string>> GetRolePermissionsAsync(Guid roleId);
Task<List<Permission>> GetAllAsync();
}

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