Compare commits

..

2 Commits

Author SHA1 Message Date
DeepnaPooja a2d9ba4183 Add testbug.md 2026-09-01 17:21:31 +05:30
DeepnaPooja dd1d93d032 Add validations 2026-09-01 16:11:36 +05:30
619 changed files with 7634 additions and 221391 deletions
-89
View File
@@ -1,89 +0,0 @@
name: CI/CD - ERP Distribution
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Restore
run: dotnet restore Distribution/Distribution.csproj
- name: Publish
run: |
rm -rf /tmp/erpdistribution-deploy
mkdir -p /tmp/erpdistribution-deploy
dotnet publish Distribution/Distribution.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpdistribution-deploy
- name: Stop service
run: systemctl stop erpdistribution.service
- name: Copy to deploy folder
run: |
mkdir -p /home/distribution/core/publish
rm -rf /home/distribution/core/publish/*
cp -r /tmp/erpdistribution-deploy/* /home/distribution/core/publish/
- name: Fix ownership and permissions
run: |
chown -R distributionuser:distributionuser /home/distribution/core/publish
find /home/distribution/core/publish -type d -exec chmod 755 {} +
find /home/distribution/core/publish -type f -exec chmod 644 {} +
chmod +x /home/distribution/core/publish/Distribution
- name: Restart service
run: systemctl restart erpdistribution.service
- name: Verify
run: |
systemctl is-active erpdistribution.service && echo "✅ Service running" || echo "❌ Service failed"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
--build-arg NEXT_PUBLIC_API_URL=https://distribution.hexdive.com/api \
-t erpdistributionfe:latest \
./distribution_fe
- name: Stop & remove existing container
run: |
docker stop erpdistributionfe 2>/dev/null || true
docker rm erpdistributionfe 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpdistributionfe \
-p 8801:3000 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpdistributionfe:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpdistributionfe --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpdistributionfe && echo "✅ Container running" || echo "❌ Container failed"
@@ -1,209 +0,0 @@
using Distribution.Features.Notifications;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Distribution.Tests.Services;
public class NotificationServiceTests
{
private readonly Mock<INotificationRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
private readonly Mock<IHubClients> _clients = new();
private readonly Mock<IClientProxy> _proxy = new();
private readonly NotificationService _sut;
public NotificationServiceTests()
{
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
_sut = new NotificationService(
_repo.Object,
new NotificationRecipientResolver(_repo.Object),
_hub.Object,
_uow.Object,
NullLogger<NotificationService>.Instance);
}
private static NotificationArgs Args(Guid? actor = null) =>
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
// Registry
[Fact]
public void EveryNotificationTypeConstant_HasADefinition()
{
var constants = typeof(NotificationTypes)
.GetFields()
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToList();
Assert.NotEmpty(constants);
Assert.Equal(constants.Count, constants.Distinct().Count());
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
}
// NotifyAsync
[Fact]
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
{
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
{
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_ExcludesTheActingUser()
{
var actor = Guid.NewGuid();
var other = Guid.NewGuid();
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
[RecipientRule.Users(actor, other)], Args(actor));
Assert.NotNull(saved);
var recipient = Assert.Single(saved!.Recipients);
Assert.Equal(other, recipient.FK_UserId);
}
[Fact]
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
{
var a = Guid.NewGuid();
var b = Guid.NewGuid();
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
_clients.Verify(c => c.User(a.ToString()), Times.Once);
_clients.Verify(c => c.User(b.ToString()), Times.Once);
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
{
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
var ex = await Record.ExceptionAsync(() =>
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
Assert.Null(ex);
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
{
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("socket gone"));
var ex = await Record.ExceptionAsync(() =>
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
Assert.Null(ex);
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
}
// Recipient rules
[Fact]
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
{
var both = Guid.NewGuid();
var permissionOnly = Guid.NewGuid();
var scopeOnly = Guid.NewGuid();
var storeId = Guid.NewGuid();
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
var ids = await new NotificationRecipientResolver(_repo.Object)
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
Assert.Equal([both], ids);
}
[Fact]
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
{
var user = Guid.NewGuid();
var supervisor = Guid.NewGuid();
var resolver = new NotificationRecipientResolver(_repo.Object);
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
}
[Fact]
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
{
var storeId = Guid.NewGuid();
var owner = Guid.NewGuid();
var resolver = new NotificationRecipientResolver(_repo.Object);
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
}
[Fact]
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
{
Models.Notification? saved = null;
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
.Callback<Models.Notification>(n => saved = n)
.Returns(Task.CompletedTask);
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
}
[Fact]
public async Task Resolve_DeduplicatesUsersAcrossRules()
{
var user = Guid.NewGuid();
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
var ids = await new NotificationRecipientResolver(_repo.Object)
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
Assert.Equal([user], ids);
}
}
@@ -18,7 +18,6 @@ public class RegionServiceTests
public RegionServiceTests()
{
_currentUser.Setup(u => u.RoleLevel).Returns(60);
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
}
@@ -149,123 +148,6 @@ public class RegionServiceTests
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
// Region code
[Fact]
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0001", result.Code);
}
[Fact]
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
Assert.Equal("REG-0010", result.Code);
}
[Fact]
public async Task CreateAsync_StoresProvidedCode_AsEntered()
{
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
Assert.Equal("nth-1", result.Code);
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
}
[Fact]
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
{
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task GetNextCodeAsync_ReturnsSuggestion()
{
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
var result = await _sut.GetNextCodeAsync();
Assert.Equal("REG-0004", result.Code);
}
[Fact]
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
Assert.Equal("north-1", result!.Code);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
Assert.Equal("REG-0001", result!.Code);
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
}
[Fact]
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task UpdateAsync_AllowsResubmittingOwnCode()
{
var region = ActiveRegion();
region.Code = "REG-0001";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
Assert.Equal("REG-0001", result!.Code);
}
[Fact]
public async Task GetByIdAsync_ReturnsCode()
{
var region = ActiveRegion();
region.Code = "REG-0007";
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
var result = await _sut.GetByIdAsync(region.Id);
Assert.Equal("REG-0007", result!.Code);
}
// DeleteAsync
[Fact]
@@ -313,93 +195,4 @@ public class RegionServiceTests
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
}
// Region manager
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
{
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
Role = new Role { Name = role, Level = 50 }
};
[Fact]
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
{
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
}
[Fact]
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
{
var region = ActiveRegion();
var user = ManagerUser("SalesRep");
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
_uow.Verify(u => u.CommitAsync(default), Times.Never);
}
[Fact]
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
{
var region = ActiveRegion();
var user = ManagerUser();
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
Assert.Equal(user.Id, result!.UserId);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
_uow.Verify(u => u.CommitAsync(default), Times.Once);
}
[Fact]
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
{
var region = ActiveRegion();
var user = ManagerUser();
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
}
[Fact]
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
{
var region = ActiveRegion();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
Assert.False(await _sut.RemoveManagerAsync(region.Id));
}
[Fact]
public async Task GetManagerAsync_ReturnsManager()
{
var region = ActiveRegion();
var user = ManagerUser();
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
var result = await _sut.GetManagerAsync(region.Id);
Assert.Equal("mgr", result!.Username);
}
}
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aa7ea37ab61a149a38d83283c5dabd76bf5a39b3")]
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
5c052f1f25bc37b6a51a3abe0e2b79d99d21422d5ab89151faea450c0ddb9cc0
@@ -11,7 +11,7 @@ build_property.EnforceExtendedAnalyzerRules =
build_property.EntryPointFilePath =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Distribution.Tests
build_property.ProjectDir = D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\
build_property.ProjectDir = C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 9.0
@@ -1,23 +1,23 @@
{
"format": 1,
"restore": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {}
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {}
},
"projects": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectName": "Distribution.Tests",
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
@@ -26,7 +26,6 @@
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -34,8 +33,8 @@
"framework": "net9.0",
"targetAlias": "net9.0",
"projectReferences": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj"
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj"
}
}
}
@@ -50,7 +49,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
"SdkAnalysisLevel": "10.0.300"
},
"frameworks": {
"net9.0": {
@@ -98,24 +97,24 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
}
}
},
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj",
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj",
"projectName": "Distribution",
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj",
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\obj\\",
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj",
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
@@ -124,7 +123,6 @@
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -144,7 +142,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
"SdkAnalysisLevel": "10.0.300"
},
"frameworks": {
"net9.0": {
@@ -193,7 +191,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
}
}
}
@@ -5,12 +5,12 @@
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\dell\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\lenovo\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\dell\.nuget\packages\" />
<SourceRoot Include="C:\Users\lenovo\.nuget\packages\" />
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
</ItemGroup>
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
@@ -22,7 +22,7 @@
<Import Project="$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props" Condition="Exists('$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props')" />
</ImportGroup>
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Pkgxunit_analyzers Condition=" '$(Pkgxunit_analyzers)' == '' ">C:\Users\dell\.nuget\packages\xunit.analyzers\1.18.0</Pkgxunit_analyzers>
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\dell\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
<Pkgxunit_analyzers Condition=" '$(Pkgxunit_analyzers)' == '' ">C:\Users\lenovo\.nuget\packages\xunit.analyzers\1.18.0</Pkgxunit_analyzers>
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\lenovo\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
</PropertyGroup>
</Project>
+10 -11
View File
@@ -2542,23 +2542,23 @@
]
},
"packageFolders": {
"C:\\Users\\dell\\.nuget\\packages\\": {},
"C:\\Users\\lenovo\\.nuget\\packages\\": {},
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectName": "Distribution.Tests",
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\obj\\",
"projectStyle": "PackageReference",
"fallbackFolders": [
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
],
"configFilePaths": [
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
],
@@ -2567,7 +2567,6 @@
],
"sources": {
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
"C:\\Program Files\\dotnet\\library-packs": {},
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
@@ -2575,8 +2574,8 @@
"framework": "net9.0",
"targetAlias": "net9.0",
"projectReferences": {
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj"
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj"
}
}
}
@@ -2591,7 +2590,7 @@
"auditLevel": "low",
"auditMode": "direct"
},
"SdkAnalysisLevel": "10.0.400"
"SdkAnalysisLevel": "10.0.300"
},
"frameworks": {
"net9.0": {
@@ -2639,7 +2638,7 @@
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
}
}
}
+50 -50
View File
@@ -1,57 +1,57 @@
{
"version": 2,
"dgSpecHash": "JPCR3IFZHKY=",
"dgSpecHash": "XdH8cqlThLk=",
"success": true,
"projectFilePath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"projectFilePath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
"expectedPackageFiles": [
"C:\\Users\\dell\\.nuget\\packages\\castle.core\\5.1.1\\castle.core.5.1.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\coverlet.collector\\6.0.2\\coverlet.collector.6.0.2.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.aspnetcore.authentication.jwtbearer\\9.0.0\\microsoft.aspnetcore.authentication.jwtbearer.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.aspnetcore.openapi\\9.0.0\\microsoft.aspnetcore.openapi.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.codecoverage\\17.12.0\\microsoft.codecoverage.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore\\9.0.0\\microsoft.entityframeworkcore.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\9.0.0\\microsoft.entityframeworkcore.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\9.0.0\\microsoft.entityframeworkcore.analyzers.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\9.0.0\\microsoft.entityframeworkcore.relational.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\9.0.0\\microsoft.extensions.caching.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.caching.memory\\9.0.0\\microsoft.extensions.caching.memory.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\9.0.0\\microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\9.0.0\\microsoft.extensions.dependencyinjection.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\9.0.0\\microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.logging\\9.0.0\\microsoft.extensions.logging.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\9.0.0\\microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.options\\9.0.0\\microsoft.extensions.options.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.primitives\\9.0.0\\microsoft.extensions.primitives.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.abstractions\\8.0.1\\microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.jsonwebtokens\\8.0.1\\microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.logging\\8.0.1\\microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.protocols\\8.0.1\\microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.protocols.openidconnect\\8.0.1\\microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.tokens\\8.0.1\\microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.net.test.sdk\\17.12.0\\microsoft.net.test.sdk.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.openapi\\1.6.22\\microsoft.openapi.1.6.22.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.testplatform.objectmodel\\17.12.0\\microsoft.testplatform.objectmodel.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\microsoft.testplatform.testhost\\17.12.0\\microsoft.testplatform.testhost.17.12.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\moq\\4.20.72\\moq.4.20.72.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\mysqlconnector\\2.4.0\\mysqlconnector.2.4.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\newtonsoft.json\\13.0.1\\newtonsoft.json.13.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\pomelo.entityframeworkcore.mysql\\9.0.0\\pomelo.entityframeworkcore.mysql.9.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore\\7.3.1\\swashbuckle.aspnetcore.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\7.3.1\\swashbuckle.aspnetcore.swagger.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\7.3.1\\swashbuckle.aspnetcore.swaggergen.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\7.3.1\\swashbuckle.aspnetcore.swaggerui.7.3.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\system.diagnostics.eventlog\\6.0.0\\system.diagnostics.eventlog.6.0.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\system.identitymodel.tokens.jwt\\8.0.1\\system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\system.reflection.metadata\\1.6.0\\system.reflection.metadata.1.6.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit\\2.9.3\\xunit.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.abstractions\\2.0.3\\xunit.abstractions.2.0.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.analyzers\\1.18.0\\xunit.analyzers.1.18.0.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.assert\\2.9.3\\xunit.assert.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.core\\2.9.3\\xunit.core.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.extensibility.core\\2.9.3\\xunit.extensibility.core.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.extensibility.execution\\2.9.3\\xunit.extensibility.execution.2.9.3.nupkg.sha512",
"C:\\Users\\dell\\.nuget\\packages\\xunit.runner.visualstudio\\2.8.2\\xunit.runner.visualstudio.2.8.2.nupkg.sha512"
"C:\\Users\\lenovo\\.nuget\\packages\\castle.core\\5.1.1\\castle.core.5.1.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\coverlet.collector\\6.0.2\\coverlet.collector.6.0.2.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.aspnetcore.authentication.jwtbearer\\9.0.0\\microsoft.aspnetcore.authentication.jwtbearer.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.aspnetcore.openapi\\9.0.0\\microsoft.aspnetcore.openapi.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.codecoverage\\17.12.0\\microsoft.codecoverage.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore\\9.0.0\\microsoft.entityframeworkcore.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\9.0.0\\microsoft.entityframeworkcore.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\9.0.0\\microsoft.entityframeworkcore.analyzers.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\9.0.0\\microsoft.entityframeworkcore.relational.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\9.0.0\\microsoft.extensions.caching.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.caching.memory\\9.0.0\\microsoft.extensions.caching.memory.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\9.0.0\\microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\9.0.0\\microsoft.extensions.dependencyinjection.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\9.0.0\\microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.logging\\9.0.0\\microsoft.extensions.logging.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\9.0.0\\microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.options\\9.0.0\\microsoft.extensions.options.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.primitives\\9.0.0\\microsoft.extensions.primitives.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.abstractions\\8.0.1\\microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.jsonwebtokens\\8.0.1\\microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.logging\\8.0.1\\microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.protocols\\8.0.1\\microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.protocols.openidconnect\\8.0.1\\microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.tokens\\8.0.1\\microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.net.test.sdk\\17.12.0\\microsoft.net.test.sdk.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.openapi\\1.6.22\\microsoft.openapi.1.6.22.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.testplatform.objectmodel\\17.12.0\\microsoft.testplatform.objectmodel.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.testplatform.testhost\\17.12.0\\microsoft.testplatform.testhost.17.12.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\moq\\4.20.72\\moq.4.20.72.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\mysqlconnector\\2.4.0\\mysqlconnector.2.4.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\newtonsoft.json\\13.0.1\\newtonsoft.json.13.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\pomelo.entityframeworkcore.mysql\\9.0.0\\pomelo.entityframeworkcore.mysql.9.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore\\7.3.1\\swashbuckle.aspnetcore.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\7.3.1\\swashbuckle.aspnetcore.swagger.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\7.3.1\\swashbuckle.aspnetcore.swaggergen.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\7.3.1\\swashbuckle.aspnetcore.swaggerui.7.3.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\system.diagnostics.eventlog\\6.0.0\\system.diagnostics.eventlog.6.0.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\system.identitymodel.tokens.jwt\\8.0.1\\system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\system.reflection.metadata\\1.6.0\\system.reflection.metadata.1.6.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit\\2.9.3\\xunit.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.abstractions\\2.0.3\\xunit.abstractions.2.0.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.analyzers\\1.18.0\\xunit.analyzers.1.18.0.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.assert\\2.9.3\\xunit.assert.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.core\\2.9.3\\xunit.core.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.extensibility.core\\2.9.3\\xunit.extensibility.core.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.extensibility.execution\\2.9.3\\xunit.extensibility.execution.2.9.3.nupkg.sha512",
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.runner.visualstudio\\2.8.2\\xunit.runner.visualstudio.2.8.2.nupkg.sha512"
],
"logs": []
}
@@ -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"
}
+73 -487
View File
@@ -16,16 +16,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Uom> Uoms { get; set; }
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<ItemBatch> ItemBatches { get; set; }
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<ShopPO> ShopPOs { get; set; }
public DbSet<ShopPOItem> ShopPOItems { get; set; }
public DbSet<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<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
@@ -36,11 +30,13 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
public DbSet<StockBalance> StockBalances { get; set; }
public DbSet<StockMovement> StockMovements { get; set; }
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
public DbSet<PriceList> PriceLists { get; set; }
public DbSet<PriceListItem> PriceListItems { get; set; }
public DbSet<Supplier> Suppliers { get; set; }
public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
@@ -52,8 +48,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<GoodsReceiptItemBatch> GoodsReceiptItemBatches { get; set; }
public DbSet<GoodsReceiptItemSerial> GoodsReceiptItemSerials { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
@@ -67,20 +61,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
public DbSet<BankAccount> BankAccounts { get; set; }
public DbSet<ChequeBook> ChequeBooks { get; set; }
public DbSet<ChequePage> ChequePages { get; set; }
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
public DbSet<SupplierPayment> SupplierPayments { get; set; }
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// --- Composite PKs ---
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
@@ -94,39 +79,27 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<LoadingSheetOrder>()
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
// --- Unique indexes ---
// NOTE: MySQL treats each NULL as distinct in a unique index, so this does not by itself
// prevent duplicate rows for untracked items (both FK columns null) — StockRepository's
// get-then-update pattern is what keeps those to one row per (Item, Location), same as before.
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
.IsUnique();
modelBuilder.Entity<ItemSerialNumber>()
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
// --- User ---
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Vehicle ---
modelBuilder.Entity<StockBalance>()
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
modelBuilder.Entity<Vehicle>()
.HasOne<Warehouse>()
@@ -135,7 +108,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- DeliveryRun ---
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
.WithMany()
.HasForeignKey(r => r.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.DriverProfile)
@@ -143,145 +120,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
// --- ShopPO ---
modelBuilder.Entity<ShopPO>()
.HasOne(p => p.Store)
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(p => p.FK_StoreId)
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
// InvoiceId is a plain column — no FK constraint
modelBuilder.Entity<ShopPO>()
.Property(p => p.InvoiceId)
.HasColumnName("InvoiceId");
// --- ShopPOItem ---
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.ShopPO)
.WithMany(p => p.Items)
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ShopPOItem>()
.HasOne(i => i.Item)
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
// --- Invoice ---
modelBuilder.Entity<Invoice>()
.HasOne(i => i.ShopPO)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.Invoice)
.WithMany(inv => inv.Items)
.HasForeignKey(i => i.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<InvoiceItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersion>()
.HasOne(v => v.Invoice)
.WithMany()
.HasForeignKey(v => v.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersion>()
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
.IsUnique();
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.InvoiceVersion)
.WithMany(v => v.Items)
.HasForeignKey(i => i.FK_InvoiceVersionId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<InvoiceVersionItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasMany(i => i.Payments)
.WithOne(p => p.Invoice)
.HasForeignKey(p => p.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheet ---
modelBuilder.Entity<LoadingSheet>()
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetOrder ---
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.LoadingSheet)
.WithMany(s => s.Orders)
.HasForeignKey(lo => lo.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetOrder>()
.HasOne(lo => lo.ShopPO)
.WithMany()
.HasForeignKey(lo => lo.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict);
// --- LoadingSheetItem ---
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.LoadingSheet)
.WithMany(s => s.Items)
.HasForeignKey(i => i.FK_LoadingSheetId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Warehouse)
.WithMany()
.HasForeignKey(i => i.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.ShopPOItem)
.WithMany()
.HasForeignKey(i => i.FK_ShopPOItemId)
.OnDelete(DeleteBehavior.SetNull)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SchemeRedemption ---
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
@@ -290,11 +152,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.FreeItem)
@@ -302,8 +163,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_FreeItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- Scheme ---
modelBuilder.Entity<Scheme>()
.HasOne(s => s.TriggerItem)
.WithMany()
@@ -332,7 +191,23 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
// --- PurchaseOrder ---
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.SourceWarehouse)
.WithMany()
.HasForeignKey(s => s.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
@@ -352,24 +227,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
// --- GoodsReceipt ---
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder)
.WithMany()
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Supplier)
.WithMany()
.HasForeignKey(gr => gr.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse)
.WithMany()
@@ -388,94 +251,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
// --- ItemBatch / ItemSerialNumber ---
modelBuilder.Entity<ItemBatch>()
.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ItemBatch>()
.HasIndex(b => new { b.FK_ItemId, b.BatchNumber });
modelBuilder.Entity<ItemSerialNumber>()
.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
// --- StockBalance / StockMovement batch & serial links ---
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemBatch)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockBalance>()
.HasOne(sb => sb.ItemSerialNumber)
.WithMany()
.HasForeignKey(sb => sb.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemBatch)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StockMovement>()
.HasOne(sm => sm.ItemSerialNumber)
.WithMany()
.HasForeignKey(sm => sm.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- GoodsReceiptItemBatch / GoodsReceiptItemSerial ---
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.GoodsReceiptItem)
.WithMany(i => i.Batches)
.HasForeignKey(b => b.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemBatch>()
.HasOne(b => b.ItemBatch)
.WithMany()
.HasForeignKey(b => b.FK_ItemBatchId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.GoodsReceiptItem)
.WithMany(i => i.Serials)
.HasForeignKey(s => s.FK_GoodsReceiptItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItemSerial>()
.HasOne(s => s.ItemSerialNumber)
.WithMany()
.HasForeignKey(s => s.FK_ItemSerialNumberId)
.OnDelete(DeleteBehavior.Restrict);
// --- PurchaseReturn ---
modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder)
.WithMany()
@@ -494,15 +269,22 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
// --- SaleReturn ---
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.ShopPO)
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_ShopPOId)
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
@@ -526,16 +308,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
// --- CreditNote ---
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
// --- Claims ---
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
@@ -588,8 +366,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
// --- DeliveryTask ---
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
@@ -597,22 +373,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.ShopPO)
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_ShopPOId)
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
// --- Regional ---
modelBuilder.Entity<Region>()
.Property(r => r.Code)
.HasMaxLength(50);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Code)
.IsUnique();
modelBuilder.Entity<RegionalTarget>()
.HasIndex(t => new { t.FK_RegionId, t.Period })
.IsUnique();
@@ -635,190 +400,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
.HasKey(c => c.Key);
modelBuilder.Entity<SysConfig>()
.Property(c => c.Key)
.HasMaxLength(100);
modelBuilder.Entity<SysConfig>().HasData(
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
);
// --- Payment service: Bank Accounts & Cheques ---
modelBuilder.Entity<BankAccount>()
.Property(a => a.AccountName).HasMaxLength(200);
modelBuilder.Entity<BankAccount>()
.Property(a => a.BankName).HasMaxLength(200);
modelBuilder.Entity<BankAccount>()
.Property(a => a.CashAccountTypeName).HasMaxLength(100);
modelBuilder.Entity<BankAccount>()
.Property(a => a.AccountNumber).HasMaxLength(100);
modelBuilder.Entity<BankAccount>()
.Property(a => a.CurrencyCode).HasMaxLength(10);
modelBuilder.Entity<BankAccount>()
.HasIndex(a => new { a.AccountType, a.AccountName })
.IsUnique();
modelBuilder.Entity<BankAccount>().HasData(new BankAccount
{
Id = new Guid("11111111-1111-1111-1111-000000000001"),
CreatedAt = new DateTime(2026, 10, 19),
IsActive = true,
AccountType = BankAccountType.Cash,
AccountName = "Petty Cash",
CashAccountTypeName = "Petty Cash",
CurrencyCode = "LKR",
IsDefault = true
});
modelBuilder.Entity<ChequeBook>()
.Property(b => b.ChequeBookNo).HasMaxLength(100);
modelBuilder.Entity<ChequeBook>()
.Property(b => b.StartChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequeBook>()
.Property(b => b.EndChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequeBook>()
.HasIndex(b => b.ChequeBookNo).IsUnique();
modelBuilder.Entity<ChequeBook>()
.HasOne(b => b.BankAccount)
.WithMany()
.HasForeignKey(b => b.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ChequePage>()
.Property(p => p.ChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequePage>()
.Property(p => p.CurrencyCode).HasMaxLength(10);
modelBuilder.Entity<ChequePage>()
.HasIndex(p => p.ChequeNo).IsUnique();
modelBuilder.Entity<ChequePage>()
.HasOne(p => p.ChequeBook)
.WithMany(b => b.Pages)
.HasForeignKey(p => p.FK_ChequeBookId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ReceivedCheque>()
.Property(c => c.ChequeNo).HasMaxLength(50);
modelBuilder.Entity<ReceivedCheque>()
.Property(c => c.ReceivedFromName).HasMaxLength(200);
modelBuilder.Entity<ReceivedCheque>()
.HasIndex(c => c.ChequeNo);
modelBuilder.Entity<ReceivedCheque>()
.HasOne(c => c.DepositBankAccount)
.WithMany()
.HasForeignKey(c => c.FK_DepositBankAccountId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SupplierPayment ---
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.Supplier)
.WithMany()
.HasForeignKey(p => p.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.BankAccount)
.WithMany()
.HasForeignKey(p => p.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.ChequePage)
.WithMany()
.HasForeignKey(p => p.FK_ChequePageId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierPayment>()
.Property(p => p.Amount).HasPrecision(18, 2);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasOne(a => a.SupplierPayment)
.WithMany(p => p.Allocations)
.HasForeignKey(a => a.FK_SupplierPaymentId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasOne(a => a.GoodsReceipt)
.WithMany()
.HasForeignKey(a => a.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasIndex(a => a.FK_GoodsReceiptId);
// --- Notifications ---
modelBuilder.Entity<Notification>()
.HasIndex(n => new { n.EntityType, n.EntityId });
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.Notification)
.WithMany(n => n.Recipients)
.HasForeignKey(r => r.FK_NotificationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.FK_UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_UserId, r.IsRead });
modelBuilder.Entity<NotificationRecipient>()
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
.IsUnique();
// --- StoreCreditMovement ---
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Store)
.WithMany()
.HasForeignKey(m => m.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Invoice)
.WithMany()
.HasForeignKey(m => m.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Payment)
.WithMany()
.HasForeignKey(m => m.FK_PaymentId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StoreCreditMovement>()
.Property(m => m.Amount).HasPrecision(18, 2);
modelBuilder.Entity<StoreCreditMovement>()
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
Seed.AdminSeed.Apply(modelBuilder);
}
}
-25
View File
@@ -1,25 +0,0 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class AdminSeed
{
private static readonly DateTime SeedDate = new(2026, 10, 13);
// Same GUID as the AuthHex user — the sub claim from the JWT maps to this Id
internal static readonly Guid SuperAdminUserId = new("00000001-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(new User
{
Id = SuperAdminUserId,
Username = "superadmin",
FullName = "Super Admin",
FK_RoleId = RoleAndPermissionSeed.SuperAdminRoleId,
IsActive = true,
CreatedAt = SeedDate
});
}
}
+3 -9
View File
@@ -16,9 +16,6 @@ public static class CommerceSeed
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
private static readonly Guid Payment2Id = new("47000000-0000-0000-0000-000000000002");
private static readonly Guid Payment3Id = new("47000000-0000-0000-0000-000000000003");
private static readonly Guid Payment4Id = new("47000000-0000-0000-0000-000000000004");
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
@@ -83,18 +80,15 @@ public static class CommerceSeed
);
modelBuilder.Entity<SchemeRedemption>().HasData(
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
new SchemeRedemption { Id = SchemeRedemption1Id, FK_SchemeId = Scheme1Id, FK_OrderId = OperationsDataSeed.Order1Id, FieldInvoiceId = null, DiscountAmount = 0m, FK_FreeItemId = MasterDataSeed.Coke500ItemId, FreeQty = 1m, FreeItemCostValue = 40m, RedeemedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
);
modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true },
new Payment { Id = Payment2Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 1000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 9, 20, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 9, 20, 0), IsActive = true },
new Payment { Id = Payment3Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 800m, Method = PaymentMethod.Cheque, EntryMode = EntryMode.DayEndManual, ChequeNumber = "000456", ChequeDate = new DateTime(2026, 8, 31), CollectedAt = new DateTime(2026, 8, 31, 11, 0, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, RecordedBy = MasterDataSeed.Supervisor1UserId, CreatedAt = new DateTime(2026, 8, 31, 18, 0, 0), IsActive = true },
new Payment { Id = Payment4Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 300m, Method = PaymentMethod.Credit, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 11, 5, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 11, 5, 0), IsActive = true }
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
);
modelBuilder.Entity<SaleReturn>().HasData(
new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
);
modelBuilder.Entity<SaleReturnItem>().HasData(
+18 -14
View File
@@ -7,6 +7,10 @@ public static class MasterDataSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 20);
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
// Implementation Notes in the plan; not derivable from HasData at migration time.
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
@@ -80,8 +84,8 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Region>().HasData(
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Models.Route>().HasData(
@@ -92,12 +96,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<Store>().HasData(
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true }
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Warehouse>().HasData(
@@ -111,12 +115,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<User>().HasData(
new User { Id = Supervisor1UserId, Username = "sunil.fernando",FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera",FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva",FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya",FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara",FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<DriverProfile>().HasData(
+72 -87
View File
@@ -8,20 +8,15 @@ public static class OperationsDataSeed
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
// Kept for OperationsExtrasSeed stock movement reference (ReferenceId on MovementType.Sale)
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO3Id = new("33000000-0000-0000-0000-000000000003");
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid ShopPOItem4Id = new("34000000-0000-0000-0000-000000000004");
internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001");
internal static readonly Guid Invoice2Id = new("34500000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -36,12 +31,16 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ClosedSaleRunId,
RideType = RideType.Sale,
Status = RideStatus.Closed,
FK_VehicleId = MasterDataSeed.VanVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
FK_RouteId = MasterDataSeed.ColomboRouteId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Meterstart = 10500,
Meterend = 10550,
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
@@ -49,101 +48,86 @@ public static class OperationsDataSeed
new DeliveryRun
{
Id = ActiveDeliveryRunId,
Status = RideStatus.Open,
RideType = RideType.Delivery,
Status = RideStatus.Active,
FK_VehicleId = MasterDataSeed.TruckVehicleId,
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
FK_RouteId = MasterDataSeed.KandyRouteId,
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
Meterstart = 20300,
Meterend = 0,
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
ClosedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPO>().HasData(
new ShopPO
modelBuilder.Entity<Sale>().HasData(
new Sale
{
Id = ShopPO1Id,
Id = Sale1Id,
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
RaisedById = MasterDataSeed.SalesRep1UserId,
InvoiceId = Invoice1Id,
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
FK_RideId = ClosedSaleRunId,
TotalAmount = 3000m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
Status = SaleStatus.Completed,
PaymentStatus = PaymentStatus.Paid,
FulfillImmediately = false,
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new Sale
{
Id = Sale2Id,
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
FK_RideId = ActiveDeliveryRunId,
TotalAmount = 1200m,
Status = SaleStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
FulfillImmediately = false,
CompletedAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<Order>().HasData(
new Order
{
Id = Order1Id,
FK_SaleId = Sale1Id,
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
FK_StoreId = MasterDataSeed.FortStoreId,
TotalAmount = 3000m,
Status = OrderStatus.Fulfilled,
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
IsActive = true
},
new ShopPO
new Order
{
Id = ShopPO2Id,
Id = Order2Id,
FK_SaleId = Sale2Id,
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = null,
TotalAmount = 1200m,
Status = ShopPOStatus.Pending,
ConfirmedAt = null,
ConfirmedBy = null,
Status = OrderStatus.Pending,
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true
},
new ShopPO
{
Id = ShopPO3Id,
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = Invoice2Id,
TotalAmount = 2400m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 31, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FulfilledAt = new DateTime(2026, 8, 31, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
}
);
modelBuilder.Entity<ShopPOItem>().HasData(
new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem4Id, FK_ShopPOId = ShopPO3Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 20, QuantityFulfilled = 20, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0), IsActive = true }
);
modelBuilder.Entity<Invoice>().HasData(
new Invoice
{
Id = Invoice1Id,
FK_ShopPOId = ShopPO1Id,
Status = InvoiceStatus.Finalized,
PaymentStatus = PaymentStatus.Paid,
CreatedBy = MasterDataSeed.SalesRep1UserId,
PrintedAt = new DateTime(2026, 8, 25, 8, 45, 0),
PrintedBy = MasterDataSeed.SalesRep1UserId,
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0),
FinalizedBy = MasterDataSeed.Supervisor1UserId,
TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
IsActive = true
},
new Invoice
{
Id = Invoice2Id,
FK_ShopPOId = ShopPO3Id,
Status = InvoiceStatus.Printed,
PaymentStatus = PaymentStatus.PartiallyPaid,
CreatedBy = MasterDataSeed.SalesRep2UserId,
PrintedAt = new DateTime(2026, 8, 31, 9, 15, 0),
PrintedBy = MasterDataSeed.SalesRep2UserId,
FinalizedAt = null,
FinalizedBy = null,
TotalAmount = 2400m,
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
}
modelBuilder.Entity<OrderItem>().HasData(
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
);
modelBuilder.Entity<LoadingSheet>().HasData(
@@ -151,19 +135,20 @@ public static class OperationsDataSeed
{
Id = LoadingSheet1Id,
FK_RideId = ClosedSaleRunId,
Status = LoadingSheetStatus.Confirmed,
PopulatedBy = MasterDataSeed.Supervisor1UserId,
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
Status = LoadingSheetStatus.ReceiptConfirmed,
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
IsActive = true
}
);
modelBuilder.Entity<LoadingSheetItem>().HasData(
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 50m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true }
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
);
modelBuilder.Entity<DeliveryTask>().HasData(
@@ -171,7 +156,7 @@ public static class OperationsDataSeed
{
Id = DeliveryTask1Id,
FK_RideId = ActiveDeliveryRunId,
FK_ShopPOId = ShopPO2Id,
FK_OrderId = Order2Id,
Sequence = 1,
Status = DeliveryTaskStatus.Pending,
CustomerConfirmed = false,
+28 -4
View File
@@ -1,4 +1,3 @@
using Distribution.Features.Stock;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -27,9 +26,13 @@ public static class OperationsExtrasSeed
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
@@ -43,7 +46,7 @@ public static class OperationsExtrasSeed
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<Uom>().HasData(
@@ -77,14 +80,35 @@ public static class OperationsExtrasSeed
);
modelBuilder.Entity<StockMovement>().HasData(
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = null, FromLocationType = null, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), 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 = null, ToLocationType = null, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
);
modelBuilder.Entity<StoreKycDocument>().HasData(
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
);
modelBuilder.Entity<DriverComplianceConfig>().HasData(
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
);
modelBuilder.Entity<RideCancelRequest>().HasData(
new RideCancelRequest
{
Id = RideCancelRequest1Id,
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
TypeCode = CancelRequestType.VehicleBreakdown,
Reason = "Reported minor engine issue, resolved after inspection",
RequestedBy = MasterDataSeed.Driver2UserId,
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
Status = CancelRequestStatus.Rejected,
ApprovedBy = MasterDataSeed.Supervisor1UserId,
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
}
);
modelBuilder.Entity<RouteVisitLog>().HasData(
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
+1 -170
View File
@@ -13,61 +13,6 @@ public static class RoleAndPermissionSeed
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
internal static readonly Guid SuperAdminRoleId = new("10000000-0000-0000-0000-000000000007");
// ── Legacy broad permissions (nav.ts uses these for sidebar visibility) ────
private static readonly Guid PermOrdersView = new("20000000-0000-0000-0000-000000000001");
private static readonly Guid PermRidesView = new("20000000-0000-0000-0000-000000000002");
private static readonly Guid PermLoadingView = new("20000000-0000-0000-0000-000000000003");
private static readonly Guid PermStoresView = new("20000000-0000-0000-0000-000000000004");
private static readonly Guid PermSalesVisitsCheckin = new("20000000-0000-0000-0000-000000000005");
private static readonly Guid PermItemsView = new("20000000-0000-0000-0000-000000000006");
private static readonly Guid PermCategoriesView = new("20000000-0000-0000-0000-000000000007");
private static readonly Guid PermWarehousesView = new("20000000-0000-0000-0000-000000000008");
private static readonly Guid PermPurchaseOrdersView = new("20000000-0000-0000-0000-000000000009");
private static readonly Guid PermPricelistsView = new("20000000-0000-0000-0000-000000000010");
private static readonly Guid PermSchemesView = new("20000000-0000-0000-0000-000000000011");
private static readonly Guid PermSuppliersView = new("20000000-0000-0000-0000-000000000012");
private static readonly Guid PermDriverProfilesView = new("20000000-0000-0000-0000-000000000013");
private static readonly Guid PermComplianceManage = new("20000000-0000-0000-0000-000000000014");
private static readonly Guid PermUsersView = new("20000000-0000-0000-0000-000000000015");
private static readonly Guid PermRolesView = new("20000000-0000-0000-0000-000000000016");
private static readonly Guid PermRegionsView = new("20000000-0000-0000-0000-000000000017");
private static readonly Guid PermRoutesView = new("20000000-0000-0000-0000-000000000018");
private static readonly Guid PermVehiclesView = new("20000000-0000-0000-0000-000000000019");
// ── Granular API permissions (used in [Authorize("...")] on controller actions) ─
private static readonly Guid PermClaimView = new("20000000-0000-0000-0000-000000000020");
private static readonly Guid PermClaimManage = new("20000000-0000-0000-0000-000000000021");
private static readonly Guid PermDeliveryTaskView = new("20000000-0000-0000-0000-000000000022");
private static readonly Guid PermDeliveryTaskManage = new("20000000-0000-0000-0000-000000000023");
private static readonly Guid PermDriverView = new("20000000-0000-0000-0000-000000000024");
private static readonly Guid PermDriverManage = new("20000000-0000-0000-0000-000000000025");
private static readonly Guid PermGrnView = new("20000000-0000-0000-0000-000000000026");
private static readonly Guid PermGrnManage = new("20000000-0000-0000-0000-000000000027");
private static readonly Guid PermLoadingSheetView = new("20000000-0000-0000-0000-000000000028");
private static readonly Guid PermLoadingSheetManage = new("20000000-0000-0000-0000-000000000029");
private static readonly Guid PermPriceListView = new("20000000-0000-0000-0000-000000000030");
private static readonly Guid PermPriceListManage = new("20000000-0000-0000-0000-000000000031");
private static readonly Guid PermPurchaseOrderView = new("20000000-0000-0000-0000-000000000032");
private static readonly Guid PermPurchaseOrderManage= new("20000000-0000-0000-0000-000000000033");
private static readonly Guid PermPurchaseReturnManage=new("20000000-0000-0000-0000-000000000034");
private static readonly Guid PermRegionView = new("20000000-0000-0000-0000-000000000035");
private static readonly Guid PermRegionManage = new("20000000-0000-0000-0000-000000000036");
private static readonly Guid PermReturnView = new("20000000-0000-0000-0000-000000000037");
private static readonly Guid PermReturnManage = new("20000000-0000-0000-0000-000000000038");
private static readonly Guid PermRouteView = new("20000000-0000-0000-0000-000000000039");
private static readonly Guid PermRouteManage = new("20000000-0000-0000-0000-000000000040");
private static readonly Guid PermSchemeView = new("20000000-0000-0000-0000-000000000041");
private static readonly Guid PermSchemeManage = new("20000000-0000-0000-0000-000000000042");
private static readonly Guid PermStockView = new("20000000-0000-0000-0000-000000000043");
private static readonly Guid PermStockManage = new("20000000-0000-0000-0000-000000000044");
private static readonly Guid PermStoreView = new("20000000-0000-0000-0000-000000000045");
private static readonly Guid PermStoreManage = new("20000000-0000-0000-0000-000000000046");
private static readonly Guid PermSupplierClaimView = new("20000000-0000-0000-0000-000000000047");
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
public static void Apply(ModelBuilder modelBuilder)
{
@@ -77,121 +22,7 @@ public static class RoleAndPermissionSeed
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SuperAdminRoleId, Name = "SuperAdmin", Level = 100, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Permission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new Permission { Id = PermOrdersView, Code = "orders.view", Description = "View shop POs, invoices, payments and returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRidesView, Code = "rides.view", Description = "View and manage delivery rides and delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingView, Code = "loading.view", Description = "View and manage loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoresView, Code = "stores.view", Description = "View store accounts, credit limits and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSalesVisitsCheckin, Code = "sales_visits.checkin", Description = "Check in and out of scheduled store visits", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermItemsView, Code = "items.view", Description = "View and manage catalogue products", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermCategoriesView, Code = "categories.view", Description = "View and manage product categories and brands", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermWarehousesView, Code = "warehouses.view", Description = "View stock balances, movements, warehouses and reports", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrdersView, Code = "purchase_orders.view", Description = "View and manage purchase orders, GRNs and supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPricelistsView, Code = "pricelists.view", Description = "View and manage regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemesView, Code = "schemes.view", Description = "View and manage pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSuppliersView, Code = "suppliers.view", Description = "View and manage supplier accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverProfilesView, Code = "driver_profiles.view", Description = "View and manage driver profiles and licensing", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermComplianceManage, Code = "compliance.manage", Description = "Manage driver compliance rules", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermUsersView, Code = "users.view", Description = "View and manage staff user accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRolesView, Code = "roles.view", Description = "View and manage roles and their permission sets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionsView, Code = "regions.view", Description = "View and manage regions and region configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRoutesView, Code = "routes.view", Description = "View and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermVehiclesView, Code = "vehicles.view", Description = "View and manage fleet vehicles", CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new Permission { Id = PermClaimView, Code = "claim.view", Description = "View agency claims and settlement status", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermClaimManage, Code = "claim.manage", Description = "Create and settle agency claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskView, Code = "delivery-task.view", Description = "View delivery tasks within a ride", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskManage, Code = "delivery-task.manage", Description = "Confirm and fail delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverView, Code = "driver.view", Description = "View driver profiles and license details", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverManage, Code = "driver.manage", Description = "Create and update driver profiles", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnView, Code = "grn.view", Description = "View goods receipt notes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnManage, Code = "grn.manage", Description = "Create and confirm goods receipts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetView, Code = "loading-sheet.view", Description = "View loading sheets and their items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetManage, Code = "loading-sheet.manage", Description = "Create, edit, and confirm loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListView, Code = "price-list.view", Description = "View regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListManage, Code = "price-list.manage", Description = "Create and manage item price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderView, Code = "purchase-order.view", Description = "View purchase orders and line items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderManage,Code = "purchase-order.manage", Description = "Create, confirm, and close purchase orders", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseReturnManage,Code= "purchase-return.manage",Description = "Create and manage purchase returns to suppliers", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionView, Code = "region.view", Description = "View regions and their configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionManage, Code = "region.manage", Description = "Create and update regions, targets, and budgets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnView, Code = "return.view", Description = "View order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnManage, Code = "return.manage", Description = "Create and manage order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteView, Code = "route.view", Description = "View delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteManage, Code = "route.manage", Description = "Create and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeView, Code = "scheme.view", Description = "View pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeManage, Code = "scheme.manage", Description = "Create and configure pricing schemes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockView, Code = "stock.view", Description = "View stock balances and movements", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockManage, Code = "stock.manage", Description = "Transfer stock and record damage or adjustments", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreView, Code = "store.view", Description = "View store accounts and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreManage, Code = "store.manage", Description = "Create and update store accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
);
// SuperAdmin gets every permission
modelBuilder.Entity<RolePermission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRidesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoresView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSalesVisitsCheckin, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermItemsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermCategoriesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermWarehousesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPricelistsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSuppliersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverProfilesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermComplianceManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermUsersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRolesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRoutesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermVehiclesView, CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseReturnManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
);
}
}
+10 -73
View File
@@ -1,5 +1,3 @@
using System.IdentityModel.Tokens.Jwt;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -8,93 +6,32 @@ namespace Distribution.Features.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : ControllerBase
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
{
private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
{
var result = await service.LoginAsync(request, ct);
if (!result.Success || result.Data is not { } data)
return ToActionResult(result);
string? token = null;
TimeSpan maxAge = TimeSpan.FromHours(1);
if (data.TryGetProperty("accessToken", out var tokenEl))
token = tokenEl.GetString();
if (data.TryGetProperty("expiresIn", out var expEl))
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
if (!string.IsNullOrEmpty(token))
{
Response.Cookies.Append("access-token", token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = maxAge,
Secure = Request.IsHttps,
});
}
MeResponse? profile = null;
if (!string.IsNullOrEmpty(token))
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
if (Guid.TryParse(userIdStr, out var userId))
{
var user = await userRepo.GetByIdAsync(userId);
if (user is not null)
profile = new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0);
}
}
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
}
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me()
public async Task<IActionResult> Me(CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value);
if (user is null) return Unauthorized();
return Ok(new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0));
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
}
[HttpPost("logout")]
[AllowAnonymous]
public IActionResult Logout()
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
{
Response.Cookies.Delete("access-token", new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
Secure = Request.IsHttps,
});
return Ok(new { success = true });
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
}
}
+2 -13
View File
@@ -1,16 +1,5 @@
namespace Distribution.Features.Auth;
public record LoginRequest(
string Identifier,
string Password,
Guid? UserTypeId = null,
string DeviceName = "Unknown Device");
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string email, string Password, string? FullName);
public record MeResponse(
Guid UserId,
string Username,
Guid RoleId,
string RoleCode,
int RoleLevel);
public record RegisterRequest(string Username, string Password, string? FullName);
+4 -8
View File
@@ -1,21 +1,17 @@
using Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates.
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// </summary>
public class AuthService(IAuthHexClient authHex)
{
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", new
{
identifier = request.Identifier,
password = request.Password,
userTypeId = request.UserTypeId,
deviceName = request.DeviceName
}, reference: Guid.NewGuid().ToString(), ct: ct);
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
@@ -0,0 +1,20 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -0,0 +1,16 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken ct);
}
@@ -1,66 +0,0 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.BankAccount;
[ApiController]
[Route("api")]
public class BankAccountController(BankAccountService service) : ControllerBase
{
[HttpGet("bank-accounts")]
public async Task<IActionResult> List([FromQuery] BankAccountType? accountType)
{
var result = await service.ListAsync(accountType);
return Ok(result);
}
[HttpPost("bank-accounts")]
public async Task<IActionResult> CreateBank(CreateBankAccountRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateBankAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("cash-accounts")]
public async Task<IActionResult> CreateCash(CreateCashAccountRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateCashAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cash-accounts/{id:guid}")]
public async Task<IActionResult> UpdateCash(Guid id, UpdateCashAccountRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateCashAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("cash-accounts/{id:guid}/status")]
public async Task<IActionResult> SetCashStatus(Guid id, SetCashAccountStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.SetCashStatusAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("cash-accounts/{id:guid}")]
public async Task<IActionResult> DeleteCash(Guid id, CancellationToken ct)
{
try
{
var ok = await service.DeleteCashAsync(id, ct);
return ok ? NoContent() : NotFound();
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("cash-account-types")]
public async Task<IActionResult> ListCashAccountTypes() => Ok(await service.ListCashAccountTypesAsync());
}
@@ -1,33 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public record BankAccountResponse(
BankAccountType AccountType,
Guid AccountId,
string AccountName,
string? BankName,
string? CashAccountTypeName,
string? AccountNumber,
string CurrencyCode,
DateTime CreatedAt,
bool? IsActive,
bool? IsDefault);
public record CreateBankAccountRequest(
string AccountName,
string? BankName,
string? AccountNumber,
string? CurrencyCode);
public record CreateCashAccountRequest(
string AccountName,
string CashAccountTypeName,
string? AccountNumber,
string? CurrencyCode);
public record UpdateCashAccountRequest(string AccountName);
public record SetCashAccountStatusRequest(bool IsActive);
public record CashAccountTypeDto(string Name);
@@ -1,40 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.BankAccount;
public class BankAccountRepository(AppDBContext context) : IBankAccountRepository
{
public async Task<Models.BankAccount?> GetByIdAsync(Guid id) =>
await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == id);
public async Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType)
{
var query = context.BankAccounts.AsQueryable();
if (accountType is not null)
query = query.Where(a => a.AccountType == accountType);
return await query.OrderByDescending(a => a.CreatedAt).ToListAsync();
}
public async Task<List<string>> ListCashAccountTypeNamesAsync() =>
await context.BankAccounts
.Where(a => a.AccountType == BankAccountType.Cash && a.CashAccountTypeName != null)
.Select(a => a.CashAccountTypeName!)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
public async Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null) =>
await context.BankAccounts.AnyAsync(a =>
a.AccountType == type &&
a.AccountName == accountName &&
(excludeId == null || a.Id != excludeId));
public async Task AddAsync(Models.BankAccount account) =>
await context.BankAccounts.AddAsync(account);
public void Update(Models.BankAccount account) => context.BankAccounts.Update(account);
public void Remove(Models.BankAccount account) => context.BankAccounts.Remove(account);
}
@@ -1,116 +0,0 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public class BankAccountService(IBankAccountRepository repo, IUnitOfWork uow)
{
private const string DefaultCurrency = "LKR";
private static BankAccountResponse ToResponse(Models.BankAccount a) =>
new(
a.AccountType,
a.Id,
a.AccountName,
a.AccountType == BankAccountType.Bank ? a.BankName : null,
a.AccountType == BankAccountType.Cash ? a.CashAccountTypeName : null,
a.AccountNumber,
a.CurrencyCode,
a.CreatedAt,
a.AccountType == BankAccountType.Cash ? a.IsActive : null,
a.AccountType == BankAccountType.Cash ? a.IsDefault : null);
public async Task<List<BankAccountResponse>> ListAsync(BankAccountType? accountType) =>
(await repo.ListAsync(accountType)).Select(ToResponse).ToList();
public async Task<List<CashAccountTypeDto>> ListCashAccountTypesAsync() =>
(await repo.ListCashAccountTypeNamesAsync()).Select(n => new CashAccountTypeDto(n)).ToList();
public async Task<BankAccountResponse> CreateBankAsync(CreateBankAccountRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Bank, request.AccountName))
throw new InvalidOperationException("A bank account with this name already exists");
var account = new Models.BankAccount
{
AccountType = BankAccountType.Bank,
AccountName = request.AccountName,
BankName = request.BankName,
AccountNumber = request.AccountNumber,
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
IsActive = true,
IsDefault = false
};
await repo.AddAsync(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse> CreateCashAsync(CreateCashAccountRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (string.IsNullOrWhiteSpace(request.CashAccountTypeName))
throw new InvalidOperationException("CashAccountTypeName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName))
throw new InvalidOperationException("A cash account with this name already exists");
var account = new Models.BankAccount
{
AccountType = BankAccountType.Cash,
AccountName = request.AccountName,
CashAccountTypeName = request.CashAccountTypeName,
AccountNumber = request.AccountNumber,
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
IsActive = true,
IsDefault = false
};
await repo.AddAsync(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse?> UpdateCashAsync(Guid id, UpdateCashAccountRequest request, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return null;
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName, excludeId: id))
throw new InvalidOperationException("A cash account with this name already exists");
account.AccountName = request.AccountName;
repo.Update(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse?> SetCashStatusAsync(Guid id, SetCashAccountStatusRequest request, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return null;
if (account.IsDefault && !request.IsActive)
throw new InvalidOperationException("The default cash account cannot be deactivated");
account.IsActive = request.IsActive;
repo.Update(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<bool> DeleteCashAsync(Guid id, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return false;
if (account.IsDefault)
throw new InvalidOperationException("The default cash account cannot be deleted");
repo.Remove(account);
await uow.CommitAsync(ct);
return true;
}
}
@@ -1,14 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public interface IBankAccountRepository
{
Task<Models.BankAccount?> GetByIdAsync(Guid id);
Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType);
Task<List<string>> ListCashAccountTypeNamesAsync();
Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null);
Task AddAsync(Models.BankAccount account);
void Update(Models.BankAccount account);
void Remove(Models.BankAccount account);
}
+1 -1
View File
@@ -1,5 +1,5 @@
namespace Distribution.Features.Brand;
public record CreateBrandRequest(string Name);
public record UpdateBrandRequest(string Name, bool IsActive = true);
public record UpdateBrandRequest(string Name);
public record BrandResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
+4 -7
View File
@@ -5,16 +5,15 @@ namespace Distribution.Features.Brand;
public class BrandService(IBrandRepository repo, IUnitOfWork uow)
{
public async Task<List<BrandResponse>> GetAllAsync() =>
// Deleted brands stay IsActive=false but remain visible showing Inactive — delete
// never removes the row, only marks it (same rule as Items/Stores/Categories).
(await repo.GetAllAsync())
.Where(b => b.IsActive)
.Select(b => new BrandResponse(b.Id, b.Name, b.IsActive, b.CreatedAt))
.ToList();
public async Task<BrandResponse?> GetByIdAsync(Guid id)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null) return null;
if (brand is null || !brand.IsActive) return null;
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
}
@@ -30,10 +29,9 @@ public class BrandService(IBrandRepository repo, IUnitOfWork uow)
public async Task<BrandResponse?> UpdateAsync(Guid id, UpdateBrandRequest request, CancellationToken ct = default)
{
var brand = await repo.GetByIdAsync(id);
if (brand is null) return null;
if (brand is null || !brand.IsActive) return null;
if (brand.ExternalErpId.HasValue) return null;
brand.Name = request.Name;
brand.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -42,11 +40,10 @@ public class BrandService(IBrandRepository repo, IUnitOfWork uow)
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// Soft-delete only — never blocked by items still referencing this brand, same rule
// as Categories.
var brand = await repo.GetByIdAsync(id);
if (brand is null || !brand.IsActive) return false;
if (brand.ExternalErpId.HasValue) return false;
if (await repo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { repo.Remove(brand); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -1,9 +1,9 @@
namespace Distribution.Features.Category;
public record CreateCategoryRequest(string Name);
public record UpdateCategoryRequest(string Name, bool IsActive = true);
public record UpdateCategoryRequest(string Name);
public record CreateSubCategoryRequest(string Name);
public record UpdateSubCategoryRequest(string Name, bool IsActive = true);
public record UpdateSubCategoryRequest(string Name);
public record SubCategoryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
public record CategorySummaryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
@@ -4,27 +4,18 @@ namespace Distribution.Features.Category;
public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepository subCategoryRepo, IUnitOfWork uow)
{
public async Task<List<CategoryResponse>> GetAllAsync()
{
// Deleted categories/sub-categories stay IsActive=false but remain visible showing
// Inactive — delete never removes the row, only marks it (same rule as Orders/Stores/Items).
var categoryList = await categoryRepo.GetAllAsync();
var result = new List<CategoryResponse>();
foreach (var category in categoryList)
{
var subs = (await subCategoryRepo.GetByCategoryIdAsync(category.Id))
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
public async Task<List<CategorySummaryResponse>> GetAllAsync() =>
(await categoryRepo.GetAllAsync())
.Where(c => c.IsActive)
.Select(c => new CategorySummaryResponse(c.Id, c.Name, c.IsActive, c.CreatedAt))
.ToList();
result.Add(new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs));
}
return result;
}
public async Task<CategoryResponse?> GetByIdAsync(Guid id)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null) return null;
if (category is null || !category.IsActive) return null;
var subs = (await subCategoryRepo.GetByCategoryIdAsync(id))
.Where(s => s.IsActive)
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
.ToList();
return new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs);
@@ -42,10 +33,9 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<CategorySummaryResponse?> UpdateAsync(Guid id, UpdateCategoryRequest request, CancellationToken ct = default)
{
var category = await categoryRepo.GetByIdAsync(id);
if (category is null) return null;
if (category is null || !category.IsActive) return null;
if (category.ExternalErpId.HasValue) return null;
category.Name = request.Name;
category.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { categoryRepo.Update(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -54,11 +44,11 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
// Soft-delete only — never blocked by child sub-categories/items, same rule as
// Items/Stores/Orders. The row stays visible showing Inactive.
var category = await categoryRepo.GetByIdAsync(id);
if (category is null || !category.IsActive) return false;
if (category.ExternalErpId.HasValue) return false;
if (await categoryRepo.HasActiveSubCategoriesAsync(id)) return false;
if (await categoryRepo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { categoryRepo.Remove(category); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -79,10 +69,9 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<SubCategoryResponse?> UpdateSubCategoryAsync(Guid id, UpdateSubCategoryRequest request, CancellationToken ct = default)
{
var sub = await subCategoryRepo.GetByIdAsync(id);
if (sub is null) return null;
if (sub is null || !sub.IsActive) return null;
if (sub.ExternalErpId.HasValue) return null;
sub.Name = request.Name;
sub.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { subCategoryRepo.Update(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -91,10 +80,10 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
public async Task<bool> DeleteSubCategoryAsync(Guid id, CancellationToken ct = default)
{
// Soft-delete only — never blocked by child items, same rule as the parent category.
var sub = await subCategoryRepo.GetByIdAsync(id);
if (sub is null || !sub.IsActive) return false;
if (sub.ExternalErpId.HasValue) return false;
if (await subCategoryRepo.HasActiveItemsAsync(id)) return false;
await uow.BeginAsync(ct);
try { subCategoryRepo.Remove(sub); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
@@ -1,55 +0,0 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ChequeBook;
[ApiController]
[Route("api")]
public class ChequeBookController(ChequeBookService service) : ControllerBase
{
[HttpGet("cheque-books")]
public async Task<IActionResult> List(
[FromQuery] Guid? bankAccountId,
[FromQuery] int? branchId,
[FromQuery] ChequeBookStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
=> Ok(await service.ListAsync(bankAccountId, branchId, status, page, pageSize));
[HttpGet("cheque-books/{chequeBookNo}")]
public async Task<IActionResult> Get(string chequeBookNo, [FromQuery] string? expand)
{
var expandPages = string.Equals(expand, "pages", StringComparison.OrdinalIgnoreCase);
var result = await service.GetAsync(chequeBookNo, expandPages);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("cheque-books")]
public async Task<IActionResult> Create(CreateChequeBookRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cheque-pages/{chequeNo}/issue")]
public async Task<IActionResult> Issue(string chequeNo, IssueChequePageRequest request, CancellationToken ct)
{
try
{
var result = await service.IssueAsync(chequeNo, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cheque-pages/{chequeNo}/status")]
public async Task<IActionResult> UpdateStatus(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdatePageStatusAsync(chequeNo, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,75 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public record ChequePageResponse(
Guid Id,
string ChequeNo,
string ChequeBookNo,
ChequePageIssueStatus IssueStatus,
PayeeType? PayeeType,
int? PayeeId,
string? PayeeName,
DateTime? IssueDate,
decimal? Amount,
string? CurrencyCode,
int? VoucherId,
string? ReferenceNo,
string? Purpose,
bool? IsCrossCheque,
bool? IsAccountPayee,
bool? IsPostDated,
string? Notes,
string? PrintedBy,
DateTime? PrintedAt,
DateTime? ClearedDate,
bool? ClearedByBank,
string? CancelReason);
public record ChequeBookResponse(
string ChequeBookNo,
int BranchId,
Guid BankAccountId,
string StartChequeNo,
string EndChequeNo,
int TotalLeaves,
DateTime ReceivedDate,
string? Description,
string? CreatedBy,
ChequeBookStatus Status,
List<ChequePageResponse> Pages);
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int? Page, int? PageSize);
public record CreateChequeBookRequest(
int BranchId,
Guid BankAccountId,
string ChequeBookNo,
string StartChequeNo,
string EndChequeNo,
int TotalLeaves,
DateTime ReceivedDate,
string? Description,
string? CreatedBy);
public record IssueChequePageRequest(
PayeeType PayeeType,
int? PayeeId,
string PayeeName,
DateTime IssueDate,
decimal Amount,
string? CurrencyCode,
int? VoucherId,
string? ReferenceNo,
string? Purpose,
bool? IsCrossCheque,
bool? IsAccountPayee,
bool? IsPostDated,
string? Notes,
string? PrintedBy);
public record UpdateChequePageStatusRequest(
ChequePageStatusAction Action,
DateTime? ClearedDate,
string? CancelReason,
string? PerformedBy);
@@ -1,46 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ChequeBook;
public class ChequeBookRepository(AppDBContext context) : IChequeBookRepository
{
public async Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages)
{
var query = context.ChequeBooks.AsQueryable();
if (includePages) query = query.Include(b => b.Pages);
return await query.FirstOrDefaultAsync(b => b.ChequeBookNo == chequeBookNo);
}
public async Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
{
var query = context.ChequeBooks.AsQueryable();
if (bankAccountId is not null) query = query.Where(b => b.FK_BankAccountId == bankAccountId);
if (branchId is not null) query = query.Where(b => b.BranchId == branchId);
if (status is not null) query = query.Where(b => b.Status == status);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<bool> BookNoExistsAsync(string chequeBookNo) =>
await context.ChequeBooks.AnyAsync(b => b.ChequeBookNo == chequeBookNo);
public async Task AddAsync(Models.ChequeBook book) => await context.ChequeBooks.AddAsync(book);
public void Update(Models.ChequeBook book) => context.ChequeBooks.Update(book);
public async Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo) =>
await context.ChequePages
.Include(p => p.ChequeBook)
.FirstOrDefaultAsync(p => p.ChequeNo == chequeNo);
public void UpdatePage(Models.ChequePage page) => context.ChequePages.Update(page);
}
@@ -1,176 +0,0 @@
using Distribution.Features.BankAccount;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public class ChequeBookService(
IChequeBookRepository repo,
IBankAccountRepository bankAccountRepo,
IUnitOfWork uow)
{
private const string DefaultCurrency = "LKR";
private static ChequePageResponse ToPageResponse(Models.ChequePage p, string chequeBookNo) =>
new(p.Id, p.ChequeNo, chequeBookNo, p.IssueStatus, p.PayeeType, p.PayeeId, p.PayeeName,
p.IssueDate, p.Amount, p.CurrencyCode, p.VoucherId, p.ReferenceNo, p.Purpose,
p.IsCrossCheque, p.IsAccountPayee, p.IsPostDated, p.Notes,
p.PrintedBy, p.PrintedAt, p.ClearedDate, p.ClearedByBank, p.CancelReason);
private static ChequeBookResponse ToBookResponse(Models.ChequeBook b, bool includePages) =>
new(b.ChequeBookNo, b.BranchId, b.FK_BankAccountId, b.StartChequeNo, b.EndChequeNo,
b.TotalLeaves, b.ReceivedDate, b.Description, b.CreatedBy, b.Status,
includePages ? b.Pages.OrderBy(p => p.ChequeNo).Select(p => ToPageResponse(p, b.ChequeBookNo)).ToList() : new());
public async Task<PagedResult<ChequeBookResponse>> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 50;
var (items, total) = await repo.ListAsync(bankAccountId, branchId, status, page, pageSize);
return new PagedResult<ChequeBookResponse>(
items.Select(b => ToBookResponse(b, includePages: false)).ToList(),
total, page, pageSize);
}
public async Task<ChequeBookResponse?> GetAsync(string chequeBookNo, bool expandPages)
{
var book = await repo.GetByNoAsync(chequeBookNo, includePages: expandPages);
return book is null ? null : ToBookResponse(book, includePages: expandPages);
}
public async Task<ChequeBookResponse> CreateAsync(CreateChequeBookRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.ChequeBookNo))
throw new InvalidOperationException("ChequeBookNo is required");
if (request.TotalLeaves <= 0)
throw new InvalidOperationException("TotalLeaves must be greater than zero");
if (!long.TryParse(request.StartChequeNo, out var start) ||
!long.TryParse(request.EndChequeNo, out var end))
throw new InvalidOperationException("StartChequeNo and EndChequeNo must be numeric");
if (end < start)
throw new InvalidOperationException("EndChequeNo must be greater than or equal to StartChequeNo");
if (end - start + 1 != request.TotalLeaves)
throw new InvalidOperationException("TotalLeaves does not match the Start/End cheque range");
if (await repo.BookNoExistsAsync(request.ChequeBookNo))
throw new InvalidOperationException("A cheque book with this number already exists");
var bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId);
if (bankAccount is null || bankAccount.AccountType != BankAccountType.Bank)
throw new InvalidOperationException("BankAccountId does not reference a valid bank account");
var book = new Models.ChequeBook
{
ChequeBookNo = request.ChequeBookNo,
BranchId = request.BranchId,
FK_BankAccountId = request.BankAccountId,
StartChequeNo = request.StartChequeNo,
EndChequeNo = request.EndChequeNo,
TotalLeaves = request.TotalLeaves,
ReceivedDate = request.ReceivedDate,
Description = request.Description,
CreatedBy = request.CreatedBy,
Status = ChequeBookStatus.Active
};
var width = request.StartChequeNo.Length;
for (var n = start; n <= end; n++)
{
book.Pages.Add(new Models.ChequePage
{
ChequeNo = n.ToString().PadLeft(width, '0'),
IssueStatus = ChequePageIssueStatus.Unused
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(book);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToBookResponse(book, includePages: true);
}
public async Task<ChequePageResponse?> IssueAsync(string chequeNo, IssueChequePageRequest request, CancellationToken ct = default)
{
var page = await repo.GetPageByNoAsync(chequeNo);
if (page is null) return null;
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
throw new InvalidOperationException("The cheque book is not active");
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
if (string.IsNullOrWhiteSpace(request.PayeeName))
throw new InvalidOperationException("PayeeName is required");
page.IssueStatus = ChequePageIssueStatus.Issued;
page.PayeeType = request.PayeeType;
page.PayeeId = request.PayeeId;
page.PayeeName = request.PayeeName;
page.IssueDate = request.IssueDate;
page.Amount = request.Amount;
page.CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode;
page.VoucherId = request.VoucherId;
page.ReferenceNo = request.ReferenceNo;
page.Purpose = request.Purpose;
page.IsCrossCheque = request.IsCrossCheque;
page.IsAccountPayee = request.IsAccountPayee;
page.IsPostDated = request.IsPostDated;
page.Notes = request.Notes;
page.PrintedBy = request.PrintedBy;
page.PrintedAt = DateTime.Now;
repo.UpdatePage(page);
await uow.CommitAsync(ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
public async Task<ChequePageResponse?> UpdatePageStatusAsync(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct = default)
{
var page = await repo.GetPageByNoAsync(chequeNo);
if (page is null) return null;
switch (request.Action)
{
case ChequePageStatusAction.Clear:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be cleared");
if (request.ClearedDate is null)
throw new InvalidOperationException("ClearedDate is required when clearing a cheque");
page.IssueStatus = ChequePageIssueStatus.Cleared;
page.ClearedDate = request.ClearedDate;
page.ClearedByBank = true;
break;
case ChequePageStatusAction.Bounce:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be bounced");
page.IssueStatus = ChequePageIssueStatus.Bounced;
break;
case ChequePageStatusAction.Cancel:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be cancelled");
if (string.IsNullOrWhiteSpace(request.CancelReason))
throw new InvalidOperationException("CancelReason is required when cancelling a cheque");
page.IssueStatus = ChequePageIssueStatus.Cancelled;
page.CancelReason = request.CancelReason;
break;
case ChequePageStatusAction.Void:
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException("Only unused cheques can be voided");
page.IssueStatus = ChequePageIssueStatus.Void;
break;
}
repo.UpdatePage(page);
await uow.CommitAsync(ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
}
@@ -1,16 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public interface IChequeBookRepository
{
Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages);
Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize);
Task<bool> BookNoExistsAsync(string chequeBookNo);
Task AddAsync(Models.ChequeBook book);
void Update(Models.ChequeBook book);
Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo);
void UpdatePage(Models.ChequePage page);
}
@@ -1,5 +1,4 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
@@ -7,13 +6,7 @@ namespace Distribution.Features.Claims;
[ApiController]
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims")]
[Authorize("claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/claims/{id:guid}")]
[Authorize("claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -21,7 +14,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims")]
[Authorize("claim.manage")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
@@ -32,27 +24,13 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPut("api/claims/{id:guid}")]
[Authorize("claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize("claim.manage")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.SettledByUserId;
if (userId is null) return BadRequest(new { error = "SettledByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SettleAsync(id, request, userId.Value, ct);
var result = await service.SettleAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
+1 -8
View File
@@ -4,14 +4,7 @@ namespace Distribution.Features.Claims;
public record CreateClaimRequest(Guid AgencyUserId, string Details);
// Only editable while the claim is still Open — once settled, the settlement amount is a
// financial record and the claim details it was settled against should not silently change.
public record UpdateClaimRequest(Guid AgencyUserId, string Details);
public record SettleClaimRequest(
decimal SettlementAmount,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? SettledByUserId = null);
public record SettleClaimRequest(decimal SettlementAmount);
public record ClaimResponse(
Guid Id,
@@ -8,12 +8,6 @@ public class ClaimRepository(AppDBContext context) : IClaimRepository
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.Claim>> GetAllAsync() =>
await context.Claims
.Where(c => c.IsActive)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
await context.Claims
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
@@ -15,9 +15,6 @@ public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
return Map(c);
}
public async Task<List<ClaimResponse>> GetAllAsync() =>
(await repo.GetAllAsync()).Select(Map).ToList();
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
@@ -43,26 +40,6 @@ public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
return Map(claim);
}
public async Task<ClaimResponse> UpdateAsync(Guid id, UpdateClaimRequest request, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id);
if (claim is null || !claim.IsActive)
throw new InvalidOperationException("Claim not found.");
if (claim.Status == ClaimStatus.Settled)
throw new InvalidOperationException("Cannot edit a claim that has already been settled.");
if (string.IsNullOrWhiteSpace(request.Details))
throw new InvalidOperationException("Claim details are required.");
claim.FK_AgencyUserId = request.AgencyUserId;
claim.Details = request.Details;
await uow.BeginAsync(ct);
try { repo.Update(claim); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id);
@@ -3,7 +3,6 @@ namespace Distribution.Features.Claims;
public interface IClaimRepository
{
Task<Models.Claim?> GetByIdAsync(Guid id);
Task<List<Models.Claim>> GetAllAsync();
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
Task AddAsync(Models.Claim entity);
void Update(Models.Claim entity);
@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DashboardReport;
[ApiController]
[Route("api/reports/dashboard")]
public class DashboardReportController(DashboardReportService service) : ControllerBase
{
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
}
[HttpGet("inventory")]
public async Task<IActionResult> Inventory(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
}
}
@@ -1,38 +0,0 @@
namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesKpis(
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
public record TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity,
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount);
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
public record InventorySummaryResponse(
DateTime From, DateTime To, string Granularity,
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
@@ -1,226 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DashboardReport;
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
{
private sealed class SoldLine
{
public DateTime Date { get; set; }
public Guid PoId { get; set; }
public decimal Value { get; set; }
public decimal? Cost { get; set; }
public Guid CategoryId { get; set; }
public string CategoryName { get; set; } = "";
public Guid RegionId { get; set; }
public string RegionName { get; set; } = "";
public Guid RepId { get; set; }
}
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q =
from li in db.InvoiceItems.AsNoTracking()
where li.IsActive && !li.IsRemoved
&& li.Invoice.Status == InvoiceStatus.Finalized
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
select new SoldLine
{
Date = li.Invoice.FinalizedAt!.Value.Date,
PoId = li.Invoice.FK_ShopPOId,
Value = li.Quantity * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name,
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
RepId = li.Invoice.ShopPO.RaisedById,
};
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
return q;
}
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
var q = db.SaleReturnItems.AsNoTracking()
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
{
var lines = SoldLines(f);
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
var costed = lines.Where(l => l.Cost != null);
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
}
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
{
if (f.CategoryId.HasValue) return 0m;
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.Invoices.AsNoTracking()
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
&& i.FinalizedAt >= start && i.FinalizedAt < end);
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
}
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
private const int OverdueDays = 30;
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
if (f.RegionId.HasValue)
{
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
}
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
var rows = await invs.Select(i => new
{
i.FinalizedAt,
i.TotalAmount,
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
.Sum(p => (decimal?)p.Amount) ?? 0m,
}).ToListAsync();
var balances = rows
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
.ToList();
return new ReceivablesTotals(collected,
balances.Sum(b => b.Balance),
balances.Where(b => b.Due < end).Sum(b => b.Balance));
}
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => l.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
{
var rows = await SoldLines(f).GroupBy(l => l.RepId)
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
var ids = rows.Select(r => r.RepId).ToList();
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName);
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
}
private IQueryable<StockBalance> Balances(DashboardFilter f)
{
var q = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
// Value = quantity x cost price; items without a cost price contribute quantity only.
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
(await Balances(f).GroupBy(b => b.Item.Category.Name)
.Select(g => new
{
Category = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.OrderByDescending(x => x.Value).ToListAsync())
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
{
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
.GroupBy(b => b.LocationId)
.Select(g => new
{
Id = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.ToListAsync();
var ids = rows.Select(r => r.Id).ToList();
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, w => w.Name);
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
.OrderByDescending(r => r.Value).ToList();
}
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.StockMovements.AsNoTracking()
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
if (f.WarehouseId.HasValue)
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
.ToListAsync();
return rows.GroupBy(r => r.Date)
.Select(g => (g.Key,
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
.ToList();
}
}
@@ -1,92 +0,0 @@
namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo)
{
public const int MaxRangeDays = 366;
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
{
var end = (to ?? DateTime.Now).Date;
var start = (from ?? end.AddDays(-29)).Date;
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
}
public static string? Validate(DashboardFilter f)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
return null;
}
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
private static SalesKpis ToKpis(SalesTotals t)
{
var net = t.GrossSales - t.ReturnValue - t.Discounts;
var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis(
t.GrossSales, t.ReturnValue, t.Discounts, net, t.Orders,
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
}
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
new(r.Collected, r.Outstanding, r.Overdue,
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
{
var days = (f.To - f.From).Days + 1;
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
var daily = Daily(f);
var current = await repo.GetSalesTotalsAsync(f);
var previous = await repo.GetSalesTotalsAsync(prev);
var currentCash = await repo.GetReceivablesAsync(f);
var previousCash = await repo.GetReceivablesAsync(prev);
var sales = await repo.GetDailySalesAsync(f);
var returns = await repo.GetDailyReturnsAsync(f);
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
.GroupBy(x => Bucket(x.Date, daily))
.OrderBy(g => g.Key)
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
.ToList();
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month",
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
new CashKpiComparison(
ToCash(currentCash, current.GrossSales - current.Discounts),
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
trend,
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
}
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
{
var daily = Daily(f);
var movements = (await repo.GetDailyMovementsAsync(f))
.GroupBy(m => Bucket(m.Date, daily))
.OrderBy(g => g.Key)
.Select(g =>
{
var rec = g.Sum(x => x.Received);
var iss = g.Sum(x => x.Issued);
return new MovementPoint(g.Key, rec, iss, rec - iss);
}).ToList();
return new InventorySummaryResponse(
f.From, f.To, daily ? "day" : "month",
await repo.GetStockValueByCategoryAsync(f),
await repo.GetStockByWarehouseAsync(f),
movements);
}
}
@@ -1,17 +0,0 @@
namespace Distribution.Features.DashboardReport;
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
public interface IDashboardReportRepository
{
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
}
@@ -1,16 +1,12 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
@@ -24,26 +20,13 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks")]
//[Authorize("delivery-task.view")]
public async Task<IActionResult> GetAll(
[FromQuery] DeliveryTaskStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, page, pageSize);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -51,7 +34,6 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
@@ -64,12 +46,11 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -77,7 +58,6 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_ShopPOId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
@@ -1,4 +1,4 @@
using Distribution.Data;
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
@@ -8,48 +8,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.ShopPO)
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var query = context.DeliveryTasks
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.OrderByDescending(t => t.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
{
var query = context.DeliveryTasks.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.CountAsync();
}
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
@@ -1,7 +1,6 @@
using Distribution.Features.LoadingEvent;
using Distribution.Features.Notifications;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.ShopPO;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -11,33 +10,23 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo,
StockMover mover,
INotificationService notifications,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private const string DeliveriesLink = "/operations/deliveries";
// Route owner + managers whose scope covers the store being delivered to.
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
[
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
];
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_ShopPOId,
t.ShopPO.FK_StoreId,
t.ShopPO.Store?.StoreName ?? string.Empty,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
@@ -50,43 +39,43 @@ public class DeliveryTaskService(
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue);
if (confirmedSheets.Count == 0)
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
var shopPOIds = confirmedSheets
.SelectMany(s => s.Orders)
.Select(o => o.FK_ShopPOId)
.Distinct()
.ToList();
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var existingTasks = await repo.GetByRideIdAsync(rideId);
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var newTasks = new List<DeliveryTask>();
foreach (var shopPOId in shopPOIds)
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
newTasks.Add(new DeliveryTask
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_ShopPOId = shopPOId,
Sequence = nextSequence++,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
});
};
created.Add(task);
}
if (newTasks.Count > 0)
{
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in newTasks) await repo.AddAsync(task);
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
}
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
@@ -98,13 +87,6 @@ public class DeliveryTaskService(
return tasks.Select(ToResponse).ToList();
}
public async Task<PagedResponse<DeliveryTaskResponse>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var items = await repo.GetAllAsync(status, page, pageSize);
var total = await repo.GetCountAsync(status);
return new PagedResponse<DeliveryTaskResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
@@ -121,16 +103,16 @@ public class DeliveryTaskService(
foreach (var input in request.Items)
{
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > shopPOItem.Quantity)
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
@@ -145,7 +127,7 @@ public class DeliveryTaskService(
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default)
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
@@ -153,7 +135,7 @@ public class DeliveryTaskService(
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0))
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
@@ -161,44 +143,59 @@ public class DeliveryTaskService(
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var shopPO = task.ShopPO;
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
shopPO.FulfilledAt = DateTime.Now;
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
await mover.MoveAsync(item.FK_ItemId, itemName,
vehicleId, LocationType.Vehicle,
StockService.ExternalLocationId, LocationType.External,
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
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,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
shopPORepo.Update(shopPO);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
if (!fullyDelivered && shopPO.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskPartiallyDelivered,
DeliveryRecipients(shopPO.FK_StoreId),
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
ct);
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
@@ -222,13 +219,6 @@ public class DeliveryTaskService(
}
catch { await uow.RollbackAsync(ct); throw; }
if (task.ShopPO?.Store is not null)
await notifications.NotifyAsync(
NotificationTypes.DeliveryTaskFailed,
DeliveryRecipients(task.ShopPO.FK_StoreId),
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
ct);
return ToResponse(task);
}
}
@@ -6,8 +6,6 @@ public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize);
Task<int> GetCountAsync(DeliveryTaskStatus? status);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
@@ -1,6 +1,5 @@
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
@@ -13,7 +12,6 @@ public class DriverProfileController(
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("driver.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
@@ -21,7 +19,6 @@ public class DriverProfileController(
}
[HttpGet("{id:guid}")]
[Authorize("driver.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -29,7 +26,6 @@ public class DriverProfileController(
}
[HttpPost]
[Authorize("driver.manage")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
try
@@ -48,7 +44,6 @@ public class DriverProfileController(
}
[HttpPut("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -56,7 +51,6 @@ public class DriverProfileController(
}
[HttpDelete("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
@@ -64,7 +58,6 @@ public class DriverProfileController(
}
[HttpGet("compliance")]
[Authorize("driver.view")]
public async Task<IActionResult> GetCompliance()
{
var result = await complianceService.GetAsync();
@@ -72,7 +65,6 @@ public class DriverProfileController(
}
[HttpPut("compliance")]
[Authorize("driver.manage")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+6 -32
View File
@@ -1,5 +1,4 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
@@ -8,7 +7,6 @@ namespace Distribution.Features.Grn;
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1,
@@ -19,7 +17,6 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpGet("api/goods-receipts/{id:guid}")]
[Authorize("grn.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -27,52 +24,29 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpPost("api/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
/// <summary>Goods received without a purchase order.</summary>
[HttpPost("api/goods-receipts/direct")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateDirectAsync(request, userId.Value, ct);
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId.Value, ct);
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
+7 -85
View File
@@ -1,104 +1,26 @@
namespace Distribution.Features.Grn;
/// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary>
public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
public record GrnItemRequest(
// Set for a PO-based receipt; null for a direct receipt (ItemId is used instead).
Guid? PurchaseOrderItemId,
int AcceptedQuantity,
int RejectedQuantity,
// Populated only when the item has TracksBatches and/or TracksExpiryDate set.
List<GrnBatchLineRequest>? Batches = null,
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
List<string>? SerialNumbers = null,
// Set for a direct receipt line (no purchase order).
Guid? ItemId = null,
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
decimal? UnitCost = null,
// Null falls back to the GRN-wide percentage.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
int FreeQuantity = 0,
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
decimal? NbtPercent = null);
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record CreateGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
// GRN-wide defaults for lines that don't set their own.
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record CreateFlatGrnRequest(
Guid PurchaseOrderId,
Guid WarehouseId,
List<GrnItemRequest> Items,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
public record CreateDirectGrnRequest(
Guid WarehouseId,
List<GrnItemRequest> Items,
Guid? SupplierId = null,
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
Guid? ReceivedByUserId = null,
decimal? DiscountPercent = null,
decimal? VatPercent = null,
decimal? NbtPercent = null);
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
Guid? PurchaseOrderItemId,
Guid PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
int RejectedQuantity,
List<GrnBatchLineResponse> Batches,
List<string> SerialNumbers,
decimal UnitCost,
decimal DiscountPercent,
decimal VatPercent,
// Accepted qty x unit cost, before discount.
decimal GrossAmount,
decimal DiscountAmount,
decimal VatAmount,
// Gross - discount + VAT.
decimal LineTotal,
int FreeQuantity,
decimal NbtPercent,
decimal NbtAmount);
int RejectedQuantity);
public record GrnResponse(
Guid Id,
Guid? PurchaseOrderId,
Guid PurchaseOrderId,
Guid ReceivedByUserId,
Guid WarehouseId,
string WarehouseName,
Guid? SupplierId,
string? SupplierName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items,
decimal DiscountPercent,
decimal VatPercent,
// Sum of line gross amounts.
decimal SubTotal,
decimal DiscountAmount,
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
decimal TaxableAmount,
decimal VatAmount,
decimal TotalAmount,
decimal NbtPercent,
decimal NbtAmount);
List<GrnItemResponse> Items);
-37
View File
@@ -1,37 +0,0 @@
namespace Distribution.Features.Grn;
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
/// <summary>
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
/// </summary>
public static class GrnPricing
{
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
public static LineAmounts Line(
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
{
var gross = Round(acceptedQty * unitCost);
var discount = Round(gross * discountPercent / 100m);
var net = gross - discount;
var nbt = Round(net * nbtPercent / 100m);
var vat = Round((net + nbt) * vatPercent / 100m);
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
}
public static void ValidatePercent(decimal value, string label)
{
if (value < 0 || value > 100)
throw new InvalidOperationException($"{label} must be between 0 and 100.");
}
public static void ValidateCost(decimal value, string label)
{
if (value < 0)
throw new InvalidOperationException($"{label} cannot be negative.");
}
}
+5 -17
View File
@@ -8,21 +8,13 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
.OrderByDescending(gr => gr.ReceivedAt)
.Skip((page - 1) * pageSize)
@@ -37,11 +29,7 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Supplier)
.Include(gr => gr.Items).ThenInclude(i => i.Item)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
@@ -55,8 +43,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
+24 -367
View File
@@ -1,6 +1,5 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -10,88 +9,15 @@ public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
IUnitOfWork uow)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
var item = i.Item ?? i.PurchaseOrderItem!.Item;
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
i.AcceptedQuantity, i.RejectedQuantity,
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
i.NbtPercent, a.Nbt);
}
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
private static GrnResponse Map(Models.GoodsReceipt gr)
{
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
var subTotal = items.Sum(i => i.GrossAmount);
var discount = items.Sum(i => i.DiscountAmount);
var vat = items.Sum(i => i.VatAmount);
var nbt = items.Sum(i => i.NbtAmount);
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.FK_SupplierId, gr.Supplier?.Name,
gr.ReceivedAt, gr.CreatedAt, items,
gr.DiscountPercent, gr.VatPercent,
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
gr.NbtPercent, nbt);
}
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
/// <summary>
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
/// forced to 0 whatever the client sent.
/// </summary>
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
{
var discount = discountPercent ?? 0m;
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
GrnPricing.ValidatePercent(discount, "GRN discount %");
GrnPricing.ValidatePercent(vat, "GRN VAT %");
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
}
/// <summary>
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
/// </summary>
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
{
if (mode == TaxMode.Disabled) return 0m;
var value = lineValue ?? headerValue;
if (value is null)
{
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
return 0m;
}
GrnPricing.ValidatePercent(value.Value, label);
return value.Value;
}
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
GrnHeader header, GrnTaxSettings tax, string sku)
{
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
}
private static GrnResponse Map(Models.GoodsReceipt gr) =>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
{
@@ -110,61 +36,10 @@ public class GrnService(
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an
/// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for
/// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not
/// yet linked into the ledger (follow-up work).
/// </summary>
private static void ValidateLineShape(Models.Item item, GrnItemRequest line)
{
if (item.TracksSerialNumbers)
{
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
throw new InvalidOperationException(
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
}
else if (line.SerialNumbers is { Count: > 0 })
{
throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers.");
}
else if (item.TracksBatches || item.TracksExpiryDate)
{
if (StockQty(line) > 0)
{
if (line.Batches is null || line.Batches.Count == 0)
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
}
foreach (var b in line.Batches ?? [])
{
if (b.Quantity <= 0)
throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive.");
if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber))
throw new InvalidOperationException($"Item {item.Sku} requires a batch number.");
if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber))
throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers.");
if (item.TracksExpiryDate && b.ExpiryDate is null)
throw new InvalidOperationException($"Item {item.Sku} requires an expiry date.");
}
}
else if (line.Batches is { Count: > 0 })
{
throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates.");
}
}
/// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
/// units don't count against the PO's ordered quantity), creates
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
/// PartiallyReceived/Received. All in one unit of work.
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
/// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
@@ -181,63 +56,37 @@ public class GrnService(
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
ReceivedAt = DateTime.Now
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
foreach (var line in request.Items)
{
if (line.PurchaseOrderItemId is null)
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
ValidateLineShape(poItem.Item, line);
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
}
}
poItem.ReceivedQuantity += line.AcceptedQuantity;
var entity = new Models.GoodsReceiptItem
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
FreeQuantity = line.FreeQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, poItem.Item));
});
}
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
@@ -245,238 +94,46 @@ public class GrnService(
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived;
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
}
/// <summary>
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
/// </summary>
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
throw new InvalidOperationException("Supplier not found.");
var tax = await sysConfig.GetGrnTaxSettingsAsync();
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = null,
FK_SupplierId = request.SupplierId,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now,
DiscountPercent = header.Discount,
VatPercent = header.Vat,
NbtPercent = header.Nbt
};
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>();
foreach (var line in request.Items)
{
if (line.ItemId is null)
throw new InvalidOperationException("Each line of a direct receipt needs an ItemId.");
var item = await itemRepo.GetByIdAsync(line.ItemId.Value);
if (item is null || !item.IsActive)
throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
ValidateLineShape(item, line);
if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
{
foreach (var serialNumber in line.SerialNumbers)
{
if (!serialsInRequest.Add((item.Id, serialNumber)))
throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}.");
if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber))
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}.");
}
}
var entity = new Models.GoodsReceiptItem
{
FK_ItemId = item.Id,
FreeQuantity = line.FreeQuantity,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
};
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
goodsReceipt.Items.Add(entity);
grLines.Add((entity, line, item));
}
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct);
}
/// <summary>
/// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO
/// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts.
/// </summary>
private async Task<GrnResponse> PostReceiptAsync(
Models.GoodsReceipt goodsReceipt,
List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines,
Guid warehouseId,
Guid receivedByUserId,
Models.PurchaseOrder? po,
CancellationToken ct)
{
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(goodsReceipt);
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
{
if (item.TracksSerialNumbers)
{
foreach (var serialNumber in line.SerialNumbers!)
{
var serial = new ItemSerialNumber
{
FK_ItemId = item.Id,
SerialNumber = serialNumber,
Status = SerialStatus.InStock,
CurrentLocationId = warehouseId,
CurrentLocationType = LocationType.Warehouse,
ReceivedAt = goodsReceipt.ReceivedAt
};
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
// 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);
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity
Quantity = line.AcceptedQuantity
});
}
else
{
balance.Quantity += b.Quantity;
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
FK_ItemId = poItem.FK_ItemId,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
FK_ItemBatchId = batch.Id,
Quantity = b.Quantity,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
else
{
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = item.Id,
LocationId = warehouseId,
LocationType = LocationType.Warehouse,
Quantity = StockQty(line)
});
}
else
{
balance.Quantity += StockQty(line);
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.Id,
FromLocationId = StockService.ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = warehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = StockQty(line),
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
}
if (po is not null) poRepo.Update(po);
poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -1,20 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Invoice;
public interface IInvoiceRepository
{
Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync();
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
Task AddAsync(Models.Invoice invoice);
Task<Models.Item?> GetItemAsync(Guid itemId);
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
Task AddVersionAsync(InvoiceVersion version);
Task<int> GetLatestVersionNumberAsync(Guid invoiceId);
Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId);
void Update(Models.Invoice invoice);
}
@@ -1,116 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Distribution.Models;
namespace Distribution.Features.Invoice;
[ApiController]
[Route("api/invoices")]
public class InvoiceController(InvoiceService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status,
[FromQuery] PaymentStatus? paymentStatus,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return Ok(result);
}
[HttpGet("outstanding-summary")]
public async Task<IActionResult> GetOutstandingSummary()
{
var result = await service.GetOutstandingSummaryAsync();
return Ok(result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/preview-totals")]
public async Task<IActionResult> PreviewTotals(Guid id, [FromBody] PreviewInvoiceTotalsRequest request)
{
var result = await service.PreviewTotalsAsync(id, request);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/versions")]
public async Task<IActionResult> GetVersions(Guid id)
{
var result = await service.GetVersionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/items")]
public async Task<IActionResult> UpdateItems(Guid id, [FromBody] UpdateInvoiceItemsRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.EditedByUserId;
if (userId is null) return BadRequest(new { error = "EditedByUserId is required while auth is unavailable" });
try
{
var result = await service.UpdateItemsAsync(id, request, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request?.PrintedByUserId;
if (userId is null) return BadRequest(new { error = "PrintedByUserId is required while auth is unavailable" });
try
{
var result = await service.PrintAsync(id, userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/finalize")]
public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request?.FinalizedByUserId;
if (userId is null) return BadRequest(new { error = "FinalizedByUserId is required while auth is unavailable" });
try
{
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId.Value, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -1,66 +0,0 @@
using Distribution.Features.ShopPO;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
public record InvoiceItemAdjustment(Guid InvoiceItemId, int AdjustedQuantity, bool IsRemoved = false, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
// InvoiceItemId set = edit/remove/restore an existing line; ItemId set (InvoiceItemId null) = add a new line.
public record InvoiceItemEdit(Guid? InvoiceItemId, Guid? ItemId, int Quantity, decimal? UnitPrice = null, bool IsRemoved = false);
// DiscountSchemeIds: the percentage-discount scheme chosen in the editor (at most one); null = leave as is.
public record UpdateInvoiceItemsRequest(List<InvoiceItemEdit> Items, Guid? EditedByUserId = null, List<Guid>? DiscountSchemeIds = null);
public record PreviewLine(Guid ItemId, int Quantity, decimal UnitPrice, bool IsFree = false, bool IsRemoved = false, Guid? SchemeId = null);
public record PreviewInvoiceTotalsRequest(List<PreviewLine> Items, List<Guid>? DiscountSchemeIds = null);
// Quantity = how many free units the scheme still entitles for the lines as typed.
public record FreeEntitlement(Guid SchemeId, int Quantity);
public record InvoiceTotalsPreview(decimal DiscountAmount, decimal TotalAmount, List<FreeEntitlement> FreeEntitlements);
public record InvoiceVersionItemResponse(Guid? ShopPOItemId, Guid ItemId, string ItemName, int Quantity, decimal UnitPrice, bool IsRemoved);
public record InvoiceVersionResponse(
Guid Id,
int VersionNumber,
InvoiceVersionStage Stage,
decimal TotalAmount,
Guid CreatedBy,
DateTime CreatedAt,
List<InvoiceVersionItemResponse> Items);
public record InvoiceItemResponse(
Guid Id,
Guid? ShopPOItemId,
Guid ItemId,
string ItemName,
int? OrderedQuantity,
int Quantity,
decimal UnitPrice,
bool IsRemoved,
bool IsFree = false,
Guid? SchemeId = null,
string? SchemeName = null);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
Guid StoreId,
string StoreName,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
Guid CreatedBy,
DateTime? PrintedAt,
Guid? PrintedBy,
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
decimal DiscountAmount,
decimal PaidAmount,
bool IsActive,
DateTime CreatedAt,
List<InvoiceItemResponse> Items,
List<ShopPODiscountResponse>? Discounts = null);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -1,85 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Invoice;
public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
{
public async Task<Models.Invoice?> GetByIdAsync(Guid id) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.ShopPO).ThenInclude(p => p.Items.Where(x => x.IsActive))
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.Item)
.Include(i => i.Items.Where(x => x.IsActive)).ThenInclude(x => x.ShopPOItem)
.Include(i => i.Payments.Where(p => p.IsActive))
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive);
public async Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId) =>
await context.Invoices
.Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.Payments.Where(p => p.IsActive))
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus))
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus));
public async Task<List<Models.Invoice>> GetOutstandingAsync() =>
await context.Invoices
.Include(i => i.Payments.Where(p => p.IsActive))
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
.ToListAsync();
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
await context.SchemeRedemptions
.Where(r => r.FK_ShopPOId == shopPOId)
.SumAsync(r => r.DiscountAmount) ?? 0m;
public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice);
public async Task<Models.Item?> GetItemAsync(Guid itemId) =>
await context.Items.FirstOrDefaultAsync(i => i.Id == itemId && i.IsActive);
public async Task AddItemsAsync(IEnumerable<InvoiceItem> items) =>
await context.InvoiceItems.AddRangeAsync(items);
public async Task AddVersionAsync(InvoiceVersion version) =>
await context.InvoiceVersions.AddAsync(version);
public async Task<int> GetLatestVersionNumberAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Where(v => v.FK_InvoiceId == invoiceId)
.MaxAsync(v => (int?)v.VersionNumber) ?? 0;
public async Task<List<InvoiceVersion>> GetVersionsAsync(Guid invoiceId) =>
await context.InvoiceVersions
.Include(v => v.Items).ThenInclude(i => i.Item)
.Where(v => v.FK_InvoiceId == invoiceId && v.IsActive)
.OrderBy(v => v.VersionNumber)
.ToListAsync();
public void Update(Models.Invoice invoice) =>
context.Invoices.Update(invoice);
}
@@ -1,345 +0,0 @@
using Distribution.Features.Scheme;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IShopPORepository shopPoRepo,
SchemeService schemeService,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
i.ShopPO?.FK_StoreId ?? Guid.Empty,
i.ShopPO?.Store?.StoreName ?? string.Empty,
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.DiscountAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
i.IsActive, i.CreatedAt,
i.Items.Where(x => x.IsActive).Select(x =>
new InvoiceItemResponse(x.Id, x.FK_ShopPOItemId, x.FK_ItemId,
x.Item?.Name ?? string.Empty, x.ShopPOItem?.Quantity,
x.Quantity, x.UnitPrice, x.IsRemoved, x.IsFree, x.FK_SchemeId, x.SchemeName)).ToList());
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
// The lines that count toward a percentage discount: active, not struck out. Free issues carry the flag so the
// scheme rules ignore them.
private static List<ShopPOItem> DiscountableLines(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved)
.Select(i => new ShopPOItem { FK_ItemId = i.FK_ItemId, Quantity = i.Quantity, UnitPrice = i.UnitPrice, IsFree = i.IsFree })
.ToList();
// TotalAmount = billable lines - scheme discount. The discount is re-evaluated against the current lines
// (so adding/removing/re-pricing items moves it, and a slab level can be gained or lost) whenever lines change
// and at print/finalize. The redemption amount is updated with it.
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
{
var lines = items.ToList();
// Lowering/removing the paid item that earned a free issue lowers the free quantity with it.
await schemeService.ReconcileInvoiceFreeLinesAsync(invoice.FK_ShopPOId, lines);
var billable = BillableTotal(lines);
var discount = await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, DiscountableLines(lines), persist: true);
invoice.DiscountAmount = Math.Min(discount, billable);
invoice.TotalAmount = billable - invoice.DiscountAmount;
}
// Live preview for the draft editor: what the discount and total would be for these unsaved lines. Changes nothing.
public async Task<InvoiceTotalsPreview?> PreviewTotalsAsync(Guid id, PreviewInvoiceTotalsRequest request)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
var lines = request.Items.Where(l => !l.IsRemoved)
.Select(l => new ShopPOItem { FK_ItemId = l.ItemId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, IsFree = l.IsFree, FK_SchemeId = l.SchemeId })
.ToList();
var billable = lines.Sum(l => l.Quantity * l.UnitPrice);
var chosen = request.DiscountSchemeIds is null
? await schemeService.CalculateDiscountAsync(invoice.FK_ShopPOId, lines, persist: false)
: await schemeService.CalculateDiscountForSchemesAsync(request.DiscountSchemeIds, lines);
var discount = Math.Min(chosen, billable);
var schemeIds = lines.Where(l => l.IsFree && l.FK_SchemeId.HasValue).Select(l => l.FK_SchemeId!.Value).ToList();
var entitlements = (await schemeService.GetFreeEntitlementsAsync(schemeIds, lines))
.Select(e => new FreeEntitlement(e.Key, e.Value)).ToList();
return new InvoiceTotalsPreview(discount, billable - discount, entitlements);
}
// Immutable copy of the invoice lines as they stand right now.
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new()
{
FK_InvoiceId = invoice.Id,
VersionNumber = versionNumber,
Stage = stage,
TotalAmount = invoice.TotalAmount,
CreatedBy = userId,
Items = items.Select(i => new InvoiceVersionItem
{
FK_ShopPOItemId = i.FK_ShopPOItemId,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsRemoved = i.IsRemoved
}).ToList()
};
public async Task<List<InvoiceVersionResponse>?> GetVersionsAsync(Guid id)
{
if (await repo.GetByIdAsync(id) is null) return null;
var versions = await repo.GetVersionsAsync(id);
return versions.Select(v => new InvoiceVersionResponse(
v.Id, v.VersionNumber, v.Stage, v.TotalAmount, v.CreatedBy, v.CreatedAt,
v.Items.Select(i => new InvoiceVersionItemResponse(
i.FK_ShopPOItemId, i.FK_ItemId, i.Item?.Name ?? string.Empty, i.Quantity, i.UnitPrice, i.IsRemoved)).ToList())).ToList();
}
private async Task AddVersionAsync(Models.Invoice invoice, InvoiceVersionStage stage, Guid userId, IEnumerable<InvoiceItem>? addedLines = null)
{
var next = await repo.GetLatestVersionNumberAsync(invoice.Id) + 1;
// Added lines may or may not be in invoice.Items yet, so merge by reference.
var lines = invoice.Items.Concat(addedLines ?? []).Where(i => i.IsActive).Distinct();
await repo.AddVersionAsync(BuildVersion(invoice, lines, next, stage, userId));
}
public static List<InvoiceItem> BuildItemsFromShopPO(Models.ShopPO po, Guid invoiceId) =>
po.Items.Where(i => i.IsActive).Select(i => new InvoiceItem
{
FK_InvoiceId = invoiceId,
FK_ShopPOItemId = i.Id,
FK_ItemId = i.FK_ItemId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
IsFree = i.IsFree,
FK_SchemeId = i.FK_SchemeId,
SchemeName = i.SchemeName
}).ToList();
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
var invoice = await repo.GetByIdAsync(id);
if (invoice is null) return null;
return ToResponse(invoice) with { Discounts = await schemeService.GetDiscountsForShopPOAsync(invoice.FK_ShopPOId) };
}
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
{
var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync()
{
var invoices = await repo.GetOutstandingAsync();
var count = invoices.Count;
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount));
return new OutstandingPaymentsResponse(count, outstanding);
}
public async Task<InvoiceResponse> 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
{
FK_ShopPOId = po.Id,
Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending,
CreatedBy = createdBy
};
var lines = BuildItemsFromShopPO(po, invoice.Id);
await ApplyTotalsAsync(invoice, lines);
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(invoice);
await repo.AddItemsAsync(lines);
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
po.InvoiceId = invoice.Id;
shopPoRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(invoice.Id);
return ToResponse(saved!);
}
public async Task<InvoiceResponse> UpdateItemsAsync(Guid id, UpdateInvoiceItemsRequest request, Guid editedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Items can only be edited while the invoice is Draft (current: {invoice.Status})");
var newLines = new List<InvoiceItem>();
foreach (var edit in request.Items)
{
if (edit.UnitPrice is < 0)
throw new InvalidOperationException("Unit price cannot be negative");
if (edit.InvoiceItemId is Guid lineId)
{
// Existing line: edit quantity/price, or strike it (IsRemoved) / restore it.
var item = invoice.Items.FirstOrDefault(i => i.Id == lineId && i.IsActive)
?? throw new InvalidOperationException($"Invoice item {lineId} not found on this invoice");
item.IsRemoved = edit.IsRemoved;
if (!edit.IsRemoved)
{
ValidateQuantity(item, edit.Quantity);
item.Quantity = edit.Quantity;
}
if (edit.UnitPrice is decimal price)
{
if (item.IsFree && price != 0m)
throw new InvalidOperationException("A free-issue line must stay at zero price");
item.UnitPrice = price;
}
}
else if (edit.ItemId is Guid itemId)
{
// New line for an item that was not on the PO.
if (edit.IsRemoved) continue;
if (edit.Quantity <= 0)
throw new InvalidOperationException("Quantity for an added item must be greater than zero");
if (edit.UnitPrice is null)
throw new InvalidOperationException("Unit price is required for an added item");
if (invoice.Items.Concat(newLines).Any(i => i.IsActive && !i.IsRemoved && i.FK_ItemId == itemId))
throw new InvalidOperationException("This item is already on the invoice — edit its existing line instead");
var catalogItem = await repo.GetItemAsync(itemId)
?? throw new InvalidOperationException($"Item {itemId} not found");
newLines.Add(new InvoiceItem
{
FK_InvoiceId = invoice.Id,
FK_ItemId = catalogItem.Id,
Item = catalogItem,
Quantity = edit.Quantity,
UnitPrice = edit.UnitPrice.Value
});
}
else
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
}
var allLines = invoice.Items.Concat(newLines).ToList();
await schemeService.ApplyInvoiceDiscountSelectionAsync(
invoice.FK_ShopPOId, invoice.ShopPO?.FK_StoreId ?? Guid.Empty, allLines, request.DiscountSchemeIds);
await ApplyTotalsAsync(invoice, allLines);
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
if (newLines.Count > 0) await repo.AddItemsAsync(newLines);
await AddVersionAsync(invoice, InvoiceVersionStage.DraftEdited, editedBy, newLines);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
private static void ValidateQuantity(InvoiceItem item, int quantity)
{
var ordered = item.ShopPOItem?.Quantity ?? int.MaxValue;
if (quantity < 0 || quantity > ordered)
throw new InvalidOperationException(
$"Quantity for item {item.Id} must be between 0 and {ordered}");
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
var lines = invoice.Items.Where(i => i.IsActive).ToList();
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
invoice.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now;
invoice.PrintedBy = printedBy;
await uow.BeginAsync(ct);
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Printed, printedBy);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
}
public async Task<InvoiceResponse> FinalizeAsync(Guid id, FinalizeInvoiceRequest request, Guid finalizedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Invoice not found");
if (invoice.Status != InvoiceStatus.Printed)
throw new InvalidOperationException($"Cannot finalize an invoice in {invoice.Status} status");
var 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);
try
{
repo.Update(invoice);
await AddVersionAsync(invoice, InvoiceVersionStage.Finalized, finalizedBy);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(invoice);
}
}
+2 -31
View File
@@ -11,14 +11,7 @@ public record CreateItemRequest(
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
List<UomConversionRequest>? Conversions,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
List<UomConversionRequest>? Conversions);
public record UpdateItemRequest(
string Name,
@@ -27,15 +20,7 @@ public record UpdateItemRequest(
Guid? SubCategoryId,
Guid? BrandId,
Guid BaseUomId,
decimal? SalePrice,
bool IsActive = true,
decimal? ReorderLevel = null,
bool TracksSerialNumbers = false,
bool TracksBatches = false,
bool TracksExpiryDate = false,
bool HasWarranty = false,
int? WarrantyPeriodDays = null,
bool AllowCustomWarrantyPeriod = false);
decimal? SalePrice);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
@@ -54,13 +39,6 @@ public record ItemSummaryResponse(
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt);
@@ -79,13 +57,6 @@ public record ItemDetailResponse(
Guid BaseUomId,
string BaseUomName,
decimal? SalePrice,
decimal? ReorderLevel,
bool TracksSerialNumbers,
bool TracksBatches,
bool TracksExpiryDate,
bool HasWarranty,
int? WarrantyPeriodDays,
bool AllowCustomWarrantyPeriod,
Models.SyncSource SyncSource,
bool IsActive,
DateTime CreatedAt,
+1 -3
View File
@@ -20,14 +20,12 @@ public class ItemRepository(AppDBContext context) : IItemRepository
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
{
// Deleted items stay IsActive=false but remain visible with an Inactive status —
// Delete never removes the row, only marks it (same rule as Orders/Stores).
var query = context.Items
.Include(i => i.Category)
.Include(i => i.SubCategory)
.Include(i => i.Brand)
.Include(i => i.BaseUom)
.AsQueryable();
.Where(i => i.IsActive);
if (categoryId.HasValue) query = query.Where(i => i.FK_CategoryId == categoryId);
if (subCategoryId.HasValue) query = query.Where(i => i.FK_SubCategoryId == subCategoryId);
+5 -64
View File
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
namespace Distribution.Features.Item;
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
public class ItemService(IItemRepository repo, IUnitOfWork uow)
{
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
@@ -13,10 +13,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt);
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description,
@@ -24,44 +21,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.ReorderLevel,
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
i.SyncSource, i.IsActive, i.CreatedAt,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
i.Conversions.Select(MapConversion).ToList());
/// <summary>
/// Rejects per-item tracking flags that are off system-wide, and warranty-period values that
/// don't make sense given HasWarranty/AllowCustomWarrantyPeriod.
/// </summary>
private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches,
bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod)
{
if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync())
throw new InvalidOperationException("Serial number tracking is disabled system-wide.");
if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync())
throw new InvalidOperationException("Batch tracking is disabled system-wide.");
if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync())
throw new InvalidOperationException("Expiry date tracking is disabled system-wide.");
if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync())
throw new InvalidOperationException("Warranty tracking is disabled system-wide.");
if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync())
throw new InvalidOperationException("Custom warranty period override is disabled system-wide.");
if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod))
throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true.");
if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod)
throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true.");
if (warrantyPeriodDays is < 0)
throw new InvalidOperationException("WarrantyPeriodDays cannot be negative.");
}
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
.Select(MapSummary)
@@ -70,7 +32,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
public async Task<ItemDetailResponse?> GetByIdAsync(Guid id)
{
var item = await repo.GetByIdAsync(id);
if (item is null) return null;
if (item is null || !item.IsActive) return null;
return MapDetail(item);
}
@@ -79,9 +41,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
if (await repo.SkuExistsAsync(request.Sku))
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
var item = new Models.Item
{
Sku = request.Sku,
@@ -92,13 +51,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
FK_BrandId = request.BrandId,
FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice,
ReorderLevel = request.ReorderLevel,
TracksSerialNumbers = request.TracksSerialNumbers,
TracksBatches = request.TracksBatches,
TracksExpiryDate = request.TracksExpiryDate,
HasWarranty = request.HasWarranty,
WarrantyPeriodDays = request.WarrantyPeriodDays,
AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod,
SyncSource = Models.SyncSource.Manual
};
@@ -122,12 +74,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
public async Task<ItemDetailResponse?> UpdateAsync(Guid id, UpdateItemRequest request, CancellationToken ct = default)
{
var item = await repo.GetByIdAsync(id);
if (item is null) return null;
if (item is null || !item.IsActive) return null;
if (item.SyncSource == Models.SyncSource.Erp) return null;
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
item.Name = request.Name;
item.Description = request.Description;
item.FK_CategoryId = request.CategoryId;
@@ -135,14 +84,6 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysCon
item.FK_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice;
item.ReorderLevel = request.ReorderLevel;
item.TracksSerialNumbers = request.TracksSerialNumbers;
item.TracksBatches = request.TracksBatches;
item.TracksExpiryDate = request.TracksExpiryDate;
item.HasWarranty = request.HasWarranty;
item.WarrantyPeriodDays = request.WarrantyPeriodDays;
item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod;
item.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(item); await uow.CommitAsync(ct); }
@@ -6,12 +6,11 @@ public interface ILoadingSheetRepository
{
Task<LoadingSheet?> GetByIdAsync(Guid id);
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds);
Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds);
}
@@ -1,47 +1,25 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
public class LoadingSheetController(
LoadingSheetService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
//[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
[FromQuery] Guid? routeId,
[FromQuery] Guid? regionId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (routeId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, routeId.Value))
return StatusCode(403);
}
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
var result = await service.GetAllAsync(rideId, status, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -49,16 +27,11 @@ public class LoadingSheetController(
}
[HttpPost]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.PopulatedByUserId;
if (userId is null) return BadRequest(new { error = "PopulatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -66,7 +39,6 @@ public class LoadingSheetController(
}
[HttpPut("{id:guid}/items")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -78,17 +50,90 @@ public class LoadingSheetController(
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
[HttpPost("{id:guid}/confirm-dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmAsync(id, userId.Value, ct);
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetReceiptItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-receipt")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ResolveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/dispatch")]
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/receipt")]
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/resolve")]
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.PatchResolveAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -5,32 +5,37 @@ namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
Guid PopulatedBy,
DateTime PopulatedAt,
DateTime? ConfirmedAt,
Guid? ConfirmedBy,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items,
List<LoadingSheetOrderResponse> Orders);
List<LoadingSheetItemResponse> Items);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
Guid FK_WarehouseId,
string WarehouseName,
decimal QuantityDispatched,
Guid? FK_ShopPOItemId,
Guid? ShopPOId = null,
Guid? StoreId = null,
string? StoreName = null);
decimal? QuantityReceived,
decimal? QuantityResolved);
public record LoadingSheetOrderResponse(Guid ShopPOId);
public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null);
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
// Combined PATCH endpoints used by the frontend
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
@@ -8,39 +8,46 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
{
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
.Include(s => s.Orders)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
.FirstOrDefaultAsync(s =>
s.FK_RideId == rideId &&
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) =>
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.Items).ThenInclude(i => i.Item)
.Include(s => s.Items).ThenInclude(i => i.Warehouse)
.Include(s => s.Items).ThenInclude(i => i.ShopPOItem).ThenInclude(p => p!.ShopPO).ThenInclude(p => p.Store)
.Include(s => s.Orders)
.Include(s => s.SourceWarehouse)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status) &&
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)))
(status == null || s.Status == status))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId) =>
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
await context.LoadingSheets
.CountAsync(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status) &&
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)));
(status == null || s.Status == status));
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
@@ -53,25 +60,4 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
await context.LoadingSheetItems.AddRangeAsync(items);
public async Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds) =>
await context.ShopPOItems
.Where(i => shopPOItemIds.Contains(i.Id))
.Select(i => i.FK_ShopPOId)
.Distinct()
.ToListAsync();
public async Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds)
{
var current = await context.LoadingSheetOrders
.Where(o => o.FK_LoadingSheetId == loadingSheetId)
.ToListAsync();
context.LoadingSheetOrders.RemoveRange(current);
await context.LoadingSheetOrders.AddRangeAsync(shopPOIds.Select(shopPOId => new LoadingSheetOrder
{
FK_LoadingSheetId = loadingSheetId,
FK_ShopPOId = shopPOId
}));
}
}
@@ -8,13 +8,13 @@ namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService(
ILoadingSheetRepository repo,
IRideRepository rideRepo,
StockMover mover,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize)
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(rideId, status, routeId, regionId);
var items = await repo.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
var total = await repo.GetCountAsync(rideId, status);
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
@@ -24,7 +24,7 @@ public class LoadingSheetService(
return sheet is null ? null : ToResponse(sheet);
}
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
@@ -34,16 +34,22 @@ public class LoadingSheetService(
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has a Planned loading sheet");
throw new InvalidOperationException("This ride already has an active loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
Status = LoadingSheetStatus.Planned,
PopulatedBy = userId,
PopulatedAt = DateTime.Now
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
};
if (ride.RideType == RideType.Delivery)
{
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
foreach (var (itemId, qty) in orderItems)
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
}
await uow.BeginAsync(ct);
try
{
@@ -61,57 +67,19 @@ public class LoadingSheetService(
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
// The request is the full desired item list: a row matching (item, warehouse,
// shop PO item) gets its quantity updated in place, a new combination is added,
// and rows for combinations not present in the request are removed.
await uow.BeginAsync(ct);
try
{
var newItems = new List<LoadingSheetItem>();
var keptItems = new List<LoadingSheetItem>();
foreach (var input in request.Items)
{
var existing = sheet.Items.FirstOrDefault(i =>
i.FK_ItemId == input.FK_ItemId &&
i.FK_WarehouseId == input.FK_WarehouseId &&
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
if (existing is not null)
{
existing.QuantityDispatched = input.Quantity;
keptItems.Add(existing);
}
else
newItems.Add(new LoadingSheetItem
repo.RemoveItems(sheet.Items);
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = input.FK_ItemId,
FK_WarehouseId = input.FK_WarehouseId,
FK_ShopPOItemId = input.FK_ShopPOItemId,
QuantityDispatched = input.Quantity
});
}
var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList();
if (removedItems.Count > 0)
repo.RemoveItems(removedItems);
if (newItems.Count > 0)
await repo.AddItemsAsync(newItems);
var shopPOItemIds = keptItems.Select(i => i.FK_ShopPOItemId)
.Concat(newItems.Select(i => i.FK_ShopPOItemId))
.Where(x => x.HasValue)
.Select(x => x!.Value)
.Distinct()
.ToList();
var shopPOIds = shopPOItemIds.Count > 0
? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds)
: new List<Guid>();
await repo.ReplaceOrdersAsync(sheet.Id, shopPOIds);
FK_ItemId = i.FK_ItemId,
QuantityDispatched = i.Quantity
}));
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
@@ -120,60 +88,289 @@ public class LoadingSheetService(
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmAsync(Guid id, Guid userId, CancellationToken ct = default)
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException($"Cannot confirm a sheet in {sheet.Status} status");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
sheet.DispatchConfirmedAt = DateTime.Now;
sheet.DispatchConfirmedBy = userId;
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
item.QuantityReceived = input.QuantityReceived;
}
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet.Id, ride.FK_VehicleId, sheet.Items, userId);
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
sheet.Status = LoadingSheetStatus.Confirmed;
sheet.ConfirmedAt = DateTime.Now;
sheet.ConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(id);
return ToResponse(saved!);
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> items, Guid userId)
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
{
foreach (var item in items.Where(i => i.QuantityDispatched > 0))
{
var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})";
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
await mover.MoveAsync(item.FK_ItemId, label,
item.FK_WarehouseId, LocationType.Warehouse,
vehicleId, LocationType.Vehicle,
item.QuantityDispatched, MovementType.Load, sheetId, userId);
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
foreach (var input in items)
{
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
sheetItem.QuantityReceived = input.QuantityReceived;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var item in sheet.Items)
{
if (!item.QuantityReceived.HasValue)
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
item.QuantityResolved = item.QuantityReceived.Value;
}
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = 0;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
if (warehouseBalance.Quantity < qty)
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
warehouseBalance.Quantity -= qty;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = qty
});
}
else
{
vehicleBalance.Quantity += qty;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = qty,
Type = MovementType.Load,
ReferenceId = sheet.Id,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.Status, s.PopulatedBy, s.PopulatedAt,
s.ConfirmedAt, s.ConfirmedBy, s.CreatedAt,
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
s.SourceWarehouse?.Name ?? string.Empty,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.FK_WarehouseId, i.Warehouse?.Name ?? string.Empty,
i.QuantityDispatched, i.FK_ShopPOItemId,
i.ShopPOItem?.FK_ShopPOId,
i.ShopPOItem?.ShopPO?.FK_StoreId,
i.ShopPOItem?.ShopPO?.Store?.StoreName)).ToList(),
s.Orders.Select(o => new LoadingSheetOrderResponse(o.FK_ShopPOId)).ToList());
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
}

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