Compare commits

..

32 Commits

Author SHA1 Message Date
DeepnaPooja 919c76746d Add local Claude settings 2026-09-16 16:26:29 +05:30
DeepnaPooja 76c08855e4 Add invoice model 2026-09-16 16:26:05 +05:30
DeepnaPooja 1ed78b9932 Add invoice lifecycle migration 2026-09-16 16:26:04 +05:30
DeepnaPooja 62de2ddce1 Add invoice lifecycle migration designer 2026-09-16 16:26:03 +05:30
DeepnaPooja 8bc895cbc5 Add invoice service 2026-09-16 16:26:02 +05:30
DeepnaPooja 792d7c0140 Add invoice repository 2026-09-16 16:26:00 +05:30
DeepnaPooja 54931ef110 Add invoice DTOs 2026-09-16 16:25:59 +05:30
DeepnaPooja 8575034714 Add invoice controller 2026-09-16 16:25:59 +05:30
DeepnaPooja 710fc15253 Add invoice repository contract 2026-09-16 16:25:58 +05:30
DeepnaPooja db24c66d77 Document order invoice lifecycle 2026-09-16 16:25:56 +05:30
DeepnaPooja 0cd4508d50 Update invoice collection docs 2026-09-16 16:25:55 +05:30
DeepnaPooja c8f86163f7 Update dispatch pipeline docs 2026-09-16 16:25:54 +05:30
DeepnaPooja 02c673328e Add order invoice settings 2026-09-16 16:25:53 +05:30
DeepnaPooja c137aae7a5 Register invoice services 2026-09-16 16:25:53 +05:30
DeepnaPooja e3d4837379 Add order invoice fields 2026-09-16 16:25:52 +05:30
DeepnaPooja de5dcf99b3 Add invoice enums 2026-09-16 16:25:51 +05:30
DeepnaPooja 2b68e2cc04 Update invoice model snapshot 2026-09-16 16:25:51 +05:30
DeepnaPooja faaa1fe6a5 Link sale invoices 2026-09-16 16:25:50 +05:30
DeepnaPooja f42809a4e3 Update return invoice handling 2026-09-16 16:25:50 +05:30
DeepnaPooja 4b56443d1a Add order invoice lifecycle 2026-09-16 16:25:50 +05:30
DeepnaPooja 72f850ea4d Update order invoice queries 2026-09-16 16:25:49 +05:30
DeepnaPooja 9f04242dc7 Update order invoice DTOs 2026-09-16 16:25:49 +05:30
DeepnaPooja d06d434143 Update order invoice endpoints 2026-09-16 16:25:48 +05:30
DeepnaPooja 248129900d Update order repository contract 2026-09-16 16:25:48 +05:30
DeepnaPooja ea8f952f14 Update delivery task invoice flow 2026-09-16 16:25:47 +05:30
DeepnaPooja 4d63b3e720 Seed invoice operations 2026-09-16 16:25:47 +05:30
DeepnaPooja 2f9661fafc Add invoice context mapping 2026-09-16 16:25:46 +05:30
DeepnaPooja 7d740efabb Update test file list cache 2026-09-16 16:25:46 +05:30
DeepnaPooja 222279b478 Update test compile inputs cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 70fd4a9150 Update test assembly reference cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 15b3e43fe8 Update test assembly inputs cache 2026-09-16 16:25:45 +05:30
DeepnaPooja 8fd31a9b20 Update test assembly info 2026-09-16 16:25:45 +05:30
548 changed files with 8474 additions and 199952 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(dotnet build *)",
"PowerShell(Get-Command mysql -ErrorAction SilentlyContinue)",
"PowerShell(Get-Command mysql.exe -ErrorAction SilentlyContinue)"
]
}
}
-89
View File
@@ -1,89 +0,0 @@
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"
@@ -1,209 +0,0 @@
using Distribution.Features.Notifications;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Distribution.Tests.Services;
public class NotificationServiceTests
{
private readonly Mock<INotificationRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
private readonly Mock<IHubClients> _clients = new();
private readonly Mock<IClientProxy> _proxy = new();
private readonly NotificationService _sut;
public NotificationServiceTests()
{
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
_sut = new NotificationService(
_repo.Object,
new NotificationRecipientResolver(_repo.Object),
_hub.Object,
_uow.Object,
NullLogger<NotificationService>.Instance);
}
private static NotificationArgs Args(Guid? actor = null) =>
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
// Registry
[Fact]
public void EveryNotificationTypeConstant_HasADefinition()
{
var constants = typeof(NotificationTypes)
.GetFields()
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToList();
Assert.NotEmpty(constants);
Assert.Equal(constants.Count, constants.Distinct().Count());
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
}
// NotifyAsync
[Fact]
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
{
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
{
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_ExcludesTheActingUser()
{
var actor = Guid.NewGuid();
var other = Guid.NewGuid();
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
[RecipientRule.Users(actor, other)], Args(actor));
Assert.NotNull(saved);
var recipient = Assert.Single(saved!.Recipients);
Assert.Equal(other, recipient.FK_UserId);
}
[Fact]
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
{
var a = Guid.NewGuid();
var b = Guid.NewGuid();
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
_clients.Verify(c => c.User(a.ToString()), Times.Once);
_clients.Verify(c => c.User(b.ToString()), Times.Once);
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
{
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
var ex = await Record.ExceptionAsync(() =>
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
Assert.Null(ex);
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
{
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("socket gone"));
var ex = await Record.ExceptionAsync(() =>
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
Assert.Null(ex);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
}
// Recipient rules
[Fact]
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
{
var both = Guid.NewGuid();
var permissionOnly = Guid.NewGuid();
var scopeOnly = Guid.NewGuid();
var storeId = Guid.NewGuid();
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
var ids = await new NotificationRecipientResolver(_repo.Object)
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
Assert.Equal([both], ids);
}
[Fact]
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
{
var user = Guid.NewGuid();
var supervisor = Guid.NewGuid();
var resolver = new NotificationRecipientResolver(_repo.Object);
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
}
[Fact]
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
{
var storeId = Guid.NewGuid();
var owner = Guid.NewGuid();
var resolver = new NotificationRecipientResolver(_repo.Object);
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
}
[Fact]
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
{
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
}
[Fact]
public async Task Resolve_DeduplicatesUsersAcrossRules()
{
var user = Guid.NewGuid();
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
var ids = await new NotificationRecipientResolver(_repo.Object)
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
Assert.Equal([user], ids);
}
}
@@ -18,7 +18,6 @@ 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);
}
@@ -149,123 +148,6 @@ 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]
@@ -313,93 +195,4 @@ 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);
}
}
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6c40421b2d23630d4249841425ec7906d93d5985")]
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
00c7acd66c7d7d5bdbb8d4468924c7202fbdc15259175676556b482252622db8
@@ -1 +1 @@
ce3b0b0b2776e1f5cfcf61d517b9f6ce1c89ed303e7e35ab0b7261f5686d0ddb
b5689108dc90f1afbeded25902110c1295d20ccadf0aaae3dfd42fcd421eba23
@@ -125,3 +125,13 @@ C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\n
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.genruntimeconfig.cache
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\.msCoverageSourceRootsMapping_Distribution.Tests
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\bin\Debug\net9.0\CoverletSourceRootsMapping_Distribution.Tests
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.AssemblyReference.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.GeneratedMSBuildEditorConfig.editorconfig
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfoInputs.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.AssemblyInfo.cs
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.csproj.CoreCompileInputs.cache
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\refint\Distribution.Tests.dll
D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\Distribution.Tests.pdb
@@ -1 +0,0 @@
False
@@ -1 +0,0 @@
bf7d0202-9a86-43b3-8323-402073a8c61a
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyW7bMBD9FYFnUaFIaqFuSeygRpDaiI226I3LKCaikAZFZUHgfy8oO0jTWy8EMeCbecvwHTn5BKhDl4fD8hlcHFGOop1LlNAaE4HLZkeqjrcdF0VZcUoZ+41yZG/hDXWo4VqXWgBmUAPmhlRYsV5jY0CUpDSEVjz1lA8j6t6RtIWBZ6uh8OMPCKP1DnXop3XGv6TZ0hZ68JMpgh9g5cYonU5kFsvt7W69wTdN/X15dXV6OcKYGhTWoA61VUW10AJzISrMdcOx7BuKe6r6UpVNRQScYNZFCE4OxWgePzkYHx1E3dGCsoJgKtAxR0ZGmWgrOcLu7ZCYzDYtUj2fy/O1e0fPEFBH8w9DT+0u9GAvnkY12cFcnM4ox8ek9BD8AUK0MPsySx0GGa132XnS9WDBRZSj9TbbDDL2Pjx9MWt70r/6D/m7NH35CnqKYK795GLCohzdSb23DrLVAnWoLrnhghjMSyUxb3qBJTCJVVUL3ipmakihXnsXrZv8NGYrF+EhzPRRh27kMKZp6212GfTeRtBxCknTrzoBN8GbScfs0/2SFKRghCXQFA9TzO7B2AA6QlK3C9O53z8QWlNCEpUpBHAx28i4z77JcZ8emMZUmgKYkqmGqB6q1rRVqxtT123da15pwspKc65Zr4yQSgmmKae94tDUKEeQws7m/Wr6viW1ajEXjcJcMo6VIBzzsq4kV9JUMiE+fczWQ8KpvjGEEoqFbGvMmWK4ZZRhTihpmGx1XaZV+rDknD1PmdxCcDD8JfnO6uBH38fsvATZFxPuJ5f+bjYvxIt1+HV2e+H1I4QspSWtS2t6Duh4PB7/AAAA//8DAAY6R+UCBAAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyU7dMBT9lcjrOHiKHWfHqCJEQYDaqjvHvuFZBPvJcRiE3r9XDg9RuuvOuvI5PsP1GwrmEVCPDrfb0ycIeUY1yn4dMcIkJhpTdUfaXnS90A2VHVdK/UY18hfwinqkhLXUasAcJGDhSIsHPlrsHGhKqCOsFYXT3M+of0PGNw6evIUmzj8gzT4G1KOfPrj4XN42vrFTXFyT4gTnYc4m2CLm5PT24u7qGp8p+f306Oj95gxzIWi8Qz3q2pZZbTUWWrdYWCWwGRXDIxtGOlDVEg3vMB8ypGCmZnYPnxpczAGy7VnDeEMw02hXI2eyKbIHM8Pd67YoWWM6KfN6Ha/H/g09QUI9qz8Cfac7sJM/sPHx0QR3MPrg5w2q0TbFLaTsYY1kdTlNJvsYqv0jx5OHkFGNrm6r68nkMabHLzndvls//w/nl8ZufIDq/AT1SFLhhCYOCzoYLNSosQFu8NBKLbqBOwmltuMYsg9LXObqPGS4T6tK1KMzM82F9Oq2Okx24zPYvKQi/ZcsQHjx+Ti6MiCoRtcpusXm6jNuShrScMILx5K3S65uwPkENkPxdJeWPf0/ECYZKYzHS0oQcnVt8qb6ZuZNueCUay0DcJQPigwjtJ3r2s4qJ2UnRytaSzhtrRCWj4PTZhg0t0ywcRCgZNFd2q3WhQJCmNCK4lGqFgupKDbcScwJjAoYpa4riM9Yq6up4IZROcIIw9p0Egs+cNxxxrEgjChuOitp2Z2PSPaNiw7V6AJSgOkvy5fepjjHMVf76qsvIdwsoXzWal2DZx/wyxr+SbQPkKpSnvGh7OW+r91ut/sDAAD//wMAfxRohPMDAAA=
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS0/jMBD+K5HPdXBsJ3FyA1q0FWKpoNpd7c0eT6hFcCrH4SHU/75yWsSyt73E0cgz8738Trx+QtKS8/1+9Yw+jmRBoptLnPGKsoYW9ZaVrVStbHKlmkpU8jdZEHeNb6QltQQooEEqsEIqLSupER1Qa7EpWGEZL2WaqR9G0r4T7XKLzw4wH8YfGEY3eNKSn87b4SXt1i6HfphsHoYe136M2kMCs1zdX29vN/Sqrr6vLi6ON0cc04DcWdKSqkOjrC5p3TRAJTSGqrqpqdaisx2W6Ty2OR8xeN3no338xGCH6DFCy3MuckZ5Qw4LYnXUCbbRI27f9gnJLNMy1Rdzef5t38kzBtLyxYegx3Fn0Luzp9FMrrdnx2/U42Niug/DHkN0OOsyU+17Hd3gs9Omy96hj2RBbu+zTa9jN4SnL2LdH/mv/4P+Nm1fvSJMEe3lMPlIWqLIgtxo2DmP2XqZhhXSyoZZKgujqay7hmoUmpqyaqQywlaYTL0cfHR+GqYxW/uID2GGT1pypfsRj8DPA+xcRIhTSJx+ValxEwY7Qcw+1S9YznLBRGqa4n6K2R1aFxAiJnbbMJ3m/dPCK85YgjKFgD5mGx132Tc97tIFW9sSOKIthKmZ6bBUVpUKaltVqupAlsBEUYKUIDpjG21MI4BL3hmJdUUWBJPZ2ZwvbQ2zrKip6rim0uqCKgBGrapBAGCBUHzRMbvtU5/pass447TRqqJSGEGV4IJKxlkttIKqSFH6kOTkvUyeXGPw2P9F+cZBGMahi9kpBNkXEe4mn95uNgfixXn6Oqu9HOARQ5bc0s6nmJ4MOhwOhz8AAAD//wMACwdnnQIEAAA=
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTS2/cIBSF/4rF2jgYsA3e5alGUZooGbVVdzyuExQHRoDzUDT/vcIzVZvuusHoinM5fOf6A3n1DGhEx9vt+Qv4nFCNsltLlNAeE4nbYUO6kYuRy0aInhJJfqIauSt4RyMauDGtkYAZ9IC5JR3WbDLYWpAtaS2hHS891UNC4wdSrrHw4gw0IX2DmFzwaETfnbfhtdytXGPmsNgmhhkufcrKm2Lm7Pz+anNziy+G/uv5ycn+ZIJUGjTOohH1E2hhVYcHKQ3mRmosBjlgpdhkJ+jKdy9zPkP0am6SffrjwYbsIZuRNpQ1BFOJdjWyKqtiW6sEm/dtcbJiOiv1ei2v2/EDvUBEI61/A923OzKzO3pOenGzPdqvWaWntK6TMjnEd1SjbQxbiNnByug4JXjW8/tGpaeL/ZmyTedvYJYM9jQsPqMRCVSjFdE8q+yCrw4OT2cHPqMa3dxXt7PKU4jPnyDf77ld/ge2a2UenYfq8qxoWm65JBbzVivMh0liBUxh3fWSC81sDyXz0+Cz80tYUnXpMzzE1SUa0YWaE+z9HUfz6DKYvMRi/UdfhLcx2MXk6k84LWlIwwgroiVvl1zdgXURTIbyiE1cDv3+kdCeElKsLDGCz9Wtyo/VF5UeywE72M5QANsyPRA9QSes6IQZbN+LfjK8M4S1neHcsElbqbSWzFBOJ81h6FGNoMxCtY6fsUQTbQQeqLGYc9liwcSAmSJaU2HEpOUnjtXNXHR6GiyhhGKpRI850wwLRhnmhJKBKWH6tkzabySHiHnJ/gqih/mvJ187E0MKU64OWVefINwtvvza1Zr7q/P4baV9FswTxKqkpZwvU3wIaLfb7X4BAAD//wMA4dUdUiEEAAA=
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyhTJMUP3ZLYQV0j9SJeNEVvQ3JkC9FSC4raNDD2vxfUbuCmt5xIDPgOZx6+nFcS4YCkJ++Ox7sTxryQmuRxC3HGFWWWtnrPul6aXtpGWGN5K/8mNRkf8DvpiZbet94iFaiQysA66sTgaQhoW9YGxjtZcsLzQvpXAmMT8DR6bOblT0zLOEfSky9jDPO3cjeMjZ/mNTRpnvA+LhmiL8Xc3j097B939KNWf9y9f385ueBSEjRjID1RAzoToKPaWk+lt44abTUFEEMYsCvrRTbGjCnC1Czh61sNYc4Rs+95w0XDKLfkXJMAGUrZDhbcfz+WSjZMtyVeb+Ft27+SEybS8/oH0Eu6Gz+NN3k+TnjC6QhpwXTj58MBYiA1Oab5iCmPuKHZup0myOMcq+tlH6YRYyY1eXyqdhPkYU6Hn3g9XRDc/wKBEyZHetKZDkSrEWQwaMOgOuS2E6ZlKCUIzQODYZBK+KCYVQMCD156r1vOgxWK1OR5mh1Mvy8bP8uZM4FprjhKtFwqZT1DYXDoNLIASjllvA3CaC5NMAIsQGsdam+VLyb5BP5ljFjd35ZuWhmkZYHK1gGVerAUUAB1nbLSOBEUFs2HOeYxrvO6VPcx43Pa+JGefIRpwQu5d8m/jBl9XlOB+pcqwl2aw+pz9eaAljWsEUwU0ZqPa64+YxgT+owF7z6t13z/k3DFGSulrClhzNUO8kv1Gywv5UDQofMcMbTCaeYG7EwwnfE6KGXU4GXnmWg7L6UXgwsWnLPCc8kHJ1EXylgMV20ed8xLaNFT7V1HJVhDQYKhxjDDW9VxZouv3jhWj9OmG3RgnHFqwSgqhRPUCC6oZJxpAcarttj5B5Kr+aQhNXnAFHH6T8ufRp/mZR5ydXVh9ROEz2ss86PaHPltjPSfjfbt7L9iqsprwRjLV7k+0LkmB4RlTXjYxk//Snbln1T7bQpx1TBjTE2eMqS8Hq/hrmsMk+fz+fwvAAAA//8DALVuXjbCBAAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRU207kOBD9lcivGwffEtt5A7rRIsRF063d1b75UgEvaaflODAI9b+vkoYBZrQP+xJHJVfVqXOO6xVFswPUotP9fv0EMY+oRDksIUZYg4nGVG5J3QrVCl0pzZlk9d+oROEKXlCLpHCOOg2YQwNYeFJjyzuHvQdNCfWE1WKuae5H1L4iEyoPT8FBNYx/QBrDEFGL/gzRD89zbxMq1w+Tr9LQw2Ucs4luBrNab662t3f4QjY367Oz480RxrlAFTxqUdOBVd7UWGrtsHDaYiW1xMbwzndQz+cxLcQMKZq+Gv3jBwY/5AjZtaxivCKYaXQokTfZzLCtGWH7sp+RLDSt5ni5hJff9hU9QUItK98JPZY7cX042Y12Cr0/Wb6oRPs07CHlAAsjy5B9b3IYYvHW47wPEDMq0dmcspmcg3FELdqmCd6j63gfIlwks4PnIT3eHNtWN+stKtFdGv4Bl+9MfkAt4p1TvrOOGqFtZ8HrhtQGtOmEFarznlPXOUY70khpmei48taappG1t5YxVKLbTXHXm9wNafdFr81Rgsv/ocDm9Hwdje1hzrkw/fhjpK1J95BRiyylnVNSa+FZrT0H4xQI2lhPjFIcBHgute6o4pxxRTtFrNKmVsTWIDxHJbocN9lEb/ohwvo7uGnm94PCa+MeQoTicjUDp8ILTTwW1BosZKexAW6wrRstlOW+gdnD50PMIU7DNBaXMcN9WjT7NMTtpjhN7iFkcHlKsxx/NXPiXRr85HLxYTZKKlJxMgO9nfJ+ysU38CGBywsrbyBvNz+nsIYRMkOZUoKYi1ng4nczzipTL33tGICn3EpiO6iVV7Vy0jeNajonakc4rZ0QjnfWa2Ot5o4J1lkBskElgtnbxfKcrGkceEewB6OwUKrBhmuPQVsKXBkpmPjCY3HbL3md9IQRhrVRDRbccqw441gQRiQ3yjXUfLXwpxFV1VRUVFyqhZp33t5ehVCoRFeQIvSfeLkOLg3j0OXizZXFF6Y+NVqFcd+bl1/6/QaSk456K9/vnz+Ae/zVpN+mOO/GYnH7c4j4+yLvanCPkIrZHibEeQ38yLie+hy2DwmMB389ePi56KFEOzDjlGC3bN9lIURIC4zVdLTYZbwOfR9GcEP0I2oFUxUlgr6h/c9rmlVSE3U4HA7/AgAA//8DADlZ+rXpBQAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxTTW/bOBD9K8KcRYUSqQ/q1sQJNkhbB7V3u9gbP0YOEYU0KCptEPi/F5Sdtm572JOE0ejNm/fevIKTTwg9vNvvr5/RxQlyiHYpVbRqCBWkbLe07nnXc1Hwpu2qlv4HOdg7fIEeWq51qQUShg0SbmhNFBs0MQZFSUtDq5onTLmboH8FaQuDz1Zj4ad/MEzWO+jhs3XGf0mzpS306GdTBD/irZuidDqRWV1v7rbre3LTNh+vLy+PnRNOCaCwBnpoBlSdkTVphdCEa6FI14qWSMkGM2CdnsffrIsYnByLyTz+4GB8dBh1XxUVKyipBBxyMDLKRFvJCbcv+8RkkWmV6vlSXl77V3jGAH2Vvwl6hLvQo714mtRsR3Mx+t3Out2Vd4PdzUHGNDiHffB7DNHiItB6k/3gVNKCFlVTUQo5fJD6wTrMblfZekwbq6E1tKIVEbJrCGeKkY5VjHBa0ZbJTjdlInmHweH4E+oHq4Of/BCzk+7Z2Zwr76J1s5+n7NZF3J2I9nAjxwlTwxwCupjdy/iQ/SWnh8TUtKbWFaIpmWqpGrDuTFd3ujVN0zWD5rWmrKw155oNygiplGC64tWgOLYN5LDF8GSdHN/73S5p+X3e+YcVDnIe48bPYUnGZB4hh0vrZHg5dvw9oTl1fTya8Ya0BGocl4Wyk59Xo0UXz/RNaSq54YIawkslCW8HQSQySVTdCN4pZhpMsb4P3sw6/moZowxy+DS7dErZbTLri3Xka8N/ofoTN0y5ypYoCymVarQgjFEkvCkrIriQhCGTkg+a8UFADiuvHzFkyS9p3RnYepO9C/rBRtRxDmnNf5fZ6012P8o4+PB0dnbrOe7nmH1CYwPqiInFNswJ6saO+BvZt71PGvIOctgcr3HZ9n8e45V3k/8D/B8dhx7kHD0cDofDNwAAAP//AwDB5+uJuAQAAA==
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy07cMBT9lcjrODi248TZwQyoCFEQjNqqOz9uGItgjxyHh9D8e+XMIEp3XcW68jk+j5t35NUToB6d7nbnz+DThEqU3DKihApMJK7bDWl63vVcVpIwSrr2NyqRu4I31KOWG1MbCZiBAMwtabBmg8HWgqxJbQlteOZUDxPq35FylYVnZ6AK0w+Ikwse9ein8za85LeVq8wYZlvFMMKln5LyJotZn99fbW5u8UUrvp+fnR1uTjBlgspZ1CMxgO6sanArpcHcSI27VrZYKTbYAZr8PcCcTxC9GqvJPn5qsCF5SKanFWUVwVSifYmsSirL1mqCzdsuK1liWud5uYyXY/+OniGinpYfgR7oTszoTkx4elLengzOu2mLSrSLYQcxOVgiWVyOo0ou+OL4yGp04BMq0c19cTuqNIT49CWn+4P1y/9wfq3M1nkoLtcZU3PLJbGY11ph3g4SK2AK60ZI3mlmBeTaVsEn5+cwT8WlT/AQF5WoRxdqnOCg7zSarUtg0hyz9F8iA+HVpVWweUBQiW5jsLNJxWfcNalIxQjLHHPazam4A+simATZ0ybOR/p/IFRQkhlXc4zgU3Gr0rb4pqZtvmBb2xgKYGumW6IHaDrbNZ1prRCdGAxvDGF1Yzg3bNBWKq0lM5TTQXNoRdad2y2WhSINlxKswMKQBnMuNdaSMtxo0tq2YVTorP4z1uJmzDg9tJZQQrFUncCcaYY7RhnmhJKWqc6IOu/ORyTHxnmHSnQF0cP4l+VrZ2KYwpCKY/XFlxDuZp9/1mJZgxfn8esS/jqYR4hFLk85n/fy2Nd+v9//AQAA//8DAB8jFwPzAwAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUS2/dNhCF/4rAtSjzJT60S2IHdY3URmw0RXdDcmQL0aUuKMppYNz/XlC+gZvusiMGPMPDj4fzQhIckAzk3fF49YyprKQlZdpLgglNmaPcPLB+UHboWccsF9rwv0lLphv8TgZiVAg8OKQSNVIVWU+9HAONER1nPDLRq9oTHlcyvBCYuojPU8BuWf/EvE5LIgP5MqW4fKtnw9SFedlil5cZr9NaIIVq5vLq/ubh9o5+NPqPq/fvX3euuNYG3RTJQKKzIVghqTGipwowUDcCUAAZIcKoneOvsikVzAnmbo1f3zzEpSQsYRCdkB2jwpFTSyIUqLY9rPjw/Vid7Jgua73dy/tyeCHPmMkg2h9AX9tdhHm6KMtxxmecj5BXzBdhORwgRdKSY16OmMuEO5r9tvMMZVpScz7swzxhKqQlt/fN3QxlXPLhJ173rwiuf4HAM2ZPBiJZL7UxqKSU4LQXDKPRQWvhAzAzeoE8IOudMo4bxqUTzMleG2siMBdISx7nxcP8+7rzc4J5G5kRWqBCJ5TWLjCUFsfeIIugtdc2uCitEcpGK8EBcOfRBKdDDcknCE9Twub6kgxEcxWVY5Eq7oEqMzoKKIH6XjtlvYwaq+bDksqUtmVbm+tU8DHv/MhAPsK84iu5dzk8TQVD2XKF+peuwru8xC2U5i0BnHWsk0xW0VaOW2k+Y5wyhoIV70Pezv3+JxFaMFatbDljKs0dlKfmN1if6oZoYh8EYuTSG+ZH7G20vQ0mam31GFQfmOR9UCrI0UcH3jsZhBKjV2g0aQnWwDV7xsMYjRo9Uo9CUqUsp1b3hkouNToEiLuRN47N7Vx1fjSRCSaoA6upkl5SK2sHJpiRYIPmNc4/kJzDpyxpyQ3mhPN/rvxpCnlZl7E05xQ2P0H4vKU6P5o9kd+mRP/ZaV8u4Svmpr4WTKl+lfMDnVpyQFi3jId9/Awv5K7+k+Zhn0JCdZpz05L7Arlsx3NZ6c4xrU6n0+lfAAAA//8DAMJGy6DDBAAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUy27dNhCGX0XgWpR5v2iXxA5qGKmN+CAtuuNlFBPRoQ4oymlgnHcvKJ/ATXfdEQPO8J9vfs4Lyu4IaETvTqebZ8h1RT2qaQ8xwhQmFlN9IHIUZpRkoMRYQsVfqEfpDn6gEWkRAg0WMAcFWEQisedTwDGCpYRGwqRoNd3XFY0vyKUhwnMKMCzrFyhrWjIa0R8px+V7e9ulIczLFoeyzHCb1+pyaGKubx7vDvcP+KNWv9+8f/96c4W1FRhSRCOK1oRgGMdaM4mFg4Dt5Bx2jkcX3aSspa9pKVco2c3DGr+9aYhLzVDDyAbGB4KZReceRVddk+3dCocfp6Zkx3Td4v0e3o/jC3qGgkbW/wT6Wu4qzOlq3fwMzzCfXFmhXIXleHQ5oh6dynKCUhPsaJ6h+GVNtVENQTtjKI3GaSF1NFwyriMnEA3RMtponRJqYkHKKXpjtZhEZFREBUJ4jXq0w5tnV9OSu4v2D3OCXFGP7h+7h9nVaSnHX/A/vhK9/R9Am2w0Ik4kV1qD4Jw7qzwjELUKSjEfHNGTZ0ADEGmFtlQTyi0jlkuljY6O2IB69MmFp5Shu71GI1JURGFJxIJ6h4WeLHbAHfZSWWE8b42iHn1Yck15W7a1u80Vvpa9XzSij25e4bXTdyU8pQqhbqVB+FO1xIeyxC3U7s0AlAxk4IS3pK2ettp9hpgKhAoNx6Fsl3r/SWGKEdKkbKVArt2Dq0/db259aheijjIwgEi518RPIE000gQdlTJqCkIGwqkMQgQ++Wid95YHJtjkBWiFegTNb91u8TBJyi112BhtsIheYi8Nw0QqYo0xOoo2kTeO3f3c8vykI2GEYeuMwoJ7jg1nHAvCiObOBEWbm38iuZhFGNSjOygZ5n+1/CmFsqzLVLuLa7pfIHzeclsf3e6g7ynjv3fa10v4BqVr03Ipt59yGdC5R0dw61bguG+f8QU9tG/SHfYlxMSgKNU9eqyu1O10CQs1WKLE+Xw+/wMAAP//AwC8ws6OwgQAAA==
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoyUTW/bOBCG/4owZ1GhSOrz1sQJNsi2Tmu3u9gbRY4cIgppkFS6QeD/vqDsbOp2D3sTRqN3nnlnRq9g5RNCDx/2++tntDFADtEsIUZZTWhHymZLq160fUWLsmmrqhJ/QQ7mDl+gh0YoVaoOCccaidC0IgMfFdEau5KWmrJKJE25C9C/gjSFxmejsHDhG/pgnIUe/jBWu++ptjSFmtysC+8mvLUhSqsSzOp6c7dd35Obpv50fXl5zAwYkkBhNPSgu1aplnHSNKwiQqIi3SglkZJrqeVYd115/MzYiN7KqQj68Z1Bu2gxqp4VjBeUsA4OOWgZZcIeZMDtyz6RLDatUjxfwstj/wrP6KFn+ZuhR7kLNZmLpzDMZtIXk9vtjN1dOTua3exlTIVz2Hu3Rx8NLgatN9k7U0kLWrCaUQo5fJTqwVjMblfZekodD2OjKaOMdLKtieADJy1nnAjKaMNlq+oyQd6htzj9oPrRKO+CG2N28j07q3PlbDR2dnPIbm3E3Qm0hxs5BUwJs/doY3Yv40P2mwwPiVQ3ulIMUZd8aOgwYtXqtmpVo+u6rUclKkV5WSkhFB8H3clh6Lhigo2DwKaGHLbon4yV0+9ut0te/lvv/MUKRzlPceNmv2xG0I+Qw6Wx0r8cM74G1KesT8dhvCktCzVNS0PZaZ5Xk0Ebz/yFHupSaNFRTUQ5SCKasSMSuSRDVXeiHbiuMa31vXd6VvHnkXHKIYcvs02nlN2mYX03lvxdi59Qf2DDtFfZssp1Q5EPoyZIO0GEGJFIHCnRVOMoq6qsaAc5rJx6RJ+leUljz8TWm+yDVw8mooqzT23+udReb7L7ScbR+aezs1vPcT/H7Atq41FFTBRbPyepGzPhL7BvfZ88FO1xb4J7y/2GfnDBxPSL+DwbTAZvjue62PE/r/VM853pPzcCepBzdHA4HA7/AAAA//8DAL7u70zYBAAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUy27jOBD8FYFnUaFI6mVgDxknwQbZjI3Ys7vYGx/NmIhMGhSVTBDk3weU5YkzmMPeCLIfVdXVfENO7AEt0OXhcP0MLg4oR9FOV5TQGpMOl82WVAveLipS1C1rSMn/Qzmyd/CKFqjhSpWqA8ygBsw1qbBkRmGtoStJqQmteKopHge0eEPCFhqerYLCD39DGKx3aIH+sU77l9Rb2EL1ftRF8D3cuiEKpxKYq+vN3Xa1xjdN/fX6y5dj5ABDKlBYjRZId61SLWW4aWiFuQCFOyMEFoJpoYWpu648plkXITjRF4N++sCgfXQQ1YIWlBUE0w6950iLKBJsKQbYvh4Skkmmq3SfT9fTcfGGniGgBc1Pgh7LXajeXuwHOdpeX0QRHiGaIPbw4sMTPIse5egQ/AFCtDDps9pkH5BKUpCC1pQQlKN7oXbWQXZ7la36RFiaRhNKKO5EW2POJMMtowxzQknDRKvqMmG8g+CgP6t6b1Xwgzcxm2XPPvVZehetG/04ZLcuwmMQ8Zh3I/oBUI7Wo+ztsHsAoV+3/mFMb27sE5flGAK4mK1F3GV/imGXSOhGV4oC6JLJhkgDVavbqlWNruu2NopXirCyUpwrZqTuhJQdU5RTIzk0NcrRaoyHMZ70/34GYhvsfg/6A8F20njdi2h82N9qcNEam0ZziphM1fcTq2yuuewtuPhJZLRAdck174jGvJQC88Z0WAATWFZ1x1vJdA3J2kvvjH0cf+pEjNGcmapta9nJkgA1TctJYyTRyjDV6UpCQxvStNB2ihBZ8bKVnSIayorrxC54Par4qxUYYShHD6NLG5rdJtov1uHvNf9QZB189Mr3aWC2h83rEGH/u6kt/f7gBxsTfzOP9tsA996l1/kPmDWbky99PAuGtAjZtHtJFg2c4k7VDHNtGiwp0biuW97UXDDQHcrRlVdPELLkMGHdNJSTqVab7DKonY2g4hhS638nVsd53px25kOQ4uv1dukDXB4O+Xz7x3NXJAevNtnJAJ/+lm9Dit75IbGIYTzz0QZ6c4J15qaj8bIH0DaAitPT9pg4a/Q7h12GaI1QcUhr8JdXkzFmp80hv/Y7G8DP7GPzsyHMppgr8XaqM/2AkxX+1wf4/v7+/gMAAP//AwCxdSWE9QUAAA==
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTU/cMBD9K9Gc4+DYjvNxoyxVEWpBsGqr3ib2pFgEZ+U4fAjtf6+cXUTprRfLGvnNvPfm+RU8PhB0cLrbnT+SjzPkEN1aElxoxltW1ltedarpKl00Uopa6l+Qg7ukF+igVsaUpiUmSRNTllesl4Nh1lJb8tJyUanUE3/P0L0CusLSozNUTPN3CrObPHTww3k7PaXZ6AozTostwjTShZ8jepPIbM5vL7dX1+xzrb+df/p0eDnTnBoUzkIHtm2MaYRkdS0qppAMawdEhigtWhx025YHmPORgsexmO39Owc7RU/RdKIQsuBMtLDPwWLERLvHmbYvu8RktWmT6vlaXq/dKzxSgE7kb4Ye2p2Y0Z08zP3iRntyOCPO90npLkw7CtHR6ssqdRwxuslnx0lnoyMfIYer2+x6xDhM4eGDWbcH/Rf/IX+bpp8/k1ki2bNp8RE6UAJy+IrmznnKLjbQgS6VVS23TJU9MlUPLUOSyPpKt6rppdWUtno2+ej8Mi1zduEj/Q4rf+jgM44zHZifBnPnIpm4hCTqp07A6zDZxcTs3f6SF7yQXCbQEndLzG7IukAmUpK3Dcux3z8QoQXnicoSAvmYXWO8y77gfJce2NpWRhDZUvY17weqGttUjamt1o0ejKoMl2VllDJy6G2Lfd9KI5QYekW1hhwobTtbA9YS15wImUbNmdKVYNhzYkZz5FXbN7ZMmXj3MbsaE64fassFF6zFRjMle8kaKSRTXPBaYmP0inuz5Lh81UAOlxQ8jX9J/upMmOZpiNkxBdkHE24Wnz5vtibiyXn2vLq9mcw9hSxtC51POT0uaL/f7/8AAAD//wMAd4cViwMEAAA=
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTXW/bIBT9KxbPwcWAMfitbVotqrpGbbRNe8Nw3aA6EGHcD1X57xNOqq5725t1xTn3fFy/I693gFp0vt9fPYNPI1qg5OYRJVRgonDVbEjdctnWopSMM0bVb7RA7gbeUIsabkxlFGAGAjC3pMYd6w22FlRFKktozTOnfhxR+460Ky08OwNlGH9AHF3wqEU/nbfhJe/WrjRDmGwZwwArPybtTRazvHq42dyt8XUjvl9dXBxfjjBmgtJZ1CKrpDGSMtw0tMZcg8Gq1xprzay2uhdKVUeY8wmi10M52qdPDTYkD8m0tKSsJJgqdFggq5POsjs9wuZtn5XMMS3zfDGP58/2HT1DRC1dfAR6pDszgzszYbfT3p71zrtxixZoH8MeYnIwRzK7HAadXPDFacnl4MAntEB3D8V60KkPcfclp4ej9dV/OL/VZus8FKslapGouOWKWMyrTmPe9AprYBp3tVBcdswKyLVdBp+cn8I0Fiuf4DHOKlGLrvUwwlHfeTRbl8CkKWbpv0QGwqtLl8HmQd69jsFOJhWfcVekJCUjLHNMaT+l4h6si2ASZE+bOJ3o/4FQQQnJyqYYwadirdO2+KbHbX5gG1sbCmAr1jWk66GWVtbSNFYIKXrDa0NYVRvODes7q3TXKWYop33HoRFZd263mA+qMY1QnQbMOasxZ12HOwUWV5KQCqSESvdfYi3uhozr+sYSSihWWoqMY1gyyjAnlDRMSyOqfDsfkZwa5xIt0A1ED8Nflm+diWEMfSpO1RdfQriffP5Zi/kMXpzHr3P4y2CeIBa5PO18vstTX4fD4fAHAAD//wMA/WXCnPMDAAA=
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUy27kIBD8FYvz4GDA2PiWpzaKsok2o+xqbzzaCYoDI8B5KJp/X+GZKDun1V4QancX1VUlfyCvngEN6HizOX8BnxNaoeyWEiVUYCJx061JO/B+aEXdU0lb2v1GK+Su4B0NqOPGNEYCZiAAc0tarNlosLUgG9JYQlteMNVDQsMHUq628OIM1CHdQ0wueDSgn87b8FreVq42U5htHcMElz5l5U0hc3Z+d7W+ucUXnfh+fnKy60yQCkDtLBqQlb0xPWW462iLuQKD5agUVopZZdUopGx2Y85niF5NdbJPXxxsyB6yGWhNWU0wlWi7QlZlVWhrlWD9vilMFpnOSn21lJfr8IFeIKKBrj4F3cEdmckdPSc9u8ke7c6s0lNazlGZHOI7WqFNDBuI2cGi0XFK8Kyn97VKTxe7nnJN529g5gz2NMw+owExgVZo0WiaVHbBV3uKp5MDn5ePOTqfnPkXVEG6uatuJ5XHEJ8PLLnbqXz5HyJfK/PoPFSXZwW74ZZLYjFvtMK8GyVWwBTWrZC818wKKAk5DT47P4c5VZc+w0NcVkIDulBTgh2/42geXQaT51j2/CXK4G0Mdja5+rKyITWpGWFlaM6bOVc/wLoIJkNZYh3nPd794QgVlJBCZY4RfK5uVX6svqn0WBpsZ1tDAWzDdEf0CG1v+7Y3nRWiF6PhrSGsaQ3nho3aSqW1ZIZyOmoOXREYSnKqJaySso522mA7tgJzYhusjBW4oVJT23R2pPRAx+pmKnN67CyhhGKpeoE50wz3jDLMCSUdU70RTcnlpyT7PPAerdAVRA/TXytfOxNDCmOu9l5XByL8mH35EVSL76/O47dF7bNgniBWxS3lfMn83qDtdrv9AwAA//8DAJ9uh65PBAAA
@@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRVTW/jNhD9KwKvlbQSKZGSgD1kHWcbLPKB2OgWvQ3JYcxGIQ2KyjYN/N8LyknX2UUPvdjSSDPz5r031Atx8IhkIGf7/foJXZxITqJdQrSivKj6ohbbqh2abmh52bGu5XX/B8mJ/YLPZCCiUapWPRYMORaNrtpCMqMKrbGvq1pXtG1STbifyPBCwJYan6zC0k+/YZisd2QgX63T/lvqDbZUo591GfyIl26K4FQCc77efNne3BYXgl+vP306vjnhlAqUVpOB6L5TqqOsEIK2RQOoit4AFABMgwbD+74+plkXMTgYy0k/fMegfXQY1UBLysqqoD055ERDhARbwoTb531CstB0nuL5El4uhxfyhIEMNH8j9Fjugxrth8dJznbUH5ZfkpN98HsM0eLCyDLkOEK03mWvPVajRRdJTj6llM2sFE4TGcgFjBO+hdfu3jq8CPCI33x4uD72La/XW5KT2+D/RBVvIe7IQJhRnTZS1dD00kjUPa9awB5MI5vOaM1qZRStTcWFkLQxrNNSAuei1VJSSnJys8luR4jGh8d3gm2OGlz+Dwk2Z6u1Azmi/nGkLYR7jGQgLW8lMtNRLjlo6FlnakDWayUZb7GTQncoBKMMVKuqrla1qFvoRdsKzmjqcjltIjgNo3e4/gvVnAgmA9mGOfW7ArWzDrPLczIQXje66StdNLWEohGmLwAZFLLlfdNJpjkmE1+AHeeAK4h470Ny//X8GZNOK++idbOfp+zSRbwPi5wn491ssrOgdjaiinNIQv3OU8nb4PWsYvbdh3VVViWrWEqa436O2R1qG1DFha9X+DebH1Mop1WVoMwhoItZkj77Faakf62FbhVF1DWTopIG2053baeE5rzjRjWtqljdqqZRzEjdg5Q9U7ShRjYoOMkJJttny6Y1XS8qo6DoIFHGelOAkrLg0vTQiKpjlXjHcHYzpjxphK5oRYseOl40TLKiY5QVTUUrwaBTvIb35j4ZsSt5WTclE91CzRtvrwvTdCQn6xB8WPk5nWLD6d2lMz57yVb+cW9HDNnHLM+upqXPZ3QYYDwNrZ9gnI/reBp9s9C77AD73RLYwvSwXGz0wx1OfnzCMC2Ba4wbfXy2uCX7mNV5tqSvdqhen0C0T7j1flQ7sMceK6/xzMH4PNljpTv42x/Bf92b5f9s2l8vFfPsJu6WybIDyckXDA7HE4dcWRX85E3MXjc3e+eZE8rP7bQf4fkn5n9BwSpTa5mk/Y7+50W+m136gGTLifDNuuKvxejnXj1gyNKigHXprPw342oeo93uAoJGfeU1/lj0kJNHhGkO+Lh8opZT02FYYJzPx2W7dFd2HO2Eyjs9kYFz3pSM0/YV7n++JxpaVqzmh8Ph8A8AAAD//wMADw4oQRAHAAA=
@@ -1,18 +0,0 @@
{
"Microsoft.NET.Workload.Emscripten.Current": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net6": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net7": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net8": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net9": "10.0.111/10.0.100",
"Microsoft.NET.Sdk.Android": "36.1.69/10.0.100",
"Microsoft.NET.Sdk.iOS": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.MacCatalyst": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.macOS": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.Maui": "10.0.20/10.0.100",
"Microsoft.NET.Sdk.tvOS": "26.5.10315/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.Current": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net6": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net7": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net8": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net9": "10.0.111/10.0.100"
}
+122 -486
View File
@@ -16,16 +16,11 @@ 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; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<Invoice> Invoices { get; set; }
public DbSet<InvoiceItem> InvoiceItems { get; set; }
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
@@ -36,11 +31,13 @@ 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; }
public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
@@ -52,8 +49,6 @@ 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; }
@@ -67,20 +62,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
public DbSet<BankAccount> BankAccounts { get; set; }
public DbSet<ChequeBook> ChequeBooks { get; set; }
public DbSet<ChequePage> ChequePages { get; set; }
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
public DbSet<SupplierPayment> SupplierPayments { get; set; }
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// --- Composite PKs ---
// OrderItem uses its inherited Template.Id as PK (no composite key on
// {FK_OrderId, FK_ItemId}) so that editing a PO's items can soft-delete the current
// row and insert a fresh one for the same item — versioning per order_invoice_lifecycle.md.
modelBuilder.Entity<OrderItem>()
.HasIndex(oi => oi.FK_OrderId);
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -94,39 +83,27 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<LoadingSheetOrder>()
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
// --- 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, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
.IsUnique();
modelBuilder.Entity<ItemSerialNumber>()
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
// --- User ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Vehicle ---
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>()
@@ -135,7 +112,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- DeliveryRun ---
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile)
@@ -143,145 +124,76 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
// --- ShopPO ---
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(p => p.FK_StoreId)
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
// InvoiceId is a plain column — no FK constraint
modelBuilder.Entity<ShopPO>()
.Property(p => p.InvoiceId)
.HasColumnName("InvoiceId");
// --- ShopPOItem ---
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.ShopPO)
.WithMany(p => p.Items)
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.Item)
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Invoice ---
modelBuilder.Entity<Invoice>()
.HasOne(i => i.ShopPO)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.Invoice)
.WithMany(inv => inv.Items)
.HasForeignKey(i => i.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersion>()
.HasOne(v => v.Invoice)
.WithMany()
.HasForeignKey(v => v.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersion>()
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
.IsUnique();
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.InvoiceVersion)
.WithMany(v => v.Items)
.HasForeignKey(i => i.FK_InvoiceVersionId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
.HasForeignKey(p => p.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheet ---
modelBuilder.Entity<LoadingSheet>()
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetOrder ---
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.LoadingSheet)
.WithMany(s => s.Orders)
.HasForeignKey(lo => lo.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.ShopPO)
.WithMany()
.HasForeignKey(lo => lo.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetItem ---
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.LoadingSheet)
.WithMany(s => s.Items)
.HasForeignKey(i => i.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Warehouse)
.WithMany()
.HasForeignKey(i => i.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.SetNull)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SchemeRedemption ---
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.CreatedByUser)
.WithMany()
.HasForeignKey(o => o.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Order>()
.HasOne(o => o.ConfirmedByUser)
.WithMany()
.HasForeignKey(o => o.ConfirmedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// Order.InvoiceId is a plain column with no DB-level FK constraint — deliberately
// unmapped as a relationship, per order_invoice_lifecycle.md.
modelBuilder.Entity<Order>()
.Property(o => o.InvoiceId)
.HasColumnName("InvoiceId");
modelBuilder.Entity<Invoice>()
.HasOne(i => i.Order)
.WithMany()
.HasForeignKey(i => i.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.PrintedByUser)
.WithMany()
.HasForeignKey(i => i.PrintedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.FinalizedByUser)
.WithMany()
.HasForeignKey(i => i.FinalizedByUserId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
@@ -290,11 +202,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
@@ -302,8 +213,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- Scheme ---
modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem)
.WithMany()
@@ -332,7 +241,23 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
// --- PurchaseOrder ---
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.SourceWarehouse)
.WithMany()
.HasForeignKey(s => s.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
@@ -352,24 +277,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
// --- GoodsReceipt ---
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder)
.WithMany()
.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()
@@ -388,94 +301,6 @@ 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>()
.HasOne(pr => pr.PurchaseOrder)
.WithMany()
@@ -494,15 +319,22 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
// --- SaleReturn ---
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
@@ -526,16 +358,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
// --- CreditNote ---
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
// --- Claims ---
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
@@ -588,8 +416,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
// --- DeliveryTask ---
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
@@ -597,22 +423,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.ShopPO)
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_ShopPOId)
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
// --- 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();
@@ -635,190 +450,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
.HasKey(c => c.Key);
modelBuilder.Entity<SysConfig>()
.Property(c => c.Key)
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
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 }
);
// --- Payment service: Bank Accounts & Cheques ---
modelBuilder.Entity<BankAccount>()
.Property(a => a.AccountName).HasMaxLength(200);
modelBuilder.Entity<BankAccount>()
.Property(a => a.BankName).HasMaxLength(200);
modelBuilder.Entity<BankAccount>()
.Property(a => a.CashAccountTypeName).HasMaxLength(100);
modelBuilder.Entity<BankAccount>()
.Property(a => a.AccountNumber).HasMaxLength(100);
modelBuilder.Entity<BankAccount>()
.Property(a => a.CurrencyCode).HasMaxLength(10);
modelBuilder.Entity<BankAccount>()
.HasIndex(a => new { a.AccountType, a.AccountName })
.IsUnique();
modelBuilder.Entity<BankAccount>().HasData(new BankAccount
{
Id = new Guid("11111111-1111-1111-1111-000000000001"),
CreatedAt = new DateTime(2026, 10, 19),
IsActive = true,
AccountType = BankAccountType.Cash,
AccountName = "Petty Cash",
CashAccountTypeName = "Petty Cash",
CurrencyCode = "LKR",
IsDefault = true
});
modelBuilder.Entity<ChequeBook>()
.Property(b => b.ChequeBookNo).HasMaxLength(100);
modelBuilder.Entity<ChequeBook>()
.Property(b => b.StartChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequeBook>()
.Property(b => b.EndChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequeBook>()
.HasIndex(b => b.ChequeBookNo).IsUnique();
modelBuilder.Entity<ChequeBook>()
.HasOne(b => b.BankAccount)
.WithMany()
.HasForeignKey(b => b.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ChequePage>()
.Property(p => p.ChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequePage>()
.Property(p => p.CurrencyCode).HasMaxLength(10);
modelBuilder.Entity<ChequePage>()
.HasIndex(p => p.ChequeNo).IsUnique();
modelBuilder.Entity<ChequePage>()
.HasOne(p => p.ChequeBook)
.WithMany(b => b.Pages)
.HasForeignKey(p => p.FK_ChequeBookId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ReceivedCheque>()
.Property(c => c.ChequeNo).HasMaxLength(50);
modelBuilder.Entity<ReceivedCheque>()
.Property(c => c.ReceivedFromName).HasMaxLength(200);
modelBuilder.Entity<ReceivedCheque>()
.HasIndex(c => c.ChequeNo);
modelBuilder.Entity<ReceivedCheque>()
.HasOne(c => c.DepositBankAccount)
.WithMany()
.HasForeignKey(c => c.FK_DepositBankAccountId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SupplierPayment ---
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.Supplier)
.WithMany()
.HasForeignKey(p => p.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.BankAccount)
.WithMany()
.HasForeignKey(p => p.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.ChequePage)
.WithMany()
.HasForeignKey(p => p.FK_ChequePageId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierPayment>()
.Property(p => p.Amount).HasPrecision(18, 2);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasOne(a => a.SupplierPayment)
.WithMany(p => p.Allocations)
.HasForeignKey(a => a.FK_SupplierPaymentId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasOne(a => a.GoodsReceipt)
.WithMany()
.HasForeignKey(a => a.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasIndex(a => a.FK_GoodsReceiptId);
// --- Notifications ---
modelBuilder.Entity<Notification>()
.HasIndex(n => new { n.EntityType, n.EntityId });
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.Notification)
.WithMany(n => n.Recipients)
.HasForeignKey(r => r.FK_NotificationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.FK_UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_UserId, r.IsRead });
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
.IsUnique();
// --- StoreCreditMovement ---
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Store)
.WithMany()
.HasForeignKey(m => m.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Invoice)
.WithMany()
.HasForeignKey(m => m.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Payment)
.WithMany()
.HasForeignKey(m => m.FK_PaymentId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StoreCreditMovement>()
.Property(m => m.Amount).HasPrecision(18, 2);
modelBuilder.Entity<StoreCreditMovement>()
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
Seed.AdminSeed.Apply(modelBuilder);
}
}
-25
View File
@@ -1,25 +0,0 @@
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
});
}
}
+3 -9
View File
@@ -16,9 +16,6 @@ public static class CommerceSeed
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
private static readonly Guid Payment2Id = new("47000000-0000-0000-0000-000000000002");
private static readonly Guid Payment3Id = new("47000000-0000-0000-0000-000000000003");
private static readonly Guid Payment4Id = new("47000000-0000-0000-0000-000000000004");
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
@@ -83,18 +80,15 @@ public static class CommerceSeed
);
modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
);
modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true },
new Payment { Id = Payment2Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 1000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 9, 20, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 9, 20, 0), IsActive = true },
new Payment { Id = Payment3Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 800m, Method = PaymentMethod.Cheque, EntryMode = EntryMode.DayEndManual, ChequeNumber = "000456", ChequeDate = new DateTime(2026, 8, 31), CollectedAt = new DateTime(2026, 8, 31, 11, 0, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, RecordedBy = MasterDataSeed.Supervisor1UserId, CreatedAt = new DateTime(2026, 8, 31, 18, 0, 0), IsActive = true },
new Payment { Id = Payment4Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 300m, Method = PaymentMethod.Credit, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 11, 5, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 11, 5, 0), IsActive = true }
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
);
modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<SaleReturnItem>().HasData(
+18 -14
View File
@@ -7,6 +7,10 @@ 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");
@@ -80,8 +84,8 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Region>().HasData(
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 }
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Models.Route>().HasData(
@@ -92,12 +96,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", 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 }
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 }
);
modelBuilder.Entity<Warehouse>().HasData(
@@ -111,12 +115,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<User>().HasData(
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 }
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 }
);
modelBuilder.Entity<DriverProfile>().HasData(
+74 -87
View File
@@ -8,20 +8,15 @@ public static class OperationsDataSeed
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
// Kept for OperationsExtrasSeed stock movement reference (ReferenceId on MovementType.Sale)
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO3Id = new("33000000-0000-0000-0000-000000000003");
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid ShopPOItem4Id = new("34000000-0000-0000-0000-000000000004");
internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001");
internal static readonly Guid Invoice2Id = new("34500000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -36,12 +31,16 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500,
Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
@@ -49,101 +48,88 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ActiveDeliveryRunId,
Status = RideStatus.Open,
RideType = RideType.Delivery,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300,
Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPO>().HasData(
new ShopPO
modelBuilder.Entity<Sale>().HasData(
new Sale
{
Id = ShopPO1Id,
Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
RaisedById = MasterDataSeed.SalesRep1UserId,
InvoiceId = Invoice1Id,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId,
TotalAmount = 3000m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
Status = SaleStatus.Completed,
PaymentStatus = PaymentStatus.Paid,
FulfillImmediately = false,
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Sale
{
Id = Sale2Id,
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
FK_RideId = ActiveDeliveryRunId,
TotalAmount = 1200m,
Status = SaleStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
FulfillImmediately = false,
CompletedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<Order>().HasData(
new Order
{
Id = Order1Id,
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new ShopPO
new Order
{
Id = ShopPO2Id,
Id = Order2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
TotalAmount = 1200m,
Status = ShopPOStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
Status = OrderStatus.Pending,
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
},
new ShopPO
{
Id = ShopPO3Id,
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = Invoice2Id,
TotalAmount = 2400m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 31, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FulfilledAt = new DateTime(2026, 8, 31, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPOItem>().HasData(
new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem4Id, FK_ShopPOId = ShopPO3Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 20, QuantityFulfilled = 20, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0), IsActive = true }
);
modelBuilder.Entity<Invoice>().HasData(
new Invoice
{
Id = Invoice1Id,
FK_ShopPOId = ShopPO1Id,
Status = InvoiceStatus.Finalized,
PaymentStatus = PaymentStatus.Paid,
CreatedBy = MasterDataSeed.SalesRep1UserId,
PrintedAt = new DateTime(2026, 8, 25, 8, 45, 0),
PrintedBy = MasterDataSeed.SalesRep1UserId,
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0),
FinalizedBy = MasterDataSeed.Supervisor1UserId,
TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
IsActive = true
},
new Invoice
{
Id = Invoice2Id,
FK_ShopPOId = ShopPO3Id,
Status = InvoiceStatus.Printed,
PaymentStatus = PaymentStatus.PartiallyPaid,
CreatedBy = MasterDataSeed.SalesRep2UserId,
PrintedAt = new DateTime(2026, 8, 31, 9, 15, 0),
PrintedBy = MasterDataSeed.SalesRep2UserId,
FinalizedAt = null,
FinalizedBy = null,
TotalAmount = 2400m,
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
}
modelBuilder.Entity<OrderItem>().HasData(
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<LoadingSheet>().HasData(
@@ -151,19 +137,20 @@ public static class OperationsDataSeed
{
Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId,
Status = LoadingSheetStatus.Confirmed,
PopulatedBy = MasterDataSeed.Supervisor1UserId,
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Status = LoadingSheetStatus.ReceiptConfirmed,
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
}
);
modelBuilder.Entity<LoadingSheetItem>().HasData(
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 50m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true }
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
);
modelBuilder.Entity<DeliveryTask>().HasData(
@@ -171,7 +158,7 @@ public static class OperationsDataSeed
{
Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId,
FK_ShopPOId = ShopPO2Id,
FK_OrderId = Order2Id,
Sequence = 1,
Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false,
+26 -1
View File
@@ -27,9 +27,13 @@ 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");
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
@@ -43,7 +47,7 @@ public static class OperationsExtrasSeed
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
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 }
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 }
);
modelBuilder.Entity<Uom>().HasData(
@@ -81,10 +85,31 @@ 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 }
);
modelBuilder.Entity<RideCancelRequest>().HasData(
new RideCancelRequest
{
Id = RideCancelRequest1Id,
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
TypeCode = CancelRequestType.VehicleBreakdown,
Reason = "Reported minor engine issue, resolved after inspection",
RequestedBy = MasterDataSeed.Driver2UserId,
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
Status = CancelRequestStatus.Rejected,
ApprovedBy = MasterDataSeed.Supervisor1UserId,
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
}
);
modelBuilder.Entity<RouteVisitLog>().HasData(
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
+12 -181
View File
@@ -7,191 +7,22 @@ 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");
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");
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");
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 = 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 }
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 }
);
}
}
+10 -73
View File
@@ -1,5 +1,3 @@
using System.IdentityModel.Tokens.Jwt;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -8,93 +6,32 @@ namespace Distribution.Features.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : ControllerBase
public class AuthController(AuthService service, ICurrentUserService currentUser) : 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")]
[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 });
}
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[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()
public async Task<IActionResult> Me(CancellationToken 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));
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
}
[HttpPost("logout")]
[AllowAnonymous]
public IActionResult Logout()
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
{
Response.Cookies.Delete("access-token", new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
Secure = Request.IsHttps,
});
return Ok(new { success = true });
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
}
}
+2 -13
View File
@@ -1,16 +1,5 @@
namespace Distribution.Features.Auth;
public record LoginRequest(
string Identifier,
string Password,
Guid? UserTypeId = null,
string DeviceName = "Unknown Device");
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string email, string Password, string? FullName);
public record MeResponse(
Guid UserId,
string Username,
Guid RoleId,
string RoleCode,
int RoleLevel);
public record RegisterRequest(string Username, string Password, string? FullName);
+4 -8
View File
@@ -1,21 +1,17 @@
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.
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// </summary>
public class AuthService(IAuthHexClient authHex)
{
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", new
{
identifier = request.Identifier,
password = request.Password,
userTypeId = request.UserTypeId,
deviceName = request.DeviceName
}, reference: Guid.NewGuid().ToString(), ct: ct);
authHex.CallAsync("loginUser", request, 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);
@@ -0,0 +1,20 @@
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);
// }
}
@@ -0,0 +1,11 @@
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.
}
@@ -0,0 +1,16 @@
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,66 +0,0 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.BankAccount;
[ApiController]
[Route("api")]
public class BankAccountController(BankAccountService service) : ControllerBase
{
[HttpGet("bank-accounts")]
public async Task<IActionResult> List([FromQuery] BankAccountType? accountType)
{
var result = await service.ListAsync(accountType);
return Ok(result);
}
[HttpPost("bank-accounts")]
public async Task<IActionResult> CreateBank(CreateBankAccountRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateBankAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("cash-accounts")]
public async Task<IActionResult> CreateCash(CreateCashAccountRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateCashAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cash-accounts/{id:guid}")]
public async Task<IActionResult> UpdateCash(Guid id, UpdateCashAccountRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateCashAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("cash-accounts/{id:guid}/status")]
public async Task<IActionResult> SetCashStatus(Guid id, SetCashAccountStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.SetCashStatusAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("cash-accounts/{id:guid}")]
public async Task<IActionResult> DeleteCash(Guid id, CancellationToken ct)
{
try
{
var ok = await service.DeleteCashAsync(id, ct);
return ok ? NoContent() : NotFound();
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("cash-account-types")]
public async Task<IActionResult> ListCashAccountTypes() => Ok(await service.ListCashAccountTypesAsync());
}
@@ -1,33 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public record BankAccountResponse(
BankAccountType AccountType,
Guid AccountId,
string AccountName,
string? BankName,
string? CashAccountTypeName,
string? AccountNumber,
string CurrencyCode,
DateTime CreatedAt,
bool? IsActive,
bool? IsDefault);
public record CreateBankAccountRequest(
string AccountName,
string? BankName,
string? AccountNumber,
string? CurrencyCode);
public record CreateCashAccountRequest(
string AccountName,
string CashAccountTypeName,
string? AccountNumber,
string? CurrencyCode);
public record UpdateCashAccountRequest(string AccountName);
public record SetCashAccountStatusRequest(bool IsActive);
public record CashAccountTypeDto(string Name);
@@ -1,40 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.BankAccount;
public class BankAccountRepository(AppDBContext context) : IBankAccountRepository
{
public async Task<Models.BankAccount?> GetByIdAsync(Guid id) =>
await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == id);
public async Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType)
{
var query = context.BankAccounts.AsQueryable();
if (accountType is not null)
query = query.Where(a => a.AccountType == accountType);
return await query.OrderByDescending(a => a.CreatedAt).ToListAsync();
}
public async Task<List<string>> ListCashAccountTypeNamesAsync() =>
await context.BankAccounts
.Where(a => a.AccountType == BankAccountType.Cash && a.CashAccountTypeName != null)
.Select(a => a.CashAccountTypeName!)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
public async Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null) =>
await context.BankAccounts.AnyAsync(a =>
a.AccountType == type &&
a.AccountName == accountName &&
(excludeId == null || a.Id != excludeId));
public async Task AddAsync(Models.BankAccount account) =>
await context.BankAccounts.AddAsync(account);
public void Update(Models.BankAccount account) => context.BankAccounts.Update(account);
public void Remove(Models.BankAccount account) => context.BankAccounts.Remove(account);
}
@@ -1,116 +0,0 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public class BankAccountService(IBankAccountRepository repo, IUnitOfWork uow)
{
private const string DefaultCurrency = "LKR";
private static BankAccountResponse ToResponse(Models.BankAccount a) =>
new(
a.AccountType,
a.Id,
a.AccountName,
a.AccountType == BankAccountType.Bank ? a.BankName : null,
a.AccountType == BankAccountType.Cash ? a.CashAccountTypeName : null,
a.AccountNumber,
a.CurrencyCode,
a.CreatedAt,
a.AccountType == BankAccountType.Cash ? a.IsActive : null,
a.AccountType == BankAccountType.Cash ? a.IsDefault : null);
public async Task<List<BankAccountResponse>> ListAsync(BankAccountType? accountType) =>
(await repo.ListAsync(accountType)).Select(ToResponse).ToList();
public async Task<List<CashAccountTypeDto>> ListCashAccountTypesAsync() =>
(await repo.ListCashAccountTypeNamesAsync()).Select(n => new CashAccountTypeDto(n)).ToList();
public async Task<BankAccountResponse> CreateBankAsync(CreateBankAccountRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Bank, request.AccountName))
throw new InvalidOperationException("A bank account with this name already exists");
var account = new Models.BankAccount
{
AccountType = BankAccountType.Bank,
AccountName = request.AccountName,
BankName = request.BankName,
AccountNumber = request.AccountNumber,
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
IsActive = true,
IsDefault = false
};
await repo.AddAsync(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse> CreateCashAsync(CreateCashAccountRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (string.IsNullOrWhiteSpace(request.CashAccountTypeName))
throw new InvalidOperationException("CashAccountTypeName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName))
throw new InvalidOperationException("A cash account with this name already exists");
var account = new Models.BankAccount
{
AccountType = BankAccountType.Cash,
AccountName = request.AccountName,
CashAccountTypeName = request.CashAccountTypeName,
AccountNumber = request.AccountNumber,
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
IsActive = true,
IsDefault = false
};
await repo.AddAsync(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse?> UpdateCashAsync(Guid id, UpdateCashAccountRequest request, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return null;
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName, excludeId: id))
throw new InvalidOperationException("A cash account with this name already exists");
account.AccountName = request.AccountName;
repo.Update(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse?> SetCashStatusAsync(Guid id, SetCashAccountStatusRequest request, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return null;
if (account.IsDefault && !request.IsActive)
throw new InvalidOperationException("The default cash account cannot be deactivated");
account.IsActive = request.IsActive;
repo.Update(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<bool> DeleteCashAsync(Guid id, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return false;
if (account.IsDefault)
throw new InvalidOperationException("The default cash account cannot be deleted");
repo.Remove(account);
await uow.CommitAsync(ct);
return true;
}
}
@@ -1,14 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public interface IBankAccountRepository
{
Task<Models.BankAccount?> GetByIdAsync(Guid id);
Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType);
Task<List<string>> ListCashAccountTypeNamesAsync();
Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null);
Task AddAsync(Models.BankAccount account);
void Update(Models.BankAccount account);
void Remove(Models.BankAccount account);
}
@@ -1,55 +0,0 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ChequeBook;
[ApiController]
[Route("api")]
public class ChequeBookController(ChequeBookService service) : ControllerBase
{
[HttpGet("cheque-books")]
public async Task<IActionResult> List(
[FromQuery] Guid? bankAccountId,
[FromQuery] int? branchId,
[FromQuery] ChequeBookStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
=> Ok(await service.ListAsync(bankAccountId, branchId, status, page, pageSize));
[HttpGet("cheque-books/{chequeBookNo}")]
public async Task<IActionResult> Get(string chequeBookNo, [FromQuery] string? expand)
{
var expandPages = string.Equals(expand, "pages", StringComparison.OrdinalIgnoreCase);
var result = await service.GetAsync(chequeBookNo, expandPages);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("cheque-books")]
public async Task<IActionResult> Create(CreateChequeBookRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cheque-pages/{chequeNo}/issue")]
public async Task<IActionResult> Issue(string chequeNo, IssueChequePageRequest request, CancellationToken ct)
{
try
{
var result = await service.IssueAsync(chequeNo, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cheque-pages/{chequeNo}/status")]
public async Task<IActionResult> UpdateStatus(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdatePageStatusAsync(chequeNo, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,75 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public record ChequePageResponse(
Guid Id,
string ChequeNo,
string ChequeBookNo,
ChequePageIssueStatus IssueStatus,
PayeeType? PayeeType,
int? PayeeId,
string? PayeeName,
DateTime? IssueDate,
decimal? Amount,
string? CurrencyCode,
int? VoucherId,
string? ReferenceNo,
string? Purpose,
bool? IsCrossCheque,
bool? IsAccountPayee,
bool? IsPostDated,
string? Notes,
string? PrintedBy,
DateTime? PrintedAt,
DateTime? ClearedDate,
bool? ClearedByBank,
string? CancelReason);
public record ChequeBookResponse(
string ChequeBookNo,
int BranchId,
Guid BankAccountId,
string StartChequeNo,
string EndChequeNo,
int TotalLeaves,
DateTime ReceivedDate,
string? Description,
string? CreatedBy,
ChequeBookStatus Status,
List<ChequePageResponse> Pages);
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int? Page, int? PageSize);
public record CreateChequeBookRequest(
int BranchId,
Guid BankAccountId,
string ChequeBookNo,
string StartChequeNo,
string EndChequeNo,
int TotalLeaves,
DateTime ReceivedDate,
string? Description,
string? CreatedBy);
public record IssueChequePageRequest(
PayeeType PayeeType,
int? PayeeId,
string PayeeName,
DateTime IssueDate,
decimal Amount,
string? CurrencyCode,
int? VoucherId,
string? ReferenceNo,
string? Purpose,
bool? IsCrossCheque,
bool? IsAccountPayee,
bool? IsPostDated,
string? Notes,
string? PrintedBy);
public record UpdateChequePageStatusRequest(
ChequePageStatusAction Action,
DateTime? ClearedDate,
string? CancelReason,
string? PerformedBy);
@@ -1,46 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ChequeBook;
public class ChequeBookRepository(AppDBContext context) : IChequeBookRepository
{
public async Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages)
{
var query = context.ChequeBooks.AsQueryable();
if (includePages) query = query.Include(b => b.Pages);
return await query.FirstOrDefaultAsync(b => b.ChequeBookNo == chequeBookNo);
}
public async Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
{
var query = context.ChequeBooks.AsQueryable();
if (bankAccountId is not null) query = query.Where(b => b.FK_BankAccountId == bankAccountId);
if (branchId is not null) query = query.Where(b => b.BranchId == branchId);
if (status is not null) query = query.Where(b => b.Status == status);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<bool> BookNoExistsAsync(string chequeBookNo) =>
await context.ChequeBooks.AnyAsync(b => b.ChequeBookNo == chequeBookNo);
public async Task AddAsync(Models.ChequeBook book) => await context.ChequeBooks.AddAsync(book);
public void Update(Models.ChequeBook book) => context.ChequeBooks.Update(book);
public async Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo) =>
await context.ChequePages
.Include(p => p.ChequeBook)
.FirstOrDefaultAsync(p => p.ChequeNo == chequeNo);
public void UpdatePage(Models.ChequePage page) => context.ChequePages.Update(page);
}
@@ -1,176 +0,0 @@
using Distribution.Features.BankAccount;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public class ChequeBookService(
IChequeBookRepository repo,
IBankAccountRepository bankAccountRepo,
IUnitOfWork uow)
{
private const string DefaultCurrency = "LKR";
private static ChequePageResponse ToPageResponse(Models.ChequePage p, string chequeBookNo) =>
new(p.Id, p.ChequeNo, chequeBookNo, p.IssueStatus, p.PayeeType, p.PayeeId, p.PayeeName,
p.IssueDate, p.Amount, p.CurrencyCode, p.VoucherId, p.ReferenceNo, p.Purpose,
p.IsCrossCheque, p.IsAccountPayee, p.IsPostDated, p.Notes,
p.PrintedBy, p.PrintedAt, p.ClearedDate, p.ClearedByBank, p.CancelReason);
private static ChequeBookResponse ToBookResponse(Models.ChequeBook b, bool includePages) =>
new(b.ChequeBookNo, b.BranchId, b.FK_BankAccountId, b.StartChequeNo, b.EndChequeNo,
b.TotalLeaves, b.ReceivedDate, b.Description, b.CreatedBy, b.Status,
includePages ? b.Pages.OrderBy(p => p.ChequeNo).Select(p => ToPageResponse(p, b.ChequeBookNo)).ToList() : new());
public async Task<PagedResult<ChequeBookResponse>> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 50;
var (items, total) = await repo.ListAsync(bankAccountId, branchId, status, page, pageSize);
return new PagedResult<ChequeBookResponse>(
items.Select(b => ToBookResponse(b, includePages: false)).ToList(),
total, page, pageSize);
}
public async Task<ChequeBookResponse?> GetAsync(string chequeBookNo, bool expandPages)
{
var book = await repo.GetByNoAsync(chequeBookNo, includePages: expandPages);
return book is null ? null : ToBookResponse(book, includePages: expandPages);
}
public async Task<ChequeBookResponse> CreateAsync(CreateChequeBookRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.ChequeBookNo))
throw new InvalidOperationException("ChequeBookNo is required");
if (request.TotalLeaves <= 0)
throw new InvalidOperationException("TotalLeaves must be greater than zero");
if (!long.TryParse(request.StartChequeNo, out var start) ||
!long.TryParse(request.EndChequeNo, out var end))
throw new InvalidOperationException("StartChequeNo and EndChequeNo must be numeric");
if (end < start)
throw new InvalidOperationException("EndChequeNo must be greater than or equal to StartChequeNo");
if (end - start + 1 != request.TotalLeaves)
throw new InvalidOperationException("TotalLeaves does not match the Start/End cheque range");
if (await repo.BookNoExistsAsync(request.ChequeBookNo))
throw new InvalidOperationException("A cheque book with this number already exists");
var bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId);
if (bankAccount is null || bankAccount.AccountType != BankAccountType.Bank)
throw new InvalidOperationException("BankAccountId does not reference a valid bank account");
var book = new Models.ChequeBook
{
ChequeBookNo = request.ChequeBookNo,
BranchId = request.BranchId,
FK_BankAccountId = request.BankAccountId,
StartChequeNo = request.StartChequeNo,
EndChequeNo = request.EndChequeNo,
TotalLeaves = request.TotalLeaves,
ReceivedDate = request.ReceivedDate,
Description = request.Description,
CreatedBy = request.CreatedBy,
Status = ChequeBookStatus.Active
};
var width = request.StartChequeNo.Length;
for (var n = start; n <= end; n++)
{
book.Pages.Add(new Models.ChequePage
{
ChequeNo = n.ToString().PadLeft(width, '0'),
IssueStatus = ChequePageIssueStatus.Unused
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(book);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToBookResponse(book, includePages: true);
}
public async Task<ChequePageResponse?> IssueAsync(string chequeNo, IssueChequePageRequest request, CancellationToken ct = default)
{
var page = await repo.GetPageByNoAsync(chequeNo);
if (page is null) return null;
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
throw new InvalidOperationException("The cheque book is not active");
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
if (string.IsNullOrWhiteSpace(request.PayeeName))
throw new InvalidOperationException("PayeeName is required");
page.IssueStatus = ChequePageIssueStatus.Issued;
page.PayeeType = request.PayeeType;
page.PayeeId = request.PayeeId;
page.PayeeName = request.PayeeName;
page.IssueDate = request.IssueDate;
page.Amount = request.Amount;
page.CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode;
page.VoucherId = request.VoucherId;
page.ReferenceNo = request.ReferenceNo;
page.Purpose = request.Purpose;
page.IsCrossCheque = request.IsCrossCheque;
page.IsAccountPayee = request.IsAccountPayee;
page.IsPostDated = request.IsPostDated;
page.Notes = request.Notes;
page.PrintedBy = request.PrintedBy;
page.PrintedAt = DateTime.Now;
repo.UpdatePage(page);
await uow.CommitAsync(ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
public async Task<ChequePageResponse?> UpdatePageStatusAsync(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct = default)
{
var page = await repo.GetPageByNoAsync(chequeNo);
if (page is null) return null;
switch (request.Action)
{
case ChequePageStatusAction.Clear:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be cleared");
if (request.ClearedDate is null)
throw new InvalidOperationException("ClearedDate is required when clearing a cheque");
page.IssueStatus = ChequePageIssueStatus.Cleared;
page.ClearedDate = request.ClearedDate;
page.ClearedByBank = true;
break;
case ChequePageStatusAction.Bounce:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be bounced");
page.IssueStatus = ChequePageIssueStatus.Bounced;
break;
case ChequePageStatusAction.Cancel:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be cancelled");
if (string.IsNullOrWhiteSpace(request.CancelReason))
throw new InvalidOperationException("CancelReason is required when cancelling a cheque");
page.IssueStatus = ChequePageIssueStatus.Cancelled;
page.CancelReason = request.CancelReason;
break;
case ChequePageStatusAction.Void:
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException("Only unused cheques can be voided");
page.IssueStatus = ChequePageIssueStatus.Void;
break;
}
repo.UpdatePage(page);
await uow.CommitAsync(ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
}
@@ -1,16 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public interface IChequeBookRepository
{
Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages);
Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize);
Task<bool> BookNoExistsAsync(string chequeBookNo);
Task AddAsync(Models.ChequeBook book);
void Update(Models.ChequeBook book);
Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo);
void UpdatePage(Models.ChequePage page);
}
@@ -1,5 +1,4 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
@@ -8,12 +7,10 @@ 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);
@@ -21,7 +18,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims")]
[Authorize("claim.manage")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
@@ -33,7 +29,6 @@ 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
@@ -45,7 +40,6 @@ 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,28 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DashboardReport;
[ApiController]
[Route("api/reports/dashboard")]
public class DashboardReportController(DashboardReportService service) : ControllerBase
{
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
}
[HttpGet("inventory")]
public async Task<IActionResult> Inventory(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
}
}
@@ -1,38 +0,0 @@
namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesKpis(
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
public record TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity,
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount);
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
public record InventorySummaryResponse(
DateTime From, DateTime To, string Granularity,
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
@@ -1,226 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DashboardReport;
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
{
private sealed class SoldLine
{
public DateTime Date { get; set; }
public Guid PoId { get; set; }
public decimal Value { get; set; }
public decimal? Cost { get; set; }
public Guid CategoryId { get; set; }
public string CategoryName { get; set; } = "";
public Guid RegionId { get; set; }
public string RegionName { get; set; } = "";
public Guid RepId { get; set; }
}
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q =
from li in db.InvoiceItems.AsNoTracking()
where li.IsActive && !li.IsRemoved
&& li.Invoice.Status == InvoiceStatus.Finalized
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
select new SoldLine
{
Date = li.Invoice.FinalizedAt!.Value.Date,
PoId = li.Invoice.FK_ShopPOId,
Value = li.Quantity * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name,
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
RepId = li.Invoice.ShopPO.RaisedById,
};
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
return q;
}
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
var q = db.SaleReturnItems.AsNoTracking()
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
{
var lines = SoldLines(f);
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
var costed = lines.Where(l => l.Cost != null);
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
}
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
{
if (f.CategoryId.HasValue) return 0m;
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.Invoices.AsNoTracking()
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
&& i.FinalizedAt >= start && i.FinalizedAt < end);
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
}
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
private const int OverdueDays = 30;
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
if (f.RegionId.HasValue)
{
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
}
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
var rows = await invs.Select(i => new
{
i.FinalizedAt,
i.TotalAmount,
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
.Sum(p => (decimal?)p.Amount) ?? 0m,
}).ToListAsync();
var balances = rows
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
.ToList();
return new ReceivablesTotals(collected,
balances.Sum(b => b.Balance),
balances.Where(b => b.Due < end).Sum(b => b.Balance));
}
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => l.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
{
var rows = await SoldLines(f).GroupBy(l => l.RepId)
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
var ids = rows.Select(r => r.RepId).ToList();
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName);
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
}
private IQueryable<StockBalance> Balances(DashboardFilter f)
{
var q = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
// Value = quantity x cost price; items without a cost price contribute quantity only.
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
(await Balances(f).GroupBy(b => b.Item.Category.Name)
.Select(g => new
{
Category = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.OrderByDescending(x => x.Value).ToListAsync())
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
{
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
.GroupBy(b => b.LocationId)
.Select(g => new
{
Id = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.ToListAsync();
var ids = rows.Select(r => r.Id).ToList();
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, w => w.Name);
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
.OrderByDescending(r => r.Value).ToList();
}
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.StockMovements.AsNoTracking()
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
if (f.WarehouseId.HasValue)
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
.ToListAsync();
return rows.GroupBy(r => r.Date)
.Select(g => (g.Key,
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
.ToList();
}
}
@@ -1,92 +0,0 @@
namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo)
{
public const int MaxRangeDays = 366;
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
{
var end = (to ?? DateTime.Now).Date;
var start = (from ?? end.AddDays(-29)).Date;
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
}
public static string? Validate(DashboardFilter f)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
return null;
}
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
private static SalesKpis ToKpis(SalesTotals t)
{
var net = t.GrossSales - t.ReturnValue - t.Discounts;
var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis(
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
}
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
new(r.Collected, r.Outstanding, r.Overdue,
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
{
var days = (f.To - f.From).Days + 1;
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
var daily = Daily(f);
var current = await repo.GetSalesTotalsAsync(f);
var previous = await repo.GetSalesTotalsAsync(prev);
var currentCash = await repo.GetReceivablesAsync(f);
var previousCash = await repo.GetReceivablesAsync(prev);
var sales = await repo.GetDailySalesAsync(f);
var returns = await repo.GetDailyReturnsAsync(f);
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
.GroupBy(x => Bucket(x.Date, daily))
.OrderBy(g => g.Key)
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
.ToList();
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month",
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
new CashKpiComparison(
ToCash(currentCash, current.GrossSales - current.Discounts),
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
trend,
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
}
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
{
var daily = Daily(f);
var movements = (await repo.GetDailyMovementsAsync(f))
.GroupBy(m => Bucket(m.Date, daily))
.OrderBy(g => g.Key)
.Select(g =>
{
var rec = g.Sum(x => x.Received);
var iss = g.Sum(x => x.Issued);
return new MovementPoint(g.Key, rec, iss, rec - iss);
}).ToList();
return new InventorySummaryResponse(
f.From, f.To, daily ? "day" : "month",
await repo.GetStockValueByCategoryAsync(f),
await repo.GetStockByWarehouseAsync(f),
movements);
}
}
@@ -1,17 +0,0 @@
namespace Distribution.Features.DashboardReport;
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
public interface IDashboardReportRepository
{
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
}
@@ -1,16 +1,12 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
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
@@ -24,26 +20,13 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
[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);
@@ -51,7 +34,6 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
@@ -64,12 +46,11 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -77,7 +58,6 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_ShopPOId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -8,48 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.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) =>
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
@@ -1,7 +1,6 @@
using Distribution.Features.LoadingEvent;
using Distribution.Features.Notifications;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -11,33 +10,23 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo,
StockMover mover,
INotificationService notifications,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private const string DeliveriesLink = "/operations/deliveries";
// Route owner + managers whose scope covers the store being delivered to.
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
[
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
];
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_ShopPOId,
t.ShopPO.FK_StoreId,
t.ShopPO.Store?.StoreName ?? string.Empty,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
@@ -50,43 +39,43 @@ public class DeliveryTaskService(
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");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
var shopPOIds = confirmedSheets
.SelectMany(s => s.Orders)
.Select(o => o.FK_ShopPOId)
.Distinct()
.ToList();
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var existingTasks = await repo.GetByRideIdAsync(rideId);
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var newTasks = new List<DeliveryTask>();
foreach (var shopPOId in shopPOIds)
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
newTasks.Add(new DeliveryTask
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_ShopPOId = shopPOId,
Sequence = nextSequence++,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
});
};
created.Add(task);
}
if (newTasks.Count > 0)
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
await uow.BeginAsync(ct);
try
{
foreach (var task in newTasks) await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
foreach (var task in created)
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();
@@ -98,13 +87,6 @@ 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);
@@ -121,16 +103,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items)
{
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > shopPOItem.Quantity)
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
@@ -145,7 +127,7 @@ public class DeliveryTaskService(
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default)
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
@@ -153,7 +135,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0))
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
@@ -161,44 +143,63 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var shopPO = task.ShopPO;
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
shopPO.FulfilledAt = DateTime.Now;
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0;
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
await mover.MoveAsync(item.FK_ItemId, itemName,
vehicleId, LocationType.Vehicle,
StockService.ExternalLocationId, LocationType.External,
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
if (availableQuantity < item.QuantityFulfilled)
throw new InvalidOperationException(
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
vehicleBalance!.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
shopPORepo.Update(shopPO);
orderRepo.Update(order);
if (fullyDelivered && order.FK_SaleId is Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
if (!fullyDelivered && shopPO.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskPartiallyDelivered,
DeliveryRecipients(shopPO.FK_StoreId),
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
ct);
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
@@ -222,13 +223,6 @@ public class DeliveryTaskService(
}
catch { await uow.RollbackAsync(ct); throw; }
if (task.ShopPO?.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskFailed,
DeliveryRecipients(task.ShopPO.FK_StoreId),
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
ct);
return ToResponse(task);
}
}
@@ -6,8 +6,6 @@ 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,6 +1,5 @@
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
@@ -13,7 +12,6 @@ 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);
@@ -21,7 +19,6 @@ public class DriverProfileController(
}
[HttpGet("{id:guid}")]
[Authorize("driver.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -29,7 +26,6 @@ public class DriverProfileController(
}
[HttpPost]
[Authorize("driver.manage")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
try
@@ -48,7 +44,6 @@ 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);
@@ -56,7 +51,6 @@ 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);
@@ -64,7 +58,6 @@ public class DriverProfileController(
}
[HttpGet("compliance")]
[Authorize("driver.view")]
public async Task<IActionResult> GetCompliance()
{
var result = await complianceService.GetAsync();
@@ -72,7 +65,6 @@ public class DriverProfileController(
}
[HttpPut("compliance")]
[Authorize("driver.manage")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+2 -24
View File
@@ -1,5 +1,4 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
@@ -8,7 +7,6 @@ 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,
@@ -19,7 +17,6 @@ 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);
@@ -27,7 +24,6 @@ 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
@@ -36,36 +32,18 @@ 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,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
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;
+7 -76
View File
@@ -1,104 +1,35 @@
namespace Distribution.Features.Grn;
/// <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 GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
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,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
Guid? ReceivedByUserId = 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,
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);
Guid? ReceivedByUserId = null);
public record GrnItemResponse(
Guid Id,
Guid? PurchaseOrderItemId,
Guid PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
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);
int RejectedQuantity);
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,
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);
List<GrnItemResponse> Items);
-37
View File
@@ -1,37 +0,0 @@
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.");
}
}
+5 -17
View File
@@ -8,21 +8,13 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.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)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.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.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)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
@@ -37,11 +29,7 @@ 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.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)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
@@ -55,8 +43,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);
}
+41 -382
View File
@@ -1,6 +1,5 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -10,88 +9,15 @@ public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
IUnitOfWork uow)
{
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 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);
/// <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}");
}
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());
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
@@ -110,61 +36,10 @@ 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 + 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.
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), 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)
{
@@ -181,63 +56,37 @@ 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,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
ReceivedAt = DateTime.Now
};
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 || line.FreeQuantity < 0)
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 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;
var entity = new Models.GoodsReceiptItem
goodsReceipt.Items.Add(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();
@@ -245,238 +94,48 @@ 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 (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
{
if (item.TracksSerialNumbers)
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)
{
foreach (var serialNumber in line.SerialNumbers!)
await stockRepo.AddBalanceAsync(new StockBalance
{
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
});
}
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
}
else
{
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 = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = StockQty(line),
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
balance.Quantity += line.AcceptedQuantity;
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
});
}
if (po is not null) poRepo.Update(po);
poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -1,20 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
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, 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<decimal> GetSchemeDiscountAsync(Guid shopPOId);
Task AddAsync(Models.Invoice invoice);
Task<Models.Item?> GetItemAsync(Guid itemId);
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
Task AddVersionAsync(InvoiceVersion version);
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
void Update(Models.Invoice invoice);
Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.Invoice entity);
void Update(Models.Invoice entity);
}
@@ -1,34 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service, ICurrentUserService currentUser) : ControllerBase
public class InvoiceController(InvoiceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[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, paymentStatus, page, pageSize);
return Ok(result);
}
[HttpGet("outstanding-summary")]
public async Task<IActionResult> GetOutstandingSummary()
{
var result = await service.GetOutstandingSummaryAsync();
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
@@ -37,80 +14,35 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
public async Task<IActionResult> Create(CreateInvoiceRequest 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.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/preview-totals")]
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
{
var result = await service.PreviewTotalsAsync(id, request);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id)
{
var result = await service.GetVersionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest 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.EditedByUserId;
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
try
{
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
public async Task<IActionResult> Print(Guid id, 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);
var result = await service.PrintAsync(id, ct);
return result is null ? NotFound() : 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)
public async Task<IActionResult> Finalize(Guid id, 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);
var result = await service.FinalizeAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
+13 -50
View File
@@ -1,66 +1,29 @@
using Distribution.Features.ShopPO;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
public record CreateInvoiceRequest(Guid OrderId);
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
public record FinalizeInvoiceItemRequest(Guid OrderItemId, decimal DeliveredQuantity, decimal FreeQuantity);
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
// Quantity = how many free units the scheme still entitles for the lines as typed.
public record FreeEntitlement(Guid SchemeId, int Quantity);
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
public record FinalizeInvoiceReturnRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
public record FinalizeInvoiceDiscountRequest(DiscountType Type, decimal Value, string? Reason);
public record InvoiceVersionResponse(
Guid Id,
int VersionNumber,
InvoiceVersionStage Stage,
decimal TotalAmount,
Guid CreatedBy,
DateTime CreatedAt,
List<InvoiceVersionItemResponse> Items);
public record InvoiceItemResponse(
Guid Id,
Guid? ShopPOItemId,
Guid ItemId,
string ItemName,
int? OrderedQuantity,
int Quantity,
decimal UnitPrice,
bool IsRemoved,
bool IsFree = false,
Guid? SchemeId = null,
string? SchemeName = null);
public record FinalizeInvoiceRequest(
List<FinalizeInvoiceItemRequest> Items,
List<FinalizeInvoiceReturnRequest>? Returns,
FinalizeInvoiceDiscountRequest? Discount);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
Guid StoreId,
string StoreName,
Guid OrderId,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
Guid CreatedBy,
Guid CreatedByUserId,
DateTime? PrintedAt,
Guid? PrintedBy,
Guid? PrintedByUserId,
DateTime? FinalizedAt,
Guid? FinalizedBy,
Guid? FinalizedByUserId,
decimal TotalAmount,
decimal DiscountAmount,
decimal PaidAmount,
bool IsActive,
DateTime CreatedAt,
List<InvoiceItemResponse> Items,
List<ShopPODiscountResponse>? Discounts = null);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
DateTime CreatedAt);
@@ -1,5 +1,4 @@
using Distribution.Data;
using Distribution.Models;
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
@@ -7,79 +6,14 @@ namespace Distribution.Features.Invoice;
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.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
await context.Invoices.FindAsync(id);
public async Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId) =>
await context.Invoices
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId) =>
await context.Invoices.FirstOrDefaultAsync(i => i.FK_OrderId == orderId && i.IsActive);
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)
.Include(i => i.Payments.Where(p => p.IsActive))
.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)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task AddAsync(Models.Invoice entity) =>
await context.Invoices.AddAsync(entity);
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)
&& (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<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
await context.SchemeRedemptions
.Where(r => r.FK_ShopPOId == shopPOId)
.SumAsync(r => r.DiscountAmount) ?? 0m;
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
await context.InvoiceItems.AddRangeAsync(items);
public async Task AddVersionAsync(InvoiceVersion version) =>
await context.InvoiceVersions.AddAsync(version);
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Where(v => v.FK_InvoiceId == invoiceId)
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Include(v => v.Items).ThenInclude(i => i.Item)
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
.OrderBy(v => v.VersionNumber)
.ToListAsync();
public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice);
public void Update(Models.Invoice entity) =>
context.Invoices.Update(entity);
}
+152 -294
View File
@@ -1,344 +1,202 @@
using Distribution.Features.Scheme;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Returns;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IShopPORepository shopPoRepo,
SchemeService schemeService,
IOrderRepository orderRepo,
IReturnRepository returnRepo,
IItemRepository itemRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
ICurrentUserService currentUser,
IConfiguration configuration)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
i.ShopPO?.FK_StoreId ?? Guid.Empty,
i.ShopPO?.Store?.StoreName ?? string.Empty,
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.DiscountAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
i.IsActive, i.CreatedAt,
i.Items.Where(x => x.IsActive).Select(x =>
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
new(i.Id, i.FK_OrderId, i.Status, i.CreatedByUserId, i.PrintedAt, i.PrintedByUserId,
i.FinalizedAt, i.FinalizedByUserId, i.TotalAmount, i.IsActive, i.CreatedAt);
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
// scheme rules ignore them.
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved)
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
.ToList();
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
// and at print/finalize. The redemption amount is updated with it.
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
{
var lines = items.ToList();
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
var billable = BillableTotal(lines);
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
invoice.DiscountAmount = Math.Min(discount, billable);
invoice.TotalAmount = billable - invoice.DiscountAmount;
}
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
var lines = request.Items.Where(l => !l.IsRemoved)
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
.ToList();
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
var chosen = request.DiscountSchemeIds is null
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
var discount = Math.Min(chosen, billable);
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
}
// Immutable copy of the invoice lines as they stand right now.
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new()
{
FK_InvoiceId = invoice.Id,
VersionNumber = versionNumber,
Stage = stage,
TotalAmount = invoice.TotalAmount,
CreatedBy = userId,
Items = items.Select(i => new InvoiceVersionItem
{
FK_ShopPOItemId = i.FK_ShopPOItemId,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsRemoved = i.IsRemoved
}).ToList()
};
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
{
if (await repo.GetByIdAsync(id) is null) return null;
var versions = await repo.GetVersionsAsync(id);
return versions.Select(v => new InvoiceVersionResponse(
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
v.Items.Select(i => new InvoiceVersionItemResponse(
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
}
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
{
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
// Added lines may or may not be in invoice.Items yet, so merge by reference.
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
}
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
{
FK_InvoiceId = invoiceId,
FK_ShopPOItemId = i.Id,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsFree = i.IsFree,
FK_SchemeId = i.FK_SchemeId,
SchemeName = i.SchemeName
}).ToList();
private bool IsModeB =>
configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") != "A";
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
/// <summary>Mode B only — creates the Invoice (Draft) for an already-Confirmed PO as a
/// separate action. Mode A creates the Invoice atomically via
/// OrderService.ConfirmAndCreateInvoiceAsync instead.</summary>
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, CancellationToken ct = default)
{
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());
}
if (!IsModeB)
throw new InvalidOperationException("POST /api/invoices is not enabled — this system uses InvoiceCreationMode A, which creates the Invoice as part of PO confirmation.");
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> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
{
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is null)
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
if (po.InvoiceId is not null)
throw new InvalidOperationException("An invoice already exists for this Shop PO");
var order = await orderRepo.GetByIdAsync(request.OrderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.ConfirmedAt is null)
throw new InvalidOperationException("Only a Confirmed PO can have an Invoice created for it.");
if (order.InvoiceId is not null)
throw new InvalidOperationException("An Invoice already exists for this PO.");
var invoice = new Models.Invoice
{
FK_ShopPOId = po.Id,
FK_OrderId = order.Id,
Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending,
CreatedBy = createdBy
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
TotalAmount = order.TotalAmount,
};
var lines = BuildItemsFromShopPO(po, invoice.Id);
await ApplyTotalsAsync(invoice, lines);
order.InvoiceId = invoice.Id;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(invoice);
await repo.AddItemsAsync(lines);
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
po.InvoiceId = invoice.Id;
shopPoRepo.Update(po);
orderRepo.Update(order);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(invoice.Id);
return ToResponse(saved!);
}
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
var newLines = new List<InvoiceItem>();
foreach (var edit in request.Items)
catch
{
if (edit.UnitPrice is < 0)
throw new InvalidOperationException("Unit price cannot be negative");
if (edit.InvoiceItemId is Guid lineId)
{
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
item.IsRemoved = edit.IsRemoved;
if (!edit.IsRemoved)
{
ValidateQuantity(item, edit.Quantity);
item.Quantity = edit.Quantity;
}
if (edit.UnitPrice is decimal price)
{
if (item.IsFree && price != 0m)
throw new InvalidOperationException("A free-issue line must stay at zero price");
item.UnitPrice = price;
}
}
else if (edit.ItemId is Guid itemId)
{
// New line for an item that was not on the PO.
if (edit.IsRemoved) continue;
if (edit.Quantity <= 0)
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
if (edit.UnitPrice is null)
throw new InvalidOperationException("Unit price is required for an added item");
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
var catalogItem = await repo.GetItemAsync(itemId)
?? throw new InvalidOperationException($"Item {itemId} not found");
newLines.Add(new InvoiceItem
{
FK_InvoiceId = invoice.Id,
FK_ItemId = catalogItem.Id,
Item = catalogItem,
Quantity = edit.Quantity,
UnitPrice = edit.UnitPrice.Value
});
}
else
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
await uow.RollbackAsync(ct);
throw;
}
var allLines = invoice.Items.Concat(newLines).ToList();
await schemeService.ApplyInvoiceDiscountSelectionAsync(
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
await ApplyTotalsAsync(invoice, allLines);
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
private static void ValidateQuantity(InvoiceItem item, int quantity)
{
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
if (quantity < 0 || quantity > ordered)
throw new InvalidOperationException(
$"Quantity for item {item.Id} must be between 0 and {ordered}");
}
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");
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
var lines = invoice.Items.Where(i => i.IsActive).ToList();
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedBy = printedBy;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
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)
/// <summary>Draft → Printed. The agent takes the physical copy to the field and may make
/// handwritten edits during the ride.</summary>
public async Task<InvoiceResponse?> PrintAsync(Guid id, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
var invoice = await repo.GetByIdAsync(id);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException("Only a Draft invoice can be printed.");
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var items = invoice.Items.Where(i => i.IsActive).ToList();
var adjustments = request.Adjustments ?? [];
// Agent's handwritten edits from the printed copy are written onto the invoice lines.
foreach (var adjustment in adjustments)
{
var item = items.FirstOrDefault(i => i.Id == adjustment.InvoiceItemId)
?? throw new InvalidOperationException($"Invoice item {adjustment.InvoiceItemId} is not on this invoice");
item.IsRemoved = adjustment.IsRemoved;
if (!adjustment.IsRemoved)
{
ValidateQuantity(item, adjustment.AdjustedQuantity);
item.Quantity = adjustment.AdjustedQuantity;
}
}
if (items.Count > 0)
await ApplyTotalsAsync(invoice, items);
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedBy = finalizedBy;
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedByUserId = currentUser.UserId;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
/// <summary>Printed → Finalized. The office worker enters whatever the agent handwrote on the
/// returned printed copy — delivered/free quantities per line, returns/damage (reusing the
/// SaleReturn/CreditNote shape from 18_returns_claims.md), and an optional invoice-level
/// discount. Payload shape resolved in order_invoice_lifecycle.md Open Question 6.</summary>
public async Task<InvoiceResponse?> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException("An invoice can only be finalized from Printed — Draft → Finalized directly is not a valid transition.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("Finalize must include at least one line item.");
var order = await orderRepo.GetByIdAsync(invoice.FK_OrderId)
?? throw new InvalidOperationException("Order not found for this Invoice.");
var orderItems = (await orderRepo.GetItemsByOrderIdAsync(order.Id)).ToDictionary(oi => oi.Id);
decimal subtotal = 0;
foreach (var line in request.Items)
{
if (!orderItems.TryGetValue(line.OrderItemId, out var orderItem))
throw new InvalidOperationException($"OrderItem {line.OrderItemId} does not belong to this Invoice's PO.");
if (line.DeliveredQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Delivered/free quantity cannot be negative.");
subtotal += line.DeliveredQuantity * orderItem.UnitPrice; // freeQuantity never bills
orderItem.QuantityFulfilled = (int)(line.DeliveredQuantity + line.FreeQuantity);
}
Models.SaleReturn? saleReturn = null;
decimal creditNoteAmount = 0;
if (request.Returns is { Count: > 0 })
{
saleReturn = new Models.SaleReturn
{
FK_OrderId = order.Id,
Mode = ReturnMode.CreditDeduction,
CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
};
foreach (var line in request.Returns)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var matchingOrderItem = orderItems.Values.FirstOrDefault(oi => oi.FK_ItemId == line.ItemId);
var unitPrice = matchingOrderItem?.UnitPrice
?? (await itemRepo.GetByIdAsync(line.ItemId))?.SalePrice
?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice,
});
}
creditNoteAmount = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = creditNoteAmount;
}
decimal discountAmount = request.Discount switch
{
{ Type: DiscountType.Amount } d => d.Value,
{ Type: DiscountType.Percentage } d => subtotal * d.Value / 100m,
null => 0,
_ => 0,
};
invoice.TotalAmount = Math.Max(0, subtotal - discountAmount - creditNoteAmount);
invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now;
invoice.FinalizedByUserId = currentUser.UserId;
order.TotalAmount = invoice.TotalAmount;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
orderRepo.Update(order);
if (saleReturn is not null)
{
await returnRepo.AddAsync(saleReturn);
await returnRepo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = creditNoteAmount,
});
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(invoice);
}
+2 -30
View File
@@ -11,14 +11,7 @@ public record CreateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
List<UomConversionRequest>? Conversions,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
List<UomConversionRequest>? Conversions);
public record UpdateItemRequest(
string Name,
@@ -28,14 +21,7 @@ public record UpdateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
bool IsActive = true,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
bool IsActive = true);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
@@ -54,13 +40,6 @@ 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);
@@ -79,13 +58,6 @@ 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,
+3 -61
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Item;
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
public class ItemService(IItemRepository repo, IUnitOfWork uow)
{
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,10 +13,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt);
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description,
@@ -24,44 +21,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt,
i.SalePrice, 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)
@@ -79,9 +41,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
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,
@@ -92,13 +51,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
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
};
@@ -125,9 +77,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
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;
@@ -135,13 +84,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
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,12 +6,11 @@ public interface ILoadingSheetRepository
{
Task<LoadingSheet?> GetByIdAsync(Guid id);
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
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<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
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,47 +1,25 @@
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,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
public class LoadingSheetController(LoadingSheetService service, 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)
{
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);
var result = await service.GetAllAsync(rideId, status, 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);
@@ -49,16 +27,11 @@ public class LoadingSheetController(
}
[HttpPost]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest 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.PopulatedByUserId;
if (userId is null) return BadRequest(new { error = "PopulatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -66,7 +39,6 @@ public class LoadingSheetController(
}
[HttpPut("{id:guid}/items")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -78,17 +50,90 @@ public class LoadingSheetController(
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
[HttpPost("{id:guid}/confirm-dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, 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" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmAsync(id, userId.Value, ct);
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetReceiptItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-receipt")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ResolveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -5,32 +5,37 @@ namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
Guid PopulatedBy,
DateTime PopulatedAt,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items,
List<LoadingSheetOrderResponse> Orders);
List<LoadingSheetItemResponse> Items);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
Guid? FK_ShopPOItemId,
Guid? ShopPOId = null,
Guid? StoreId = null,
string? StoreName = null);
decimal? QuantityReceived,
decimal? QuantityResolved);
public record LoadingSheetOrderResponse(Guid ShopPOId);
public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null);
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -8,39 +8,46 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
{
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.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);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
.FirstOrDefaultAsync(s =>
s.FK_RideId == rideId &&
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) =>
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, 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)
.Include(s => s.SourceWarehouse)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(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)))
(status == null || s.Status == status))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId) =>
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
await context.LoadingSheets
.CountAsync(s =>
(rideId == null || s.FK_RideId == rideId) &&
(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)));
(status == null || s.Status == status));
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
@@ -53,25 +60,4 @@ 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
}));
}
}
@@ -8,13 +8,13 @@ namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService(
ILoadingSheetRepository repo,
IRideRepository rideRepo,
StockMover mover,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(rideId, status, routeId, regionId);
var items = await repo.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
var total = await repo.GetCountAsync(rideId, status);
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -24,7 +24,7 @@ public class LoadingSheetService(
return sheet is null ? null : ToResponse(sheet);
}
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -34,16 +34,22 @@ public class LoadingSheetService(
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has a Planned loading sheet");
throw new InvalidOperationException("This ride already has an active loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
Status = LoadingSheetStatus.Planned,
PopulatedBy = userId,
PopulatedAt = DateTime.Now
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
};
if (ride.RideType == RideType.Delivery)
{
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
foreach (var (itemId, qty) in orderItems)
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
}
await uow.BeginAsync(ct);
try
{
@@ -61,57 +67,19 @@ public class LoadingSheetService(
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
// 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
{
var newItems = new List<LoadingSheetItem>();
var keptItems = new List<LoadingSheetItem>();
foreach (var input in request.Items)
repo.RemoveItems(sheet.Items);
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
{
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);
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
QuantityDispatched = i.Quantity
}));
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -120,60 +88,289 @@ public class LoadingSheetService(
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
sheet.DispatchConfirmedAt = DateTime.Now;
sheet.DispatchConfirmedBy = userId;
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
item.QuantityReceived = input.QuantityReceived;
}
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId);
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
sheet.Status = LoadingSheetStatus.Confirmed;
sheet.ConfirmedAt = DateTime.Now;
sheet.ConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return ToResponse(saved!);
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
{
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
{
var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})";
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
await mover.MoveAsync(item.FK_ItemId, label,
item.FK_WarehouseId, LocationType.Warehouse,
vehicleId, LocationType.Vehicle,
item.QuantityDispatched, MovementType.Load, sheetId, userId);
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
if (warehouseBalance.Quantity < qty)
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
warehouseBalance.Quantity -= qty;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = qty
});
}
else
{
vehicleBalance.Quantity += qty;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = qty,
Type = MovementType.Load,
ReferenceId = sheet.Id,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
s.SourceWarehouse?.Name ?? string.Empty,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
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,
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());
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
}
@@ -1,29 +0,0 @@
namespace Distribution.Features.Notifications;
public interface INotificationRepository
{
// ── Inbox ────────────────────────────────────────────────────────────
Task AddAsync(Models.Notification notification);
/// <summary>Stops tracking a notification (and its recipients) whose save failed.</summary>
void Discard(Models.Notification notification);
Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly);
Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize);
Task<int> GetUnreadCountAsync(Guid userId);
Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId);
Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId);
void RemoveRecipient(Models.NotificationRecipient recipient);
// ── Recipient resolution ─────────────────────────────────────────────
Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds);
Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode);
Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id);
Task<Guid?> GetReportsToUserIdAsync(Guid userId);
Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id);
// ── Retention ────────────────────────────────────────────────────────
Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take);
Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take);
void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients);
void RemoveNotifications(IEnumerable<Models.Notification> notifications);
}
@@ -1,63 +0,0 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Notifications;
/// <summary>
/// Daily retention purge: deletes read inbox rows older than <c>Notifications:RetentionDays</c>
/// (default 90), then any notification left with no recipients.
/// </summary>
public class NotificationCleanupJob(
IServiceScopeFactory scopes,
IConfiguration config,
ILogger<NotificationCleanupJob> logger) : BackgroundService
{
private const int BatchSize = 500;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Let the app finish starting before the first run.
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try { await RunOnceAsync(stoppingToken); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { logger.LogError(ex, "Notification cleanup failed."); }
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
}
}
private async Task RunOnceAsync(CancellationToken ct)
{
var retentionDays = config.GetValue("Notifications:RetentionDays", 90);
var cutoff = DateTime.Now.AddDays(-retentionDays);
using var scope = scopes.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
var removed = 0;
while (true)
{
var expired = await repo.GetExpiredReadRecipientsAsync(cutoff, BatchSize);
if (expired.Count == 0) break;
repo.RemoveRecipients(expired);
await uow.CommitAsync(ct);
removed += expired.Count;
}
while (true)
{
var orphans = await repo.GetOrphanNotificationsAsync(BatchSize);
if (orphans.Count == 0) break;
repo.RemoveNotifications(orphans);
await uow.CommitAsync(ct);
}
if (removed > 0)
logger.LogInformation("Notification cleanup removed {Count} read inbox rows older than {Days} days.", removed, retentionDays);
}
}
@@ -1,57 +0,0 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Notifications;
/// <summary>Every endpoint acts on the signed-in user's own notifications only.</summary>
[ApiController]
[Route("api/notifications")]
public class NotificationController(NotificationService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetInbox(
[FromQuery] bool unreadOnly = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
return Ok(await service.GetInboxAsync(userId, unreadOnly, page, pageSize));
}
[HttpGet("unread-count")]
public async Task<IActionResult> GetUnreadCount()
{
if (currentUser.UserId is not { } userId) return Unauthorized();
return Ok(await service.GetUnreadCountAsync(userId));
}
[HttpPatch("{id:guid}/read")]
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
try
{
await service.MarkReadAsync(userId, id, ct);
return NoContent();
}
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
}
[HttpPatch("read-all")]
public async Task<IActionResult> MarkAllRead(CancellationToken ct)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
await service.MarkAllReadAsync(userId, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
try
{
await service.DeleteAsync(userId, id, ct);
return NoContent();
}
catch (KeyNotFoundException ex) { return NotFound(new { error = ex.Message }); }
}
}
@@ -1,28 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Notifications;
/// <param name="Values">Object whose public properties fill the {Token} placeholders in the type's templates.</param>
/// <param name="CreatedByUserId">The acting user; excluded from the recipients.</param>
public record NotificationArgs(
object? Values,
string EntityType,
Guid EntityId,
string Link,
Guid? CreatedByUserId = null);
/// <param name="Id">The caller's own recipient row id — use it for read / delete.</param>
public record NotificationResponse(
Guid Id,
string Type,
NotificationSeverity Severity,
string Title,
string Message,
string EntityType,
Guid EntityId,
string Link,
DateTime CreatedAt,
bool IsRead,
DateTime? ReadAt);
public record UnreadCountResponse(int Count);
@@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Distribution.Features.Notifications;
/// <summary>
/// Server → client only. The server pushes a "notification" event to each recipient; clients never
/// invoke hub methods. Users are addressed by their id (see <see cref="NotificationUserIdProvider"/>).
/// </summary>
[Authorize]
public class NotificationHub : Hub
{
public const string Path = "/hubs/notifications";
public const string ClientEvent = "notification";
}
/// <summary>Maps a SignalR connection to the "UserId" claim that <c>AuthMiddleware</c> puts on the principal.</summary>
public class NotificationUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection) =>
connection.User?.FindFirst("UserId")?.Value;
}
@@ -1,53 +0,0 @@
namespace Distribution.Features.Notifications;
public class NotificationRecipientResolver(INotificationRepository repo)
{
/// <summary>
/// Expands the rules into a de-duplicated list of active user ids, never including
/// <paramref name="actingUserId"/>.
/// </summary>
public async Task<List<Guid>> ResolveAsync(IEnumerable<RecipientRule> rules, Guid? actingUserId)
{
var ids = new HashSet<Guid>();
foreach (var rule in rules)
ids.UnionWith(await ResolveRuleAsync(rule));
if (actingUserId is { } actor)
ids.Remove(actor);
if (ids.Count == 0) return [];
return await repo.FilterActiveUserIdsAsync(ids);
}
private async Task<IEnumerable<Guid>> ResolveRuleAsync(RecipientRule rule)
{
switch (rule)
{
case UsersRule r:
return r.Ids;
case PermissionRule r:
return await repo.GetUserIdsByPermissionAsync(r.Code);
case ScopeRule r:
return await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
case PermissionInScopeRule r:
var withPermission = await repo.GetUserIdsByPermissionAsync(r.Code);
var inScope = await repo.GetUserIdsByScopeAsync(r.Kind, r.Id);
return withPermission.Intersect(inScope);
case ReportsToRule r:
var supervisor = await repo.GetReportsToUserIdAsync(r.UserId);
return supervisor is { } s ? [s] : [];
case RouteOwnerRule r:
var owner = await repo.GetRouteOwnerUserIdAsync(r.Kind, r.Id);
return owner is { } o ? [o] : [];
default:
throw new NotSupportedException($"Unknown recipient rule {rule.GetType().Name}.");
}
}
}
@@ -1,156 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Notifications;
public class NotificationRepository(AppDBContext context) : INotificationRepository
{
// ── Inbox ────────────────────────────────────────────────────────────
public async Task AddAsync(Models.Notification notification) =>
await context.Notifications.AddAsync(notification);
public void Discard(Models.Notification notification)
{
foreach (var recipient in notification.Recipients)
context.Entry(recipient).State = EntityState.Detached;
context.Entry(notification).State = EntityState.Detached;
}
private IQueryable<Models.NotificationRecipient> InboxQuery(Guid userId, bool unreadOnly)
{
var query = context.NotificationRecipients.Where(r => r.FK_UserId == userId && r.IsActive);
return unreadOnly ? query.Where(r => !r.IsRead) : query;
}
public Task<int> GetInboxCountAsync(Guid userId, bool unreadOnly) =>
InboxQuery(userId, unreadOnly).CountAsync();
public Task<List<Models.NotificationRecipient>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize) =>
InboxQuery(userId, unreadOnly)
.Include(r => r.Notification)
.OrderByDescending(r => r.Notification.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.AsNoTracking()
.ToListAsync();
public Task<int> GetUnreadCountAsync(Guid userId) =>
InboxQuery(userId, unreadOnly: true).CountAsync();
public Task<Models.NotificationRecipient?> GetRecipientAsync(Guid userId, Guid recipientId) =>
context.NotificationRecipients
.FirstOrDefaultAsync(r => r.Id == recipientId && r.FK_UserId == userId && r.IsActive);
public Task<List<Models.NotificationRecipient>> GetUnreadRecipientsAsync(Guid userId) =>
InboxQuery(userId, unreadOnly: true).ToListAsync();
public void RemoveRecipient(Models.NotificationRecipient recipient) =>
context.NotificationRecipients.Remove(recipient);
// ── Recipient resolution ─────────────────────────────────────────────
public Task<List<Guid>> FilterActiveUserIdsAsync(IEnumerable<Guid> userIds)
{
var ids = userIds.ToList();
return context.Users
.Where(u => u.IsActive && ids.Contains(u.Id))
.Select(u => u.Id)
.ToListAsync();
}
public Task<List<Guid>> GetUserIdsByPermissionAsync(string permissionCode) =>
context.Users
.Where(u => u.IsActive && context.RolePermissions.Any(rp =>
rp.FK_RoleId == u.FK_RoleId && rp.IsActive &&
rp.Permission.IsActive && rp.Permission.Code == permissionCode))
.Select(u => u.Id)
.ToListAsync();
public async Task<List<Guid>> GetUserIdsByScopeAsync(ScopeKind kind, Guid id)
{
Guid? regionId = null;
Guid? routeId = null;
Guid? storeId = null;
switch (kind)
{
case ScopeKind.Region:
regionId = id;
break;
case ScopeKind.Route:
routeId = id;
regionId = await context.Routes.Where(r => r.Id == id)
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
break;
case ScopeKind.Store:
storeId = id;
routeId = await context.Stores.Where(s => s.Id == id)
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync();
if (routeId is { } rid)
regionId = await context.Routes.Where(r => r.Id == rid)
.Select(r => (Guid?)r.FK_RegionId).FirstOrDefaultAsync();
break;
}
var userIds = new HashSet<Guid>();
if (regionId is { } region)
userIds.UnionWith(await context.UserRegionScopes
.Where(s => s.IsActive && s.FK_RegionId == region)
.Select(s => s.FK_UserId).ToListAsync());
if (routeId is { } route)
userIds.UnionWith(await context.UserRouteScopes
.Where(s => s.IsActive && s.FK_RouteId == route)
.Select(s => s.FK_UserId).ToListAsync());
if (storeId is { } store)
userIds.UnionWith(await context.UserStoreScopes
.Where(s => s.IsActive && s.FK_StoreId == store)
.Select(s => s.FK_UserId).ToListAsync());
return userIds.ToList();
}
public Task<Guid?> GetReportsToUserIdAsync(Guid userId) =>
context.Users.Where(u => u.Id == userId)
.Select(u => u.FK_ReportsToUserId)
.FirstOrDefaultAsync();
public async Task<Guid?> GetRouteOwnerUserIdAsync(ScopeKind kind, Guid id)
{
Guid? routeId = kind switch
{
ScopeKind.Route => id,
ScopeKind.Store => await context.Stores.Where(s => s.Id == id)
.Select(s => (Guid?)s.FK_RouteId).FirstOrDefaultAsync(),
_ => null
};
if (routeId is not { } rid) return null;
return await context.Routes.Where(r => r.Id == rid && r.IsActive)
.Select(r => r.FK_OwnerUserId)
.FirstOrDefaultAsync();
}
// ── Retention ────────────────────────────────────────────────────────
public Task<List<Models.NotificationRecipient>> GetExpiredReadRecipientsAsync(DateTime cutoff, int take) =>
context.NotificationRecipients
.Where(r => r.IsRead && r.ReadAt != null && r.ReadAt < cutoff)
.Take(take)
.ToListAsync();
public Task<List<Models.Notification>> GetOrphanNotificationsAsync(int take) =>
context.Notifications
.Where(n => !n.Recipients.Any())
.Take(take)
.ToListAsync();
public void RemoveRecipients(IEnumerable<Models.NotificationRecipient> recipients) =>
context.NotificationRecipients.RemoveRange(recipients);
public void RemoveNotifications(IEnumerable<Models.Notification> notifications) =>
context.Notifications.RemoveRange(notifications);
}
@@ -1,162 +0,0 @@
using System.Text.RegularExpressions;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.SignalR;
namespace Distribution.Features.Notifications;
public interface INotificationService
{
/// <summary>
/// Creates a notification for every resolved recipient and pushes it live. Best-effort: this method
/// never throws, so a notification problem can never fail the business action that triggered it.
/// Call it only AFTER the owning service's <c>uow.CommitAsync()</c> has succeeded.
/// </summary>
Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default);
}
public class NotificationService(
INotificationRepository repo,
NotificationRecipientResolver resolver,
IHubContext<NotificationHub> hub,
IUnitOfWork uow,
ILogger<NotificationService> logger) : INotificationService
{
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
// ── Trigger side ─────────────────────────────────────────────────────
public async Task NotifyAsync(string type, IReadOnlyList<RecipientRule> recipients, NotificationArgs args, CancellationToken ct = default)
{
try
{
if (!NotificationTypes.All.TryGetValue(type, out var definition))
{
logger.LogError("Notification type {Type} is not registered in NotificationTypes; skipped.", type);
return;
}
var userIds = await resolver.ResolveAsync(recipients, args.CreatedByUserId);
if (userIds.Count == 0) return;
var values = ToDictionary(args.Values);
var notification = new Models.Notification
{
Type = type,
Severity = definition.Severity,
Title = Truncate(Render(definition.TitleTemplate, values), 150),
Message = Truncate(Render(definition.MessageTemplate, values), 500),
EntityType = args.EntityType,
EntityId = args.EntityId,
Link = args.Link,
CreatedByUserId = args.CreatedByUserId,
CreatedAt = DateTime.Now,
Recipients = userIds.Select(id => new Models.NotificationRecipient { FK_UserId = id }).ToList()
};
await repo.AddAsync(notification);
try
{
await uow.CommitAsync(ct);
}
catch
{
repo.Discard(notification);
throw;
}
await PushAsync(notification);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create {Type} notification for {EntityType} {EntityId}.",
type, args.EntityType, args.EntityId);
}
}
// Separate from persistence: a failed push still leaves the notification in the inbox.
private async Task PushAsync(Models.Notification notification)
{
foreach (var recipient in notification.Recipients)
{
try
{
await hub.Clients.User(recipient.FK_UserId.ToString())
.SendAsync(NotificationHub.ClientEvent, ToResponse(notification, recipient));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to push notification {NotificationId} to user {UserId}.",
notification.Id, recipient.FK_UserId);
}
}
}
// ── Inbox side ───────────────────────────────────────────────────────
public async Task<PagedResponse<NotificationResponse>> GetInboxAsync(Guid userId, bool unreadOnly, int page, int pageSize)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var total = await repo.GetInboxCountAsync(userId, unreadOnly);
var rows = await repo.GetInboxAsync(userId, unreadOnly, page, pageSize);
return new PagedResponse<NotificationResponse>(page, pageSize, total,
rows.Select(r => ToResponse(r.Notification, r)).ToList());
}
public async Task<UnreadCountResponse> GetUnreadCountAsync(Guid userId) =>
new(await repo.GetUnreadCountAsync(userId));
public async Task MarkReadAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
{
var recipient = await repo.GetRecipientAsync(userId, recipientId)
?? throw new KeyNotFoundException("Notification not found.");
if (recipient.IsRead) return;
recipient.IsRead = true;
recipient.ReadAt = DateTime.Now;
await uow.CommitAsync(ct);
}
public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default)
{
var unread = await repo.GetUnreadRecipientsAsync(userId);
if (unread.Count == 0) return;
var now = DateTime.Now;
foreach (var recipient in unread)
{
recipient.IsRead = true;
recipient.ReadAt = now;
}
await uow.CommitAsync(ct);
}
public async Task DeleteAsync(Guid userId, Guid recipientId, CancellationToken ct = default)
{
var recipient = await repo.GetRecipientAsync(userId, recipientId)
?? throw new KeyNotFoundException("Notification not found.");
repo.RemoveRecipient(recipient);
await uow.CommitAsync(ct);
}
// ── Helpers ──────────────────────────────────────────────────────────
private static NotificationResponse ToResponse(Models.Notification n, Models.NotificationRecipient r) =>
new(r.Id, n.Type, n.Severity, n.Title, n.Message, n.EntityType, n.EntityId, n.Link,
n.CreatedAt, r.IsRead, r.ReadAt);
private static Dictionary<string, string> ToDictionary(object? values) =>
values is null
? []
: values.GetType().GetProperties()
.ToDictionary(p => p.Name, p => p.GetValue(values)?.ToString() ?? string.Empty);
private static string Render(string template, Dictionary<string, string> values) =>
TokenPattern.Replace(template, m => values.TryGetValue(m.Groups[1].Value, out var v) ? v : string.Empty).Trim();
private static string Truncate(string value, int max) =>
value.Length <= max ? value : value[..(max - 1)] + "…";
}
@@ -1,49 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Notifications;
public record NotificationTypeDefinition(NotificationSeverity Severity, string TitleTemplate, string MessageTemplate);
/// <summary>
/// Registry of every notification type. Services must pass one of these constants to
/// <see cref="INotificationService.NotifyAsync"/> — never a string literal. Templates use {Token}
/// placeholders filled from <see cref="NotificationArgs.Values"/>.
/// </summary>
public static class NotificationTypes
{
public const string RideCancelled = "RideCancelled";
public const string RideDriverAssigned = "RideDriverAssigned";
public const string DeliveryTaskFailed = "DeliveryTaskFailed";
public const string DeliveryTaskPartiallyDelivered = "DeliveryTaskPartiallyDelivered";
public const string SupplierClaimApproved = "SupplierClaimApproved";
public const string SupplierClaimRejected = "SupplierClaimRejected";
public const string ShopPOConfirmed = "ShopPOConfirmed";
public const string ShopPOCancelled = "ShopPOCancelled";
public const string StockLow = "StockLow";
public const string CreditLimitBreached = "CreditLimitBreached";
public static readonly IReadOnlyDictionary<string, NotificationTypeDefinition> All =
new Dictionary<string, NotificationTypeDefinition>
{
[RideCancelled] = new(NotificationSeverity.Critical,
"Ride cancelled", "A ride was cancelled ({CancelType}). {Reason}"),
[RideDriverAssigned] = new(NotificationSeverity.Info,
"Ride assigned", "You have been assigned to a ride."),
[DeliveryTaskFailed] = new(NotificationSeverity.Warning,
"Delivery failed", "Delivery to {StoreName} failed. {Note}"),
[DeliveryTaskPartiallyDelivered] = new(NotificationSeverity.Warning,
"Partial delivery", "Delivery to {StoreName} was only partly delivered."),
[SupplierClaimApproved] = new(NotificationSeverity.Success,
"Supplier claim approved", "Your supplier claim was approved."),
[SupplierClaimRejected] = new(NotificationSeverity.Warning,
"Supplier claim rejected", "Your supplier claim was rejected."),
[ShopPOConfirmed] = new(NotificationSeverity.Info,
"Shop order confirmed", "Shop order for {StoreName} was confirmed."),
[ShopPOCancelled] = new(NotificationSeverity.Warning,
"Shop order cancelled", "Shop order for {StoreName} was cancelled: {Reason}"),
[StockLow] = new(NotificationSeverity.Warning,
"Low stock", "{ItemName} is down to {Quantity} at {Location}."),
[CreditLimitBreached] = new(NotificationSeverity.Warning,
"Credit limit exceeded", "{StoreName} has exceeded its credit limit."),
};
}
@@ -1,37 +0,0 @@
namespace Distribution.Features.Notifications;
public enum ScopeKind { Region, Route, Store }
/// <summary>
/// Who a notification goes to. There is deliberately no "everyone" rule — every trigger must say who
/// needs to know. Scope coverage follows Region → Route → Store: a region scope covers its routes
/// and their stores, a route scope covers its stores.
/// </summary>
public abstract record RecipientRule
{
/// <summary>These specific users (creator, driver, approver, route owner …).</summary>
public static RecipientRule Users(params Guid[] ids) => new UsersRule(ids);
/// <summary>Every active user whose role holds the permission code.</summary>
public static RecipientRule Permission(string code) => new PermissionRule(code);
/// <summary>Every active user whose scope covers the region / route / store.</summary>
public static RecipientRule Scope(ScopeKind kind, Guid id) => new ScopeRule(kind, id);
/// <summary>Users who hold the permission AND whose scope covers the region / route / store.</summary>
public static RecipientRule PermissionInScope(string code, ScopeKind kind, Guid id) =>
new PermissionInScopeRule(code, kind, id);
/// <summary>The user's supervisor (User.FK_ReportsToUserId).</summary>
public static RecipientRule ReportsTo(Guid userId) => new ReportsToRule(userId);
/// <summary>The owner of the route (<see cref="ScopeKind.Route"/>) or of the store's route (<see cref="ScopeKind.Store"/>).</summary>
public static RecipientRule RouteOwner(ScopeKind kind, Guid id) => new RouteOwnerRule(kind, id);
}
public sealed record UsersRule(IReadOnlyCollection<Guid> Ids) : RecipientRule;
public sealed record PermissionRule(string Code) : RecipientRule;
public sealed record ScopeRule(ScopeKind Kind, Guid Id) : RecipientRule;
public sealed record PermissionInScopeRule(string Code, ScopeKind Kind, Guid Id) : RecipientRule;
public sealed record ReportsToRule(Guid UserId) : RecipientRule;
public sealed record RouteOwnerRule(ScopeKind Kind, Guid Id) : RecipientRule;
@@ -0,0 +1,19 @@
namespace Distribution.Features.Order;
public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
/// <summary>Confirmed POs (ConfirmedAt set, not cancelled) matching the optional filters —
/// backs Mode B's "office browses Confirmed POs" step per order_invoice_lifecycle.md.</summary>
Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId);
}
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
[ApiController]
[Route("api/orders")]
public class OrderController(OrderService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("confirmed")]
public async Task<IActionResult> GetConfirmed([FromQuery] Guid? agentId, [FromQuery] Guid? routeId, [FromQuery] Guid? shopId) =>
Ok(await service.GetConfirmedAsync(agentId, routeId, shopId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : 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> ReplaceItems(Guid id, UpdateOrderItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.ReplaceItemsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-and-create-invoice")]
public async Task<IActionResult> ConfirmAndCreateInvoice(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAndCreateInvoiceAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
var cancelled = await service.DeleteAsync(id, ct);
return cancelled ? NoContent() : NotFound();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
}
+28
View File
@@ -0,0 +1,28 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record CreateOrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice);
public record CreateOrderRequest(Guid StoreId, List<CreateOrderItemRequest> Items);
public record UpdateOrderItemsRequest(List<CreateOrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record OrderItemResponse(Guid Id, Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid? SaleId,
Guid? InvoiceId,
DateTime OrderDate,
Guid StoreId,
Guid CreatedByUserId,
decimal TotalAmount,
OrderStatus Status,
DateTime? ConfirmedAt,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,62 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
public class OrderRepository(AppDBContext context) : IOrderRepository
{
public async Task<Models.Order?> GetByIdAsync(Guid id) =>
await context.Orders.FindAsync(id);
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
public void Update(Models.Order entity) =>
context.Orders.Update(entity);
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
public async Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId, bool includeHistory = false) =>
await context.OrderItems
.Where(oi => oi.FK_OrderId == orderId && (includeHistory || oi.IsActive))
.ToListAsync();
public async Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId) =>
await context.Orders.Where(o => o.FK_StoreId == storeId && o.IsActive).ToListAsync();
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
public async Task<List<Models.Order>> GetConfirmedAsync(Guid? agentId, Guid? routeId, Guid? storeId) =>
await context.Orders
.Include(o => o.Store)
.Where(o => o.IsActive
&& o.ConfirmedAt != null
&& o.Status != OrderStatus.Cancelled
&& (agentId == null || o.CreatedByUserId == agentId)
&& (routeId == null || o.Store.FK_RouteId == routeId)
&& (storeId == null || o.FK_StoreId == storeId))
.ToListAsync();
}

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