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
597 changed files with 8477 additions and 212463 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() public RegionServiceTests()
{ {
_currentUser.Setup(u => u.RoleLevel).Returns(60); _currentUser.Setup(u => u.RoleLevel).Returns(60);
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object); _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); _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 // DeleteAsync
[Fact] [Fact]
@@ -313,93 +195,4 @@ public class RegionServiceTests
_uow.Verify(u => u.RollbackAsync(default), Times.Once); _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.AssemblyCompanyAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [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.AssemblyProductAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")] [assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [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.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\Distribution.Tests.genruntimeconfig.cache
C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\obj\Debug\net9.0\ref\Distribution.Tests.dll 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 -572
View File
@@ -16,16 +16,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Uom> Uoms { get; set; } public DbSet<Uom> Uoms { get; set; }
public DbSet<UomConversion> UomConversions { get; set; } public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { 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<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; } public DbSet<Sale> Sales { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; } public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<Invoice> Invoices { 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<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { 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<UserStoreScope> UserStoreScopes { get; set; }
public DbSet<StockBalance> StockBalances { get; set; } public DbSet<StockBalance> StockBalances { get; set; }
public DbSet<StockMovement> StockMovements { get; set; } public DbSet<StockMovement> StockMovements { get; set; }
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
public DbSet<PriceList> PriceLists { get; set; } public DbSet<PriceList> PriceLists { get; set; }
public DbSet<PriceListItem> PriceListItems { get; set; } public DbSet<PriceListItem> PriceListItems { get; set; }
public DbSet<Supplier> Suppliers { get; set; } public DbSet<Supplier> Suppliers { get; set; }
public DbSet<DriverProfile> DriverProfiles { get; set; } public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; } public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; } public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; } public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { 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<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; } public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { 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<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; } public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; } public DbSet<SaleReturn> SaleReturns { get; set; }
@@ -67,25 +62,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; } public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; } public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { 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<ExpenseCategory> ExpenseCategories { get; set; }
public DbSet<ExpenseVendor> ExpenseVendors { get; set; }
public DbSet<Expense> Expenses { get; set; }
public DbSet<ExpenseAttachment> ExpenseAttachments { get; set; }
public DbSet<RecurringExpenseTemplate> RecurringExpenseTemplates { get; set; }
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) 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>() modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId }); .HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -99,39 +83,27 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>() modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId }); .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>() modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo) .HasOne(u => u.ReportsTo)
.WithMany() .WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId) .HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict); .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>() modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>() .HasOne<Warehouse>()
@@ -140,7 +112,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(false); .IsRequired(false);
// --- DeliveryRun --- modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>() modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile) .HasOne(r => r.DriverProfile)
@@ -148,145 +124,76 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId) .HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- ShopPO --- modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
.WithMany() .WithMany()
.HasForeignKey(p => p.FK_StoreId) .HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// InvoiceId is a plain column — no FK constraint modelBuilder.Entity<Sale>()
modelBuilder.Entity<ShopPO>() .HasOne(s => s.CreatedByUser)
.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)
.WithMany() .WithMany()
.HasForeignKey(i => i.FK_ItemId) .HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Invoice --- modelBuilder.Entity<Sale>()
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>()
.HasOne(s => s.Ride) .HasOne(s => s.Ride)
.WithMany() .WithMany()
.HasForeignKey(s => s.FK_RideId) .HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict); .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)
.IsRequired(false); .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>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme) .HasOne(r => r.Scheme)
@@ -295,11 +202,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.ShopPO) .HasOne(r => r.Order)
.WithMany() .WithMany()
.HasForeignKey(r => r.FK_ShopPOId) .HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict);
.IsRequired(false);
modelBuilder.Entity<SchemeRedemption>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem) .HasOne(r => r.FreeItem)
@@ -307,8 +213,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_FreeItemId) .HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Scheme ---
modelBuilder.Entity<Scheme>() modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem) .HasOne(s => s.TriggerItem)
.WithMany() .WithMany()
@@ -337,7 +241,23 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId }) .HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique(); .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>() modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier) .HasOne(po => po.Supplier)
@@ -357,24 +277,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId) .HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
// --- GoodsReceipt ---
modelBuilder.Entity<GoodsReceipt>() modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder) .HasOne(gr => gr.PurchaseOrder)
.WithMany() .WithMany()
.HasForeignKey(gr => gr.FK_PurchaseOrderId) .HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Supplier)
.WithMany()
.HasForeignKey(gr => gr.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceipt>() modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse) .HasOne(gr => gr.Warehouse)
.WithMany() .WithMany()
@@ -393,94 +301,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId) .HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict); .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>() modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder) .HasOne(pr => pr.PurchaseOrder)
.WithMany() .WithMany()
@@ -499,15 +319,22 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId) .HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict); .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>() modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId); .HasIndex(i => i.FK_PurchaseReturnId);
// --- SaleReturn ---
modelBuilder.Entity<SaleReturn>() modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.ShopPO) .HasOne(r => r.Order)
.WithMany() .WithMany()
.HasForeignKey(r => r.FK_ShopPOId) .HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>() modelBuilder.Entity<SaleReturn>()
@@ -531,16 +358,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SaleReturnItem>() modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId); .HasIndex(i => i.FK_SaleReturnId);
// --- CreditNote ---
modelBuilder.Entity<CreditNote>() modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn) .HasOne(n => n.SaleReturn)
.WithMany() .WithMany()
.HasForeignKey(n => n.FK_SaleReturnId) .HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Claims ---
modelBuilder.Entity<Claim>() modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser) .HasOne(c => c.AgencyUser)
.WithMany() .WithMany()
@@ -593,8 +416,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierClaimItem>() modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId); .HasIndex(i => i.FK_SupplierClaimId);
// --- DeliveryTask ---
modelBuilder.Entity<DeliveryTask>() modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride) .HasOne(t => t.Ride)
.WithMany() .WithMany()
@@ -602,22 +423,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>() modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.ShopPO) .HasOne(t => t.Order)
.WithMany() .WithMany()
.HasForeignKey(t => t.FK_ShopPOId) .HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>() modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period }) .HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique(); .IsUnique();
@@ -640,271 +450,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId) .HasIndex(r => r.FK_RegionId)
.IsUnique(); .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 });
// --- Expense ---
modelBuilder.Entity<ExpenseCategory>()
.HasOne(c => c.Parent)
.WithMany()
.HasForeignKey(c => c.FK_ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<ExpenseCategory>()
.Property(c => c.Name).HasMaxLength(100);
modelBuilder.Entity<ExpenseCategory>()
.Property(c => c.Code).HasMaxLength(50);
modelBuilder.Entity<ExpenseCategory>()
.HasIndex(c => new { c.FK_ParentCategoryId, c.Name }).IsUnique();
modelBuilder.Entity<ExpenseVendor>()
.Property(v => v.Name).HasMaxLength(200);
modelBuilder.Entity<ExpenseVendor>()
.HasIndex(v => v.Name).IsUnique();
modelBuilder.Entity<Expense>()
.HasOne(e => e.Category).WithMany()
.HasForeignKey(e => e.FK_CategoryId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Expense>()
.HasOne(e => e.Warehouse).WithMany()
.HasForeignKey(e => e.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.Vendor).WithMany()
.HasForeignKey(e => e.FK_VendorId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.BankAccount).WithMany()
.HasForeignKey(e => e.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.ChequePage).WithMany()
.HasForeignKey(e => e.FK_ChequePageId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.RecurringTemplate).WithMany()
.HasForeignKey(e => e.FK_RecurringTemplateId)
.OnDelete(DeleteBehavior.SetNull).IsRequired(false);
modelBuilder.Entity<Expense>()
.Property(e => e.Payee).HasMaxLength(200);
modelBuilder.Entity<Expense>()
.Property(e => e.BillRef).HasMaxLength(100);
modelBuilder.Entity<Expense>()
.HasIndex(e => new { e.Status, e.BillDate });
modelBuilder.Entity<ExpenseAttachment>()
.HasOne(a => a.Expense).WithMany(e => e.Attachments)
.HasForeignKey(a => a.FK_ExpenseId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ExpenseAttachment>()
.Property(a => a.OriginalFileName).HasMaxLength(260);
modelBuilder.Entity<ExpenseAttachment>()
.Property(a => a.StoredPath).HasMaxLength(500);
modelBuilder.Entity<ExpenseAttachment>()
.Property(a => a.ContentType).HasMaxLength(100);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasOne(t => t.Category).WithMany()
.HasForeignKey(t => t.FK_CategoryId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasOne(t => t.Warehouse).WithMany()
.HasForeignKey(t => t.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasOne(t => t.Vendor).WithMany()
.HasForeignKey(t => t.FK_VendorId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<RecurringExpenseTemplate>()
.Property(t => t.Name).HasMaxLength(200);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasIndex(t => new { t.IsPaused, t.NextRunDate });
Seed.RoleAndPermissionSeed.Apply(modelBuilder); Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.ExpenseSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder); Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder); Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder); Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder); Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.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 SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
private static readonly Guid Payment1Id = new("47000000-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 SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturnItem1Id = new("52000000-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( 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( 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 = 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 }
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 }
); );
modelBuilder.Entity<SaleReturn>().HasData( 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( modelBuilder.Entity<SaleReturnItem>().HasData(
-61
View File
@@ -1,61 +0,0 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class ExpenseSeed
{
private static readonly DateTime SeedDate = new(2026, 10, 21);
// Top-level categories
private static readonly Guid CatSalaries = new("30000000-0000-0000-0000-000000000001");
private static readonly Guid CatUtilities = new("30000000-0000-0000-0000-000000000002");
private static readonly Guid CatRent = new("30000000-0000-0000-0000-000000000003");
private static readonly Guid CatVehicle = new("30000000-0000-0000-0000-000000000004");
private static readonly Guid CatOffice = new("30000000-0000-0000-0000-000000000005");
private static readonly Guid CatBankFees = new("30000000-0000-0000-0000-000000000006");
private static readonly Guid CatMisc = new("30000000-0000-0000-0000-000000000007");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ExpenseCategory>().HasData(
// Top-level
new ExpenseCategory { Id = CatSalaries, Name = "Salaries", Code = "SAL", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatUtilities, Name = "Utilities", Code = "UTL", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatRent, Name = "Rent", Code = "RNT", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatVehicle, Name = "Vehicle", Code = "VEH", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatOffice, Name = "Office", Code = "OFF", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatBankFees, Name = "Bank Fees", Code = "BNK", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatMisc, Name = "Miscellaneous", Code = "MSC", CreatedAt = SeedDate, IsActive = true },
// Titles under Salaries
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000101"), Name = "Basic Salary", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000102"), Name = "Bonus", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000103"), Name = "Overtime", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
// Titles under Utilities
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000201"), Name = "Electricity", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000202"), Name = "Water", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000203"), Name = "Internet", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
// Titles under Rent
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000301"), Name = "Warehouse Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000302"), Name = "Office Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
// Titles under Vehicle
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000401"), Name = "Fuel", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000402"), Name = "Maintenance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000403"), Name = "Insurance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
// Titles under Office
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000501"), Name = "Stationery", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000502"), Name = "Cleaning", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
// Titles under Bank Fees
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000601"), Name = "Service Charges", FK_ParentCategoryId = CatBankFees, CreatedAt = SeedDate, IsActive = true },
// Titles under Misc
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000701"), Name = "Other", FK_ParentCategoryId = CatMisc, CreatedAt = SeedDate, IsActive = true }
);
}
}
+18 -14
View File
@@ -7,6 +7,10 @@ public static class MasterDataSeed
{ {
private static readonly DateTime SeedDate = new(2026, 8, 20); 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). // 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 SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002"); internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
@@ -80,8 +84,8 @@ public static class MasterDataSeed
); );
modelBuilder.Entity<Region>().HasData( modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true }, new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true } new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
); );
modelBuilder.Entity<Models.Route>().HasData( modelBuilder.Entity<Models.Route>().HasData(
@@ -92,12 +96,12 @@ public static class MasterDataSeed
); );
modelBuilder.Entity<Store>().HasData( 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 = 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", 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", 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, 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", 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, 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( modelBuilder.Entity<Warehouse>().HasData(
@@ -111,12 +115,12 @@ public static class MasterDataSeed
); );
modelBuilder.Entity<User>().HasData( 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 = 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",FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, 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",FullName = "Kamal Silva", 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",FullName = "Ruwan Jayasuriya", 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",FullName = "Priyantha Kumara", 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",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, 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( 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 ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002"); 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 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 Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002"); internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO3Id = new("33000000-0000-0000-0000-000000000003");
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001"); internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002"); internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003"); internal static readonly Guid OrderItem3Id = 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 LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001"); internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -36,12 +31,16 @@ public static class OperationsDataSeed
new DeliveryRun new DeliveryRun
{ {
Id = ClosedSaleRunId, Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed, Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId, FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId, FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500, Meterstart = 10500,
Meterend = 10550, Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0), 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), ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true IsActive = true
@@ -49,101 +48,88 @@ public static class OperationsDataSeed
new DeliveryRun new DeliveryRun
{ {
Id = ActiveDeliveryRunId, Id = ActiveDeliveryRunId,
Status = RideStatus.Open, RideType = RideType.Delivery,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId, FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId, FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300, Meterstart = 20300,
Meterend = 0, Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0), StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null, ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0), CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true IsActive = true
} }
); );
modelBuilder.Entity<ShopPO>().HasData( modelBuilder.Entity<Sale>().HasData(
new ShopPO new Sale
{ {
Id = ShopPO1Id, Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId, FK_StoreId = MasterDataSeed.FortStoreId,
RaisedById = MasterDataSeed.SalesRep1UserId, CreatedByUserId = MasterDataSeed.SalesRep1UserId,
InvoiceId = Invoice1Id, FK_RideId = ClosedSaleRunId,
TotalAmount = 3000m, TotalAmount = 3000m,
Status = ShopPOStatus.Fulfilled, Status = SaleStatus.Completed,
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0), PaymentStatus = PaymentStatus.Paid,
ConfirmedBy = MasterDataSeed.Supervisor1UserId, 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), FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true 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, FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId, CreatedByUserId = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
TotalAmount = 1200m, TotalAmount = 1200m,
Status = ShopPOStatus.Pending, Status = OrderStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
FulfilledAt = null, FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true 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( modelBuilder.Entity<OrderItem>().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 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 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 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 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 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 }
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<LoadingSheet>().HasData( modelBuilder.Entity<LoadingSheet>().HasData(
@@ -151,19 +137,20 @@ public static class OperationsDataSeed
{ {
Id = LoadingSheet1Id, Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId, FK_RideId = ClosedSaleRunId,
Status = LoadingSheetStatus.Confirmed, FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
PopulatedBy = MasterDataSeed.Supervisor1UserId, Status = LoadingSheetStatus.ReceiptConfirmed,
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0), DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0), DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
ConfirmedBy = MasterDataSeed.Supervisor1UserId, ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true IsActive = true
} }
); );
modelBuilder.Entity<LoadingSheetItem>().HasData( 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 = 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, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true } new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
); );
modelBuilder.Entity<DeliveryTask>().HasData( modelBuilder.Entity<DeliveryTask>().HasData(
@@ -171,7 +158,7 @@ public static class OperationsDataSeed
{ {
Id = DeliveryTask1Id, Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId, FK_RideId = ActiveDeliveryRunId,
FK_ShopPOId = ShopPO2Id, FK_OrderId = Order2Id,
Sequence = 1, Sequence = 1,
Status = DeliveryTaskStatus.Pending, Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false, 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 StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003"); 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 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 RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002"); 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) public static void Apply(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<User>().HasData( 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( 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 = 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 } 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( modelBuilder.Entity<DriverComplianceConfig>().HasData(
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId } 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( 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 = 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 } 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 -202
View File
@@ -7,212 +7,22 @@ public static class RoleAndPermissionSeed
{ {
private static readonly DateTime SeedDate = new(2026, 8, 18); private static readonly DateTime SeedDate = new(2026, 8, 18);
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001"); 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 SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003"); 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 AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005"); private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006"); private static readonly Guid 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 PermExpenseView = new("20000000-0000-0000-0000-000000000051");
private static readonly Guid PermExpenseCreate = new("20000000-0000-0000-0000-000000000052");
private static readonly Guid PermExpensePost = new("20000000-0000-0000-0000-000000000053");
private static readonly Guid PermExpensePay = new("20000000-0000-0000-0000-000000000054");
private static readonly Guid PermExpenseVoid = new("20000000-0000-0000-0000-000000000055");
private static readonly Guid PermExpenseManageCats = new("20000000-0000-0000-0000-000000000056");
private static readonly Guid PermExpenseManageRec = new("20000000-0000-0000-0000-000000000057");
public static void Apply(ModelBuilder modelBuilder) public static void Apply(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Role>().HasData( modelBuilder.Entity<Role>().HasData(
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, 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 = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, 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 = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, 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 = 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 },
new Permission { Id = PermExpenseView, Code = "expense.view", Description = "View expenses, categories, vendors and recurring templates", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseCreate, Code = "expense.create", Description = "Create and edit draft expenses", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpensePost, Code = "expense.post", Description = "Post a draft expense (approve it)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpensePay, Code = "expense.pay", Description = "Pay a posted expense (debit a bank account or issue a cheque)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseVoid, Code = "expense.void", Description = "Void an expense", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseManageCats, Code = "expense.manage_categories", Description = "Manage expense categories, titles and vendors", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseManageRec, Code = "expense.manage_recurring", Description = "Manage recurring expense templates", 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 RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseCreate, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePost, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePay, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseVoid, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageCats, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageRec, 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 Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -8,93 +6,32 @@ namespace Distribution.Features.Auth;
[ApiController] [ApiController]
[Route("api/auth")] [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) => private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 }; result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) ToActionResult(await service.LoginAsync(request, ct));
{
var result = await service.LoginAsync(request, ct);
if (!result.Success || result.Data is not { } data)
return ToActionResult(result);
string? token = null;
TimeSpan maxAge = TimeSpan.FromHours(1);
if (data.TryGetProperty("accessToken", out var tokenEl))
token = tokenEl.GetString();
if (data.TryGetProperty("expiresIn", out var expEl))
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
if (!string.IsNullOrEmpty(token))
{
Response.Cookies.Append("access-token", token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = maxAge,
Secure = Request.IsHttps,
});
}
MeResponse? profile = null;
if (!string.IsNullOrEmpty(token))
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
if (Guid.TryParse(userIdStr, out var userId))
{
var user = await userRepo.GetByIdAsync(userId);
if (user is not null)
profile = new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0);
}
}
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
}
[HttpPost("register")] [HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) => public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct)); ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")] [HttpGet("me")]
[Authorize] [Authorize]
public async Task<IActionResult> Me() public async Task<IActionResult> Me(CancellationToken ct)
{ {
if (currentUser.UserId is null) return Unauthorized(); var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value); return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
if (user is null) return Unauthorized();
return Ok(new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0));
} }
[HttpPost("logout")] [HttpPost("logout")]
[AllowAnonymous] [Authorize]
public IActionResult Logout() public async Task<IActionResult> Logout(CancellationToken ct)
{ {
Response.Cookies.Delete("access-token", new CookieOptions var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
{ return ToActionResult(await service.LogoutAsync(token, ct));
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
Secure = Request.IsHttps,
});
return Ok(new { success = true });
} }
} }
+2 -13
View File
@@ -1,16 +1,5 @@
namespace Distribution.Features.Auth; namespace Distribution.Features.Auth;
public record LoginRequest( public record LoginRequest(string Username, string Password);
string Identifier,
string Password,
Guid? UserTypeId = null,
string DeviceName = "Unknown Device");
public record RegisterRequest(string Username, string email, string Password, string? FullName); public record RegisterRequest(string Username, string Password, string? FullName);
public record MeResponse(
Guid UserId,
string Username,
Guid RoleId,
string RoleCode,
int RoleLevel);
+4 -8
View File
@@ -1,21 +1,17 @@
using Distribution.Infra.Authorization;
namespace Distribution.Features.Auth; namespace Distribution.Features.Auth;
/// <summary> /// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions /// 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 /// 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> /// </summary>
public class AuthService(IAuthHexClient authHex) public class AuthService(IAuthHexClient authHex)
{ {
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) => public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", new authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
{
identifier = request.Identifier,
password = request.Password,
userTypeId = request.UserTypeId,
deviceName = request.DeviceName
}, reference: Guid.NewGuid().ToString(), ct: ct);
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) => public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct); 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 Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims; namespace Distribution.Features.Claims;
@@ -8,12 +7,10 @@ namespace Distribution.Features.Claims;
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{ {
[HttpGet("api/claims")] [HttpGet("api/claims")]
[Authorize("claim.view")]
public async Task<IActionResult> GetAll() => public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync()); Ok(await service.GetAllAsync());
[HttpGet("api/claims/{id:guid}")] [HttpGet("api/claims/{id:guid}")]
[Authorize("claim.view")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
var result = await service.GetByIdAsync(id); var result = await service.GetByIdAsync(id);
@@ -21,7 +18,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
} }
[HttpPost("api/claims")] [HttpPost("api/claims")]
[Authorize("claim.manage")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{ {
try try
@@ -33,7 +29,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
} }
[HttpPut("api/claims/{id:guid}")] [HttpPut("api/claims/{id:guid}")]
[Authorize("claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct) public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
{ {
try try
@@ -45,7 +40,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
} }
[HttpPost("api/claims/{id:guid}/settle")] [HttpPost("api/claims/{id:guid}/settle")]
[Authorize("claim.manage")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct) public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? request.SettledByUserId; 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 Microsoft.AspNetCore.Mvc;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide; namespace Distribution.Features.DeliveryRide;
[ApiController] [ApiController]
[Route("api")] [Route("api")]
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{ {
[HttpPost("rides/{rideId:guid}/tasks/generate")] [HttpPost("rides/{rideId:guid}/tasks/generate")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct) public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{ {
try try
@@ -24,26 +20,13 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
[HttpGet("rides/{rideId:guid}/tasks")] [HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")] [HttpGet("rides/{rideId:guid}/delivery-tasks")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetByRide(Guid rideId) public async Task<IActionResult> GetByRide(Guid rideId)
{ {
var result = await service.GetByRideIdAsync(rideId); var result = await service.GetByRideIdAsync(rideId);
return Ok(result); 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}")] [HttpGet("delivery-tasks/{id:guid}")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
var result = await service.GetByIdAsync(id); var result = await service.GetByIdAsync(id);
@@ -51,7 +34,6 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
} }
[HttpPut("delivery-tasks/{id:guid}/quantities")] [HttpPut("delivery-tasks/{id:guid}/quantities")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct) public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{ {
try try
@@ -64,12 +46,11 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
} }
[HttpPost("delivery-tasks/{id:guid}/confirm")] [HttpPost("delivery-tasks/{id:guid}/confirm")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct) public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{ {
try try
{ {
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct); var result = await service.ConfirmAsync(id, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -77,7 +58,6 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
} }
[HttpPost("delivery-tasks/{id:guid}/fail")] [HttpPost("delivery-tasks/{id:guid}/fail")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct) public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{ {
try try
@@ -1,4 +1,4 @@
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.DeliveryRide; namespace Distribution.Features.DeliveryRide;
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
public record DeliveryTaskResponse( public record DeliveryTaskResponse(
Guid Id, Guid Id,
Guid FK_RideId, Guid FK_RideId,
Guid FK_ShopPOId, Guid FK_OrderId,
Guid StoreId, Guid StoreId,
string StoreName, string StoreName,
int Sequence, int Sequence,
@@ -1,4 +1,4 @@
using Distribution.Data; using Distribution.Data;
using Distribution.Models; using Distribution.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -8,48 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
{ {
public async Task<DeliveryTask?> GetByIdAsync(Guid id) => public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks await context.DeliveryTasks
.Include(t => t.ShopPO) .Include(t => t.Order)
.ThenInclude(o => o.Store) .ThenInclude(o => o.Store)
.Include(t => t.ShopPO) .Include(t => t.Order)
.ThenInclude(o => o.Items.Where(i => i.IsActive)) .ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item) .ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id); .FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) => public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks await context.DeliveryTasks
.Include(t => t.ShopPO) .Include(t => t.Order)
.ThenInclude(o => o.Store) .ThenInclude(o => o.Store)
.Include(t => t.ShopPO) .Include(t => t.Order)
.ThenInclude(o => o.Items.Where(i => i.IsActive)) .ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item) .ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId) .Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence) .OrderBy(t => t.Sequence)
.ToListAsync(); .ToListAsync();
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize) public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
{
var query = context.DeliveryTasks
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.OrderByDescending(t => t.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
{
var query = context.DeliveryTasks.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.CountAsync();
}
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
await context.DeliveryTasks 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) => public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task); await context.DeliveryTasks.AddAsync(task);
@@ -1,7 +1,6 @@
using Distribution.Features.LoadingEvent; using Distribution.Features.Order;
using Distribution.Features.Notifications;
using Distribution.Features.Ride; using Distribution.Features.Ride;
using Distribution.Features.ShopPO; using Distribution.Features.Sale;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -11,33 +10,23 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService( public class DeliveryTaskService(
IDeliveryTaskRepository repo, IDeliveryTaskRepository repo,
IRideRepository rideRepo, IRideRepository rideRepo,
IShopPORepository shopPORepo, IOrderRepository orderRepo,
ILoadingSheetRepository loadingSheetRepo, ISaleRepository saleRepo,
StockMover mover, IStockRepository stockRepo,
INotificationService notifications,
IUnitOfWork uow) 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) => private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id, new(t.Id,
t.FK_RideId, t.FK_RideId,
t.FK_ShopPOId, t.FK_OrderId,
t.ShopPO.FK_StoreId, t.Order.FK_StoreId,
t.ShopPO.Store?.StoreName ?? string.Empty, t.Order.Store?.StoreName ?? string.Empty,
t.Sequence, t.Sequence,
t.Status, t.Status,
t.CustomerConfirmed, t.CustomerConfirmed,
t.ConfirmedAt, t.ConfirmedAt,
t.Note, t.Note,
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse( t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId, i.FK_ItemId,
i.Item?.Name ?? string.Empty, i.Item?.Name ?? string.Empty,
i.Quantity, i.Quantity,
@@ -50,43 +39,43 @@ public class DeliveryTaskService(
var ride = await rideRepo.GetByIdAsync(rideId) var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue); if (ride.Status != RideStatus.Active)
if (confirmedSheets.Count == 0) throw new InvalidOperationException("Tasks can only be generated for an Active ride");
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
var shopPOIds = confirmedSheets if (ride.RideType != RideType.Delivery)
.SelectMany(s => s.Orders) throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
.Select(o => o.FK_ShopPOId)
.Distinct()
.ToList();
var existingTasks = await repo.GetByRideIdAsync(rideId); var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
var newTasks = new List<DeliveryTask>(); var created = new List<DeliveryTask>();
foreach (var shopPOId in shopPOIds) 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_RideId = rideId,
FK_ShopPOId = shopPOId, FK_OrderId = order.Id,
Sequence = nextSequence++, Sequence = sequence++,
Status = DeliveryTaskStatus.Pending 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); foreach (var task in created)
try await repo.AddAsync(task);
{ await uow.CommitAsync(ct);
foreach (var task in newTasks) await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
} }
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId); var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList(); return saved.Select(ToResponse).ToList();
@@ -98,13 +87,6 @@ public class DeliveryTaskService(
return tasks.Select(ToResponse).ToList(); 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) public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{ {
var task = await repo.GetByIdAsync(id); var task = await repo.GetByIdAsync(id);
@@ -121,16 +103,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items) foreach (var input in request.Items)
{ {
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId) var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO"); ?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0) if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}"); 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}"); throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
shopPOItem.QuantityFulfilled = input.QuantityFulfilled; orderItem.QuantityFulfilled = input.QuantityFulfilled;
} }
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -145,7 +127,7 @@ public class DeliveryTaskService(
return ToResponse(saved!); 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) var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found"); ?? throw new KeyNotFoundException("Delivery task not found");
@@ -153,7 +135,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending) if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed"); 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"); 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) var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
@@ -161,44 +143,63 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId; 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.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now; task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered; task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var shopPO = task.ShopPO; var order = task.Order;
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled; order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered) if (fullyDelivered)
shopPO.FulfilledAt = DateTime.Now; order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try 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(); var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
await mover.MoveAsync(item.FK_ItemId, itemName, if (availableQuantity < item.QuantityFulfilled)
vehicleId, LocationType.Vehicle, throw new InvalidOperationException(
StockService.ExternalLocationId, LocationType.External, $"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
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); 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); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } 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); var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!); return ToResponse(saved!);
} }
@@ -222,13 +223,6 @@ public class DeliveryTaskService(
} }
catch { await uow.RollbackAsync(ct); throw; } 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); return ToResponse(task);
} }
} }
@@ -6,8 +6,6 @@ public interface IDeliveryTaskRepository
{ {
Task<DeliveryTask?> GetByIdAsync(Guid id); Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId); 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<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task); Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task); void Update(DeliveryTask task);
@@ -1,6 +1,5 @@
using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile; namespace Distribution.Features.DriverProfile;
@@ -13,7 +12,6 @@ public class DriverProfileController(
ICurrentUserService currentUser) : ControllerBase ICurrentUserService currentUser) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize("driver.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20) public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{ {
var result = await service.GetAllAsync(page, pageSize); var result = await service.GetAllAsync(page, pageSize);
@@ -21,7 +19,6 @@ public class DriverProfileController(
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[Authorize("driver.view")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
var result = await service.GetByIdAsync(id); var result = await service.GetByIdAsync(id);
@@ -29,7 +26,6 @@ public class DriverProfileController(
} }
[HttpPost] [HttpPost]
[Authorize("driver.manage")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{ {
try try
@@ -48,7 +44,6 @@ public class DriverProfileController(
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct) public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{ {
var result = await service.UpdateAsync(id, request, ct); var result = await service.UpdateAsync(id, request, ct);
@@ -56,7 +51,6 @@ public class DriverProfileController(
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct) public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{ {
var success = await service.DeactivateAsync(id, ct); var success = await service.DeactivateAsync(id, ct);
@@ -64,7 +58,6 @@ public class DriverProfileController(
} }
[HttpGet("compliance")] [HttpGet("compliance")]
[Authorize("driver.view")]
public async Task<IActionResult> GetCompliance() public async Task<IActionResult> GetCompliance()
{ {
var result = await complianceService.GetAsync(); var result = await complianceService.GetAsync();
@@ -72,7 +65,6 @@ public class DriverProfileController(
} }
[HttpPut("compliance")] [HttpPut("compliance")]
[Authorize("driver.manage")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct) public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -1,35 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Expense;
[ApiController]
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
{
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
[Authorize("expense.create")]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
{
try
{
var result = await service.UploadAsync(expenseId, file, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("api/expense-attachments/{id:guid}/download")]
[Authorize("expense.view")]
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
{
var result = await service.DownloadAsync(id, ct);
if (result is null) return NotFound();
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
}
[HttpDelete("api/expense-attachments/{id:guid}")]
[Authorize("expense.create")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
}
@@ -1,108 +0,0 @@
using Distribution.Data;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Expense;
public class ExpenseAttachmentService(
AppDBContext context,
IConfiguration config,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private const long MaxBytes = 10 * 1024 * 1024;
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
private static readonly string[] AllowedContentTypes =
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
private string AttachmentsRoot =>
config["Expense:AttachmentsRoot"]
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
{
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
if (expense is null) return null;
if (file is null || file.Length == 0)
throw new InvalidOperationException("File is empty");
if (file.Length > MaxBytes)
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
throw new InvalidOperationException($"File type '{ext}' is not allowed");
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
var now = DateTime.Now;
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
Directory.CreateDirectory(absoluteDir);
var storedName = $"{Guid.NewGuid()}{ext}";
var absolutePath = Path.Combine(absoluteDir, storedName);
await using (var stream = File.Create(absolutePath))
{
await file.CopyToAsync(stream, ct);
}
var attachment = new Models.ExpenseAttachment
{
FK_ExpenseId = expenseId,
OriginalFileName = file.FileName,
StoredPath = absolutePath,
ContentType = file.ContentType,
SizeBytes = file.Length,
UploadedByUserId = userId
};
await uow.BeginAsync(ct);
try
{
await context.ExpenseAttachments.AddAsync(attachment, ct);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
try { File.Delete(absolutePath); } catch { }
throw;
}
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
}
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
{
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
if (a is null) return null;
if (!File.Exists(a.StoredPath)) return null;
var stream = File.OpenRead(a.StoredPath);
return (stream, a.ContentType, a.OriginalFileName);
}
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
{
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
if (a is null) return false;
a.IsActive = false;
await uow.BeginAsync(ct);
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
// Best-effort file removal — keep DB soft-delete even if the file is gone.
try { File.Delete(a.StoredPath); } catch { }
return true;
}
}
@@ -1,109 +0,0 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Expense;
[ApiController]
[Route("api/expenses")]
public class ExpenseController(
ExpenseService service,
IPermissionService permissions,
ICurrentUserService currentUser) : ControllerBase
{
private async Task<bool> HasAsync(string code)
{
if (currentUser.RoleId is not { } roleId) return false;
return await permissions.HasPermissionAsync(roleId, code);
}
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> List(
[FromQuery] ExpenseStatus? status,
[FromQuery] Guid? categoryId,
[FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize("expense.view")]
public async Task<IActionResult> Get(Guid id)
{
var result = await service.GetAsync(id);
return result is null ? NotFound() : Ok(result);
}
/// <summary>
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
/// (requires expense.post + expense.pay). Draft is the default.
/// </summary>
[HttpPost]
[Authorize("expense.create")]
public async Task<IActionResult> Create(
CreateExpenseRequest request,
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
CancellationToken ct = default)
{
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
return Forbid();
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
return Forbid();
try { return Ok(await service.CreateAsync(request, action, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.create")]
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateDraftAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/post")]
[Authorize("expense.post")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
try
{
var result = await service.PostAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/pay")]
[Authorize("expense.pay")]
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.PayAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/void")]
[Authorize("expense.void")]
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
{
try
{
var result = await service.VoidAsync(id, request?.Reason, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,77 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Expense;
public record ExpensePaymentRequest(
PaymentMethod Method,
Guid? BankAccountId,
Guid? ChequePageId);
public record CreateExpenseRequest(
Guid CategoryId,
Guid? WarehouseId,
decimal Amount,
DateTime BillDate,
DateTime? DueDate,
DateTime? PeriodCovered,
Guid? VendorId,
string? Payee,
string? BillRef,
string? Description,
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
ExpensePaymentRequest? Payment);
public record UpdateExpenseDraftRequest(
Guid CategoryId,
Guid? WarehouseId,
decimal Amount,
DateTime BillDate,
DateTime? DueDate,
DateTime? PeriodCovered,
Guid? VendorId,
string? Payee,
string? BillRef,
string? Description);
public record VoidExpenseRequest(string? Reason);
public record ExpenseAttachmentResponse(
Guid Id,
string OriginalFileName,
string ContentType,
long SizeBytes,
DateTime CreatedAt);
public record ExpenseResponse(
Guid Id,
Guid CategoryId,
string CategoryName,
Guid? ParentCategoryId,
string? ParentCategoryName,
Guid? WarehouseId,
string? WarehouseName,
decimal Amount,
DateTime BillDate,
DateTime? DueDate,
DateTime? PeriodCovered,
Guid? VendorId,
string? VendorName,
string? Payee,
string? BillRef,
string? Description,
ExpenseStatus Status,
PaymentMethod? Method,
Guid? BankAccountId,
Guid? ChequePageId,
string? ChequeNo,
DateTime? PaidAt,
Guid RecordedByUserId,
Guid? PostedByUserId,
Guid? PaidByUserId,
Guid? VoidedByUserId,
string? VoidReason,
Guid? RecurringTemplateId,
List<ExpenseAttachmentResponse> Attachments,
DateTime CreatedAt);
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
@@ -1,50 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Expense;
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
{
private IQueryable<Models.Expense> Base() =>
context.Expenses
.Include(e => e.Category!).ThenInclude(c => c.Parent)
.Include(e => e.Warehouse)
.Include(e => e.Vendor)
.Include(e => e.ChequePage)
.Include(e => e.Attachments.Where(a => a.IsActive));
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
private IQueryable<Models.Expense> Filtered(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
Base()
.Where(e => e.IsActive)
.Where(e => !status.HasValue || e.Status == status)
.Where(e => !categoryId.HasValue
|| e.FK_CategoryId == categoryId
|| e.Category!.FK_ParentCategoryId == categoryId)
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
.Where(e => !from.HasValue || e.BillDate >= from)
.Where(e => !to.HasValue || e.BillDate <= to);
public async Task<List<Models.Expense>> ListAsync(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
int page, int pageSize) =>
await Filtered(status, categoryId, warehouseId, from, to)
.OrderByDescending(e => e.BillDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountAsync(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
public async Task AddAsync(Models.Expense entity) =>
await context.Expenses.AddAsync(entity);
public void Update(Models.Expense entity) =>
context.Expenses.Update(entity);
}
@@ -1,269 +0,0 @@
using Distribution.Data;
using Distribution.Features.BankAccount;
using Distribution.Features.ChequeBook;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Expense;
public class ExpenseService(
AppDBContext context,
IExpenseRepository repo,
IBankAccountRepository bankAccountRepo,
IChequeBookRepository chequeBookRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
private static ExpenseResponse ToResponse(Models.Expense e) =>
new(e.Id,
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
e.FK_WarehouseId, e.Warehouse?.Name,
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
e.FK_VendorId, e.Vendor?.Name,
e.Payee, e.BillRef, e.Description,
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
e.PaidAt,
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
e.FK_RecurringTemplateId,
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
e.CreatedAt);
public async Task<PagedExpensesResponse> ListAsync(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 20;
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
}
public async Task<ExpenseResponse?> GetAsync(Guid id)
{
var e = await repo.GetByIdAsync(id);
return e is null ? null : ToResponse(e);
}
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
{
ValidateBase(request);
var category = await context.ExpenseCategories.Include(c => c.Parent)
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
?? throw new InvalidOperationException("Category not found");
if (category.FK_ParentCategoryId is null)
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
var entity = new Models.Expense
{
FK_CategoryId = request.CategoryId,
FK_WarehouseId = request.WarehouseId,
Amount = request.Amount,
BillDate = request.BillDate.Date,
DueDate = request.DueDate?.Date,
PeriodCovered = request.PeriodCovered?.Date,
FK_VendorId = request.VendorId,
Payee = request.Payee,
BillRef = request.BillRef,
Description = request.Description,
RecordedByUserId = userId,
Status = ExpenseStatus.Draft
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(entity);
if (action is ExpenseAction.Post or ExpenseAction.Pay)
{
entity.Status = ExpenseStatus.Posted;
entity.PostedByUserId = userId;
entity.PostedAt = DateTime.Now;
}
if (action == ExpenseAction.Pay)
{
if (request.Payment is null)
throw new InvalidOperationException("Payment details are required when action = Pay");
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
}
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status != ExpenseStatus.Draft)
throw new InvalidOperationException("Only Draft expenses can be edited");
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
entity.FK_CategoryId = request.CategoryId;
entity.FK_WarehouseId = request.WarehouseId;
entity.Amount = request.Amount;
entity.BillDate = request.BillDate.Date;
entity.DueDate = request.DueDate?.Date;
entity.PeriodCovered = request.PeriodCovered?.Date;
entity.FK_VendorId = request.VendorId;
entity.Payee = request.Payee;
entity.BillRef = request.BillRef;
entity.Description = request.Description;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status != ExpenseStatus.Draft)
throw new InvalidOperationException("Only Draft expenses can be posted");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
entity.Status = ExpenseStatus.Posted;
entity.PostedByUserId = userId;
entity.PostedAt = DateTime.Now;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status != ExpenseStatus.Posted)
throw new InvalidOperationException("Only Posted expenses can be paid");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
await uow.BeginAsync(ct);
try
{
await ApplyPaymentAsync(entity, payment, userId, ct);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status == ExpenseStatus.Void)
throw new InvalidOperationException("Already voided");
if (entity.Status == ExpenseStatus.Paid)
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
entity.Status = ExpenseStatus.Void;
entity.VoidedByUserId = userId;
entity.VoidedAt = DateTime.Now;
entity.VoidReason = reason;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
// ─── helpers ──────────────────────────────────────────────────────────────
private static void ValidateBase(CreateExpenseRequest request)
{
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
throw new InvalidOperationException("Due date cannot be earlier than bill date");
}
/// <summary>
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
/// Cheque → consume an Unused ChequePage from an Active cheque book.
/// </summary>
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
{
if (payment.Method == PaymentMethod.Credit)
throw new InvalidOperationException("Credit is not a valid method for expense payments");
if (payment.Method == PaymentMethod.Cash)
{
if (payment.BankAccountId is null)
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
if (account.IsActive is false)
throw new InvalidOperationException("The selected account is inactive");
entity.FK_BankAccountId = account.Id;
}
else // Cheque
{
if (payment.ChequePageId is null)
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
var page = await context.ChequePages
.Include(p => p.ChequeBook)
.FirstOrDefaultAsync(p => p.Id == payment.ChequePageId.Value, ct)
?? throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
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");
page.IssueStatus = ChequePageIssueStatus.Issued;
page.PayeeType = PayeeType.Other;
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
page.IssueDate = DateTime.Now;
page.Amount = entity.Amount;
page.CurrencyCode = "LKR";
page.PrintedAt = DateTime.Now;
chequeBookRepo.UpdatePage(page);
entity.FK_ChequePageId = page.Id;
entity.ChequePage = page;
}
entity.Method = payment.Method;
entity.Status = ExpenseStatus.Paid;
entity.PaidByUserId = userId;
entity.PaidAt = DateTime.Now;
// TODO ledger: when accounting is wired in, post a journal entry here —
// Debit Expense (category)
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
}
}
@@ -1,24 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Expense;
public interface IExpenseRepository
{
Task<Models.Expense?> GetByIdAsync(Guid id);
Task<List<Models.Expense>> ListAsync(
ExpenseStatus? status,
Guid? categoryId,
Guid? warehouseId,
DateTime? from,
DateTime? to,
int page,
int pageSize);
Task<int> CountAsync(
ExpenseStatus? status,
Guid? categoryId,
Guid? warehouseId,
DateTime? from,
DateTime? to);
Task AddAsync(Models.Expense entity);
void Update(Models.Expense entity);
}
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ExpenseCategory;
[ApiController]
[Route("api/expense-categories")]
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
{
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
[HttpPost]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,19 +0,0 @@
namespace Distribution.Features.ExpenseCategory;
public record ExpenseCategoryResponse(
Guid Id,
string Name,
string? Code,
Guid? ParentCategoryId,
bool IsActive,
List<ExpenseCategoryResponse> Children);
public record CreateExpenseCategoryRequest(
string Name,
string? Code,
Guid? ParentCategoryId);
public record UpdateExpenseCategoryRequest(
string Name,
string? Code,
bool IsActive);
@@ -1,19 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ExpenseCategory;
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
{
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
public async Task AddAsync(Models.ExpenseCategory entity) =>
await context.ExpenseCategories.AddAsync(entity);
public void Update(Models.ExpenseCategory entity) =>
context.ExpenseCategories.Update(entity);
}
@@ -1,64 +0,0 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.ExpenseCategory;
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
{
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
{
var all = await repo.GetAllAsync();
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
return all
.Where(c => c.FK_ParentCategoryId is null)
.Select(c => Map(c, byParent))
.ToList();
}
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new InvalidOperationException("Name is required");
if (request.ParentCategoryId is { } parentId)
{
var parent = await repo.GetByIdAsync(parentId)
?? throw new InvalidOperationException("Parent category not found");
if (parent.FK_ParentCategoryId is not null)
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
}
var entity = new Models.ExpenseCategory
{
Name = request.Name.Trim(),
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
FK_ParentCategoryId = request.ParentCategoryId
};
await uow.BeginAsync(ct);
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
}
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
entity.Name = request.Name.Trim();
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
entity.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
}
}
@@ -1,11 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ExpenseCategory;
public interface IExpenseCategoryRepository
{
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
Task<List<Models.ExpenseCategory>> GetAllAsync();
Task AddAsync(Models.ExpenseCategory entity);
void Update(Models.ExpenseCategory entity);
}
@@ -1,37 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ExpenseVendor;
[ApiController]
[Route("api/expense-vendors")]
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
{
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> List(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50) =>
Ok(await service.ListAsync(search, page, pageSize));
[HttpPost]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest 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 }); }
}
}
@@ -1,7 +0,0 @@
namespace Distribution.Features.ExpenseVendor;
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
@@ -1,31 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ExpenseVendor;
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
{
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
await context.ExpenseVendors
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
.OrderBy(v => v.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountAsync(string? search) =>
await context.ExpenseVendors
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
public async Task AddAsync(Models.ExpenseVendor entity) =>
await context.ExpenseVendors.AddAsync(entity);
public void Update(Models.ExpenseVendor entity) =>
context.ExpenseVendors.Update(entity);
}
@@ -1,59 +0,0 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.ExpenseVendor;
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
{
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 50;
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
var items = await repo.ListAsync(search, page, pageSize);
var total = await repo.CountAsync(search);
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
}
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
{
var name = (request.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
if (await repo.NameExistsAsync(name, null))
throw new InvalidOperationException("A vendor with this name already exists");
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
await uow.BeginAsync(ct);
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(entity);
}
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
var name = (request.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
if (await repo.NameExistsAsync(name, id))
throw new InvalidOperationException("A vendor with this name already exists");
entity.Name = name;
entity.ContactInfo = request.ContactInfo;
entity.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(entity);
}
}
@@ -1,11 +0,0 @@
namespace Distribution.Features.ExpenseVendor;
public interface IExpenseVendorRepository
{
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
Task<int> CountAsync(string? search);
Task<bool> NameExistsAsync(string name, Guid? excludeId);
Task AddAsync(Models.ExpenseVendor entity);
void Update(Models.ExpenseVendor entity);
}
+2 -24
View File
@@ -1,5 +1,4 @@
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn; namespace Distribution.Features.Grn;
@@ -8,7 +7,6 @@ namespace Distribution.Features.Grn;
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{ {
[HttpGet("api/goods-receipts")] [HttpGet("api/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetAll( public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId, [FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1, [FromQuery] int page = 1,
@@ -19,7 +17,6 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
} }
[HttpGet("api/goods-receipts/{id:guid}")] [HttpGet("api/goods-receipts/{id:guid}")]
[Authorize("grn.view")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
var result = await service.GetByIdAsync(id); var result = await service.GetByIdAsync(id);
@@ -27,7 +24,6 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
} }
[HttpPost("api/goods-receipts")] [HttpPost("api/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct) public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{ {
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware // 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" }); if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try try
{ {
var inner = new CreateGrnRequest(request.WarehouseId, request.Items, var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct); var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } 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")] [HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) => public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id)); Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")] [HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct) public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? request.ReceivedByUserId; var userId = currentUser.UserId ?? request.ReceivedByUserId;
+7 -76
View File
@@ -1,104 +1,35 @@
namespace Distribution.Features.Grn; namespace Distribution.Features.Grn;
/// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary> public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemRequest(
// Set for a PO-based receipt; null for a direct receipt (ItemId is used instead).
Guid? PurchaseOrderItemId,
int AcceptedQuantity,
int RejectedQuantity,
// Populated only when the item has TracksBatches and/or TracksExpiryDate set.
List<GrnBatchLineRequest>? Batches = null,
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order).
Guid? ItemId = null,
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
decimal? UnitCost = null,
// Null falls back to the GRN-wide percentage.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0,
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
decimal? NbtPercent = null);
public record CreateGrnRequest( public record CreateGrnRequest(
Guid WarehouseId, Guid WarehouseId,
List<GrnItemRequest> Items, List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null, Guid? ReceivedByUserId = null);
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest( public record CreateFlatGrnRequest(
Guid PurchaseOrderId, Guid PurchaseOrderId,
Guid WarehouseId, Guid WarehouseId,
List<GrnItemRequest> Items, List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null, Guid? ReceivedByUserId = null);
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
Guid? SupplierId = null,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemResponse( public record GrnItemResponse(
Guid Id, Guid Id,
Guid? PurchaseOrderItemId, Guid PurchaseOrderItemId,
Guid ItemId, Guid ItemId,
string Sku, string Sku,
string ItemName, string ItemName,
int AcceptedQuantity, int AcceptedQuantity,
int RejectedQuantity, int RejectedQuantity);
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers,
decimal UnitCost,
decimal DiscountPercent,
decimal VatPercent,
// Accepted qty x unit cost, before discount.
decimal GrossAmount,
decimal DiscountAmount,
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
public record GrnResponse( public record GrnResponse(
Guid Id, Guid Id,
Guid? PurchaseOrderId, Guid PurchaseOrderId,
Guid ReceivedByUserId, Guid ReceivedByUserId,
Guid WarehouseId, Guid WarehouseId,
string WarehouseName, string WarehouseName,
Guid? SupplierId,
string? SupplierName,
DateTime ReceivedAt, DateTime ReceivedAt,
DateTime CreatedAt, DateTime CreatedAt,
List<GrnItemResponse> Items, List<GrnItemResponse> Items);
decimal DiscountPercent,
decimal VatPercent,
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
-37
View File
@@ -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) => public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts await context.GoodsReceipts
.Include(gr => gr.Warehouse) .Include(gr => gr.Warehouse)
.Include(gr => gr.Supplier) .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.FirstOrDefaultAsync(gr => gr.Id == id); .FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) => public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts await context.GoodsReceipts
.Include(gr => gr.Warehouse) .Include(gr => gr.Warehouse)
.Include(gr => gr.Supplier) .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId)) .Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt) .OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
@@ -37,11 +29,7 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts await context.GoodsReceipts
.Include(gr => gr.Warehouse) .Include(gr => gr.Warehouse)
.Include(gr => gr.Supplier) .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive) .Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt) .OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync(); .ToListAsync();
@@ -55,8 +43,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) => public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem) .Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive) .Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId) .GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) }) .Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total); .ToDictionaryAsync(x => x.ItemId, x => x.Total);
} }
+41 -382
View File
@@ -1,6 +1,5 @@
using Distribution.Features.Po; using Distribution.Features.Po;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
@@ -10,88 +9,15 @@ public class GrnService(
IGrnRepository repo, IGrnRepository repo,
IPoRepository poRepo, IPoRepository poRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
IUnitOfWork uow, IUnitOfWork uow)
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
{ {
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
{ new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line. i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
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);
}
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary> private static GrnResponse Map(Models.GoodsReceipt gr) =>
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity; 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());
private static GrnResponse Map(Models.GoodsReceipt gr)
{
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
var nbt = items.Sum(i => i.NbtAmount);
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
gr.NbtPercent, nbt);
}
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
/// <summary>
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
/// forced to 0 whatever the client sent.
/// </summary>
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
{
var discount = discountPercent ?? 0m;
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
}
/// <summary>
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
/// </summary>
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
{
if (mode == TaxMode.Disabled) return 0m;
var value = lineValue ?? headerValue;
if (value is null)
{
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
return 0m;
}
GrnPricing.ValidatePercent(value.Value, label);
return value.Value;
}
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
GrnHeader header, GrnTaxSettings tax, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
}
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) 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) => public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); (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> /// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/ /// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free /// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
/// units don't count against the PO's ordered quantity), creates /// transitions the Po to PartiallyReceived/Received. All in one unit of work.
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// </summary> /// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default) 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) if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item."); 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 var goodsReceipt = new Models.GoodsReceipt
{ {
FK_PurchaseOrderId = po.Id, FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId, FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId, FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now, ReceivedAt = DateTime.Now
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
}; };
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
foreach (var line in request.Items) 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) 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."); ?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (poItem.IsCancelled) if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock."); 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."); throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity) if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity."); $"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; poItem.ReceivedQuantity += line.AcceptedQuantity;
var entity = new Models.GoodsReceiptItem goodsReceipt.Items.Add(new Models.GoodsReceiptItem
{ {
FK_PurchaseOrderItemId = poItem.Id, FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity, AcceptedQuantity = line.AcceptedQuantity,
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity 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(); var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
@@ -245,238 +94,48 @@ public class GrnService(
? PurchaseOrderStatus.Received ? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived; : 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); await uow.BeginAsync(ct);
try try
{ {
await repo.AddAsync(goodsReceipt); 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 = poItem.FK_ItemId,
{ LocationId = request.WarehouseId,
FK_ItemId = item.Id, LocationType = LocationType.Warehouse,
SerialNumber = serialNumber, Quantity = line.AcceptedQuantity
Status = SerialStatus.InStock, });
CurrentLocationId = warehouseId,
CurrentLocationType = LocationType.Warehouse,
ReceivedAt = goodsReceipt.ReceivedAt
};
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
// stays null until the sale/delivery flow sets it.
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
await stockRepo.AddItemSerialNumberAsync(serial);
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1
});
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemSerialNumberId = serial.Id,
Quantity = 1,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
foreach (var b in line.Batches!)
{
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
: null;
if (batch is null)
{
batch = new ItemBatch
{
FK_ItemId = item.Id,
BatchNumber = b.BatchNumber,
ExpiryDate = b.ExpiryDate
};
await stockRepo.AddItemBatchAsync(batch);
}
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity
});
}
else
{
balance.Quantity += b.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
} }
else else
{ {
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId); balance.Quantity += line.AcceptedQuantity;
if (balance is null) balance.UpdatedAt = DateTime.Now;
{ stockRepo.UpdateBalance(balance);
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
});
} }
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); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
@@ -1,20 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
public interface IInvoiceRepository public interface IInvoiceRepository
{ {
Task<Models.Invoice?> GetByIdAsync(Guid id); Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId); Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize); Task AddAsync(Models.Invoice entity);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus); void Update(Models.Invoice entity);
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);
} }
@@ -1,34 +1,11 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
[ApiController] [ApiController]
[Route("api/invoices")] [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}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
@@ -37,80 +14,35 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
} }
[HttpPost] [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 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); 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 }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpPost("{id:guid}/print")] [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 try
{ {
var result = await service.PrintAsync(id, userId.Value, ct); var result = await service.PrintAsync(id, ct);
return Ok(result); return result is null ? NotFound() : Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
[HttpPost("{id:guid}/finalize")] [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 try
{ {
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId.Value, ct); var result = await service.FinalizeAsync(id, request, ct);
return Ok(result); return result is null ? NotFound() : Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
} }
} }
+13 -50
View File
@@ -1,66 +1,29 @@
using Distribution.Features.ShopPO;
using Distribution.Models; using Distribution.Models;
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null); public record CreateInvoiceRequest(Guid OrderId);
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);
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line. public record FinalizeInvoiceItemRequest(Guid OrderItemId, decimal DeliveredQuantity, decimal FreeQuantity);
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 PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null); public record FinalizeInvoiceReturnRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
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 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( public record FinalizeInvoiceRequest(
Guid Id, List<FinalizeInvoiceItemRequest> Items,
int VersionNumber, List<FinalizeInvoiceReturnRequest>? Returns,
InvoiceVersionStage Stage, FinalizeInvoiceDiscountRequest? Discount);
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 InvoiceResponse( public record InvoiceResponse(
Guid Id, Guid Id,
Guid ShopPOId, Guid OrderId,
Guid StoreId,
string StoreName,
InvoiceStatus Status, InvoiceStatus Status,
PaymentStatus PaymentStatus, Guid CreatedByUserId,
Guid CreatedBy,
DateTime? PrintedAt, DateTime? PrintedAt,
Guid? PrintedBy, Guid? PrintedByUserId,
DateTime? FinalizedAt, DateTime? FinalizedAt,
Guid? FinalizedBy, Guid? FinalizedByUserId,
decimal TotalAmount, decimal TotalAmount,
decimal DiscountAmount,
decimal PaidAmount,
bool IsActive, bool IsActive,
DateTime CreatedAt, DateTime CreatedAt);
List<InvoiceItemResponse> Items,
List<ShopPODiscountResponse>? Discounts = null);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -1,5 +1,4 @@
using Distribution.Data; using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
@@ -7,79 +6,14 @@ namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{ {
public async Task<Models.Invoice?> GetByIdAsync(Guid id) => public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices await context.Invoices.FindAsync(id);
.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);
public async Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId) => public async Task<Models.Invoice?> GetByOrderIdAsync(Guid orderId) =>
await context.Invoices await context.Invoices.FirstOrDefaultAsync(i => i.FK_OrderId == orderId && i.IsActive);
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) => public async Task AddAsync(Models.Invoice entity) =>
await context.Invoices await context.Invoices.AddAsync(entity);
.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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus) => public void Update(Models.Invoice entity) =>
await context.Invoices context.Invoices.Update(entity);
.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);
} }
+152 -294
View File
@@ -1,344 +1,202 @@
using Distribution.Features.Scheme; using Distribution.Features.Item;
using Distribution.Features.ShopPO; using Distribution.Features.Order;
using Distribution.Features.Stock; using Distribution.Features.Returns;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Invoice; namespace Distribution.Features.Invoice;
public class InvoiceService( public class InvoiceService(
IInvoiceRepository repo, IInvoiceRepository repo,
IShopPORepository shopPoRepo, IOrderRepository orderRepo,
SchemeService schemeService, IReturnRepository returnRepo,
IItemRepository itemRepo,
IUnitOfWork uow, IUnitOfWork uow,
ICurrentUserService currentUser) ICurrentUserService currentUser,
IConfiguration configuration)
{ {
private static InvoiceResponse ToResponse(Models.Invoice i) => private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId, new(i.Id, i.FK_OrderId, i.Status, i.CreatedByUserId, i.PrintedAt, i.PrintedByUserId,
i.ShopPO?.FK_StoreId ?? Guid.Empty, i.FinalizedAt, i.FinalizedByUserId, i.TotalAmount, i.IsActive, i.CreatedAt);
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());
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) => private bool IsModeB =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice); configuration.GetValue<string>("OrderInvoice:InvoiceCreationMode") != "A";
// 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();
public async Task<InvoiceResponse?> GetByIdAsync(Guid id) public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{ {
var invoice = await repo.GetByIdAsync(id); var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null; return invoice is null || !invoice.IsActive ? null : ToResponse(invoice);
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
} }
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); if (!IsModeB)
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize); throw new InvalidOperationException("POST /api/invoices is not enabled — this system uses InvoiceCreationMode A, which creates the Invoice as part of PO confirmation.");
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync() var order = await orderRepo.GetByIdAsync(request.OrderId);
{ if (order is null || !order.IsActive)
var invoices = await repo.GetOutstandingAsync(); throw new InvalidOperationException("Order not found.");
var count = invoices.Count; if (order.ConfirmedAt is null)
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount)); throw new InvalidOperationException("Only a Confirmed PO can have an Invoice created for it.");
return new OutstandingPaymentsResponse(count, outstanding); if (order.InvoiceId is not null)
} throw new InvalidOperationException("An Invoice already exists for this PO.");
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 invoice = new Models.Invoice var invoice = new Models.Invoice
{ {
FK_ShopPOId = po.Id, FK_OrderId = order.Id,
Status = InvoiceStatus.Draft, Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending, CreatedByUserId = currentUser.UserId ?? throw new InvalidOperationException("No authenticated user."),
CreatedBy = createdBy TotalAmount = order.TotalAmount,
}; };
var lines = BuildItemsFromShopPO(po, invoice.Id); order.InvoiceId = invoice.Id;
await ApplyTotalsAsync(invoice, lines);
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await repo.AddAsync(invoice); await repo.AddAsync(invoice);
await repo.AddItemsAsync(lines); orderRepo.Update(order);
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
po.InvoiceId = invoice.Id;
shopPoRepo.Update(po);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch
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)
{ {
if (edit.UnitPrice is < 0) await uow.RollbackAsync(ct);
throw new InvalidOperationException("Unit price cannot be negative"); throw;
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)");
} }
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); 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) var invoice = await repo.GetByIdAsync(id);
?? throw new KeyNotFoundException("Invoice not found"); 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) invoice.Status = InvoiceStatus.Printed;
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status"); invoice.PrintedAt = DateTime.Now;
invoice.PrintedByUserId = currentUser.UserId;
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;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
repo.Update(invoice); repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
await uow.CommitAsync(ct); 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); return ToResponse(invoice);
} }
+2 -30
View File
@@ -11,14 +11,7 @@ public record CreateItemRequest(
Guid? BrandId, Guid? BrandId,
Guid BaseUomId, Guid BaseUomId,
decimal? SalePrice, decimal? SalePrice,
List<UomConversionRequest>? Conversions, List<UomConversionRequest>? Conversions);
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
public record UpdateItemRequest( public record UpdateItemRequest(
string Name, string Name,
@@ -28,14 +21,7 @@ public record UpdateItemRequest(
Guid? BrandId, Guid? BrandId,
Guid BaseUomId, Guid BaseUomId,
decimal? SalePrice, decimal? SalePrice,
bool IsActive = true, bool IsActive = true);
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions); public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
@@ -54,13 +40,6 @@ public record ItemSummaryResponse(
Guid BaseUomId, Guid BaseUomId,
string BaseUomName, string BaseUomName,
decimal? SalePrice, decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource, Models.SyncSource SyncSource,
bool IsActive, bool IsActive,
DateTime CreatedAt); DateTime CreatedAt);
@@ -79,13 +58,6 @@ public record ItemDetailResponse(
Guid BaseUomId, Guid BaseUomId,
string BaseUomName, string BaseUomName,
decimal? SalePrice, decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource, Models.SyncSource SyncSource,
bool IsActive, bool IsActive,
DateTime CreatedAt, DateTime CreatedAt,
+3 -61
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Item; 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) => private static UomConversionResponse MapConversion(Models.UomConversion c) =>
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor); 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_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name, i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name, i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.ReorderLevel, i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) => private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description, 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_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name, i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name, i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.ReorderLevel, i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt,
i.Conversions.Select(MapConversion).ToList()); 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) => public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
(await repo.GetAllAsync(categoryId, subCategoryId, brandId)) (await repo.GetAllAsync(categoryId, subCategoryId, brandId))
.Select(MapSummary) .Select(MapSummary)
@@ -79,9 +41,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
if (await repo.SkuExistsAsync(request.Sku)) if (await repo.SkuExistsAsync(request.Sku))
throw new InvalidOperationException($"SKU '{request.Sku}' already exists."); 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 var item = new Models.Item
{ {
Sku = request.Sku, Sku = request.Sku,
@@ -92,13 +51,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
FK_BrandId = request.BrandId, FK_BrandId = request.BrandId,
FK_BaseUomId = request.BaseUomId, FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice, 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 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 is null) return null;
if (item.SyncSource == Models.SyncSource.Erp) 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.Name = request.Name;
item.Description = request.Description; item.Description = request.Description;
item.FK_CategoryId = request.CategoryId; 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_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId; item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice; 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; item.IsActive = request.IsActive;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -6,12 +6,11 @@ public interface ILoadingSheetRepository
{ {
Task<LoadingSheet?> GetByIdAsync(Guid id); Task<LoadingSheet?> GetByIdAsync(Guid id);
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId); Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize); Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId); Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet); Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet); void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items); void RemoveItems(IEnumerable<LoadingSheetItem> items);
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items); Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds);
Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds);
} }

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