Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c775146b64 | |||
| 6e4bebb89b | |||
| 932d09c135 | |||
| 455ce699ad | |||
| 7d7e9f1318 | |||
| 87e3112a9a | |||
| 801276559d | |||
| b1bda1448e | |||
| 58247a1a0b | |||
| 3a503abe6a | |||
| 855eb44118 | |||
| 33dc4adce2 | |||
| 5b2b39b7bf | |||
| e13ec0f109 | |||
| 2c39b34c5f | |||
| 99832df134 | |||
| 2be9d9b396 | |||
| a12e170205 | |||
| fdfca7cf5e | |||
| e84ce29064 | |||
| 2be2928ad1 | |||
| 872fd42c4a | |||
| 39ad424d21 | |||
| d79c5e55d0 | |||
| 50d3113c28 | |||
| 34d33571de | |||
| 0f575370d4 | |||
| e4061fa94c | |||
| 5f8bc70174 | |||
| f2d5c9f50a | |||
| 9e39981c9a | |||
| bd8b2d8b44 | |||
| f26557a66e | |||
| 91959913a8 | |||
| 48f607d623 | |||
| 82be251878 | |||
| 209ee7da2d | |||
| 6b9f27573b | |||
| a0df2a1bbf | |||
| d1c3d4e22f | |||
| a80512994b | |||
| 2de9e5e4ad | |||
| 5707bd4c31 | |||
| 28c465f59b | |||
| 21b38b5276 | |||
| 992cd292fa | |||
| 7b6c9ac92a | |||
| eb0b042f0c | |||
| a980e636f5 | |||
| b992d07689 | |||
| a4d58f3377 | |||
| 94db2a311c | |||
| 6b608ad2ef | |||
| 87c807c16c | |||
| 3c75f9786e | |||
| 2d6723a040 | |||
| 2fe2ba007c | |||
| f3f2b71bd4 | |||
| bc72426495 | |||
| da29492798 | |||
| ce21aa6588 | |||
| f44cbf711b | |||
| aaac42b481 | |||
| 7a21cde217 | |||
| 531b3a7fef | |||
| a81e32bbe2 | |||
| 4ef63420b7 | |||
| 9fdf5aa8e3 | |||
| d9c339949e | |||
| 0052725b88 | |||
| 5d18aba1f9 | |||
| 5828879ddc | |||
| d75a43d45f | |||
| 728f2a8b17 | |||
| 1b66d5d767 | |||
| c5a0992b5d | |||
| 09d86835a2 | |||
| d1d2d55352 | |||
| c626f56cdb | |||
| 263e2ea9c0 | |||
| 5c37f02f19 | |||
| 6b3f28d3ae | |||
| 558afc6f9d | |||
| 5b2ac8cc14 | |||
| 5cb32bee95 | |||
| 9adfff48a7 | |||
| e5f6aa15b9 | |||
| 4e12b03e4b | |||
| f5967497be | |||
| 74f33d8979 | |||
| dd8f50f54c | |||
| 25b21d6fa8 | |||
| 106ed57897 | |||
| 0451a0f418 | |||
| b12f3c8d08 | |||
| 702efba62e | |||
| 55aa35308a | |||
| 40e890d4ff | |||
| 9908a552d1 | |||
| 4110e2fcfc | |||
| 23fb7361a4 | |||
| ff6f3d3218 | |||
| 92a311e1cc | |||
| 3538bb5b0e | |||
| b75cd01e90 | |||
| c52ecfdebc | |||
| edb5fddc3a | |||
| 05d44ab9f0 | |||
| c358764f4e | |||
| 92f1aadb81 | |||
| a8725bc473 | |||
| d578cbad20 | |||
| e1db9b1b69 | |||
| 9707ef596c | |||
| bfd3e5cded | |||
| d28d996295 | |||
| 4e493f51f8 | |||
| c52eaabec4 | |||
| 63c1c4ad1a | |||
| e3af6100ec | |||
| df0eb3cd5a | |||
| d8ba956817 | |||
| 89c0c17be7 | |||
| cc72e22664 | |||
| ef4e0e0873 | |||
| bd90a3d68e | |||
| 7dac429a1a | |||
| 3dbfe6db23 | |||
| a6de58e2e9 | |||
| 681cf821a6 | |||
| 37f4a88014 | |||
| a4f5d12234 | |||
| ee50f0932d | |||
| 7dd012f926 | |||
| aebfa6e3cc | |||
| 2d365cd906 | |||
| d6fd685b3c | |||
| a892aec527 | |||
| c68c6e1ab9 | |||
| aaf6e5f12d | |||
| 75b0a802c3 | |||
| e8692b2474 | |||
| be38777ebb | |||
| 41f992b824 | |||
| 1ec5426d15 | |||
| 3b0a5f0d3f | |||
| 96e12972b9 | |||
| ddc3ecd3e6 | |||
| 556dedb8a3 | |||
| 80db899091 | |||
| c290a1e778 | |||
| d7b6f29200 | |||
| 6f72f7a0e9 | |||
| ee672da9f7 | |||
| 4b95e03e05 | |||
| ae302d0c9c | |||
| 53dcdf929a | |||
| d2277f626c | |||
| 35fa3b19e3 | |||
| b7db6a83e6 | |||
| 8bb3b3d192 | |||
| e5dadef863 | |||
| 44e335d542 | |||
| d20e4a532f | |||
| 52845b5d87 | |||
| 3149902574 | |||
| 3c17047550 | |||
| 34ffc7f97a | |||
| 37fb72376a | |||
| b3840b47ce | |||
| a9a201cce1 | |||
| 52a9a3fde7 | |||
| 3c2dee5b4a | |||
| 1b310c86b3 | |||
| 1579936baf | |||
| e398ee9d6b | |||
| a8b6503a8a | |||
| a3c0211654 | |||
| dc32ae7ea6 | |||
| e4e12aa05a | |||
| 6a31ad0190 | |||
| 3103fb66ef | |||
| 502bf25cc0 | |||
| 484aa3fece | |||
| 1c9ca179df | |||
| c8c04c357d | |||
| b63f264551 | |||
| 7ced279ad5 | |||
| 4d7b8d8a56 | |||
| 1f6d63a0b3 | |||
| 63e7ddba82 | |||
| e132e29c82 | |||
| e2cec66d6b | |||
| 03d01d4c4e | |||
| 93f2f1bd3b | |||
| 93355d34f1 | |||
| 6c40421b2d | |||
| 9151f47d15 | |||
| 661b522b7d | |||
| 541f59b15d | |||
| b68e5043c7 | |||
| e0bb17d0ff | |||
| 436f7c10a0 | |||
| 0f29413375 | |||
| 8739e8e1a7 | |||
| 308be2f5ce | |||
| ef17f7da0a | |||
| ca2507a904 | |||
| 307055f8e1 | |||
| d9031f8e73 | |||
| 75578ea7f6 | |||
| 4f02fa303d |
@@ -0,0 +1,89 @@
|
|||||||
|
name: CI/CD - ERP Distribution
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- production
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
jobs:
|
||||||
|
build-and-deploy-backend:
|
||||||
|
name: Build & Deploy Backend
|
||||||
|
runs-on: linux
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
git clone \
|
||||||
|
--depth 1 \
|
||||||
|
--branch production \
|
||||||
|
https://oauth2:${{ secrets.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
|
||||||
|
.
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore Distribution/Distribution.csproj
|
||||||
|
- name: Publish
|
||||||
|
run: |
|
||||||
|
rm -rf /tmp/erpdistribution-deploy
|
||||||
|
mkdir -p /tmp/erpdistribution-deploy
|
||||||
|
dotnet publish Distribution/Distribution.csproj \
|
||||||
|
-c Release \
|
||||||
|
-r linux-x64 \
|
||||||
|
--self-contained true \
|
||||||
|
-o /tmp/erpdistribution-deploy
|
||||||
|
- name: Stop service
|
||||||
|
run: systemctl stop erpdistribution.service
|
||||||
|
- name: Copy to deploy folder
|
||||||
|
run: |
|
||||||
|
mkdir -p /home/distribution/core/publish
|
||||||
|
rm -rf /home/distribution/core/publish/*
|
||||||
|
cp -r /tmp/erpdistribution-deploy/* /home/distribution/core/publish/
|
||||||
|
- name: Fix ownership and permissions
|
||||||
|
run: |
|
||||||
|
chown -R distributionuser:distributionuser /home/distribution/core/publish
|
||||||
|
find /home/distribution/core/publish -type d -exec chmod 755 {} +
|
||||||
|
find /home/distribution/core/publish -type f -exec chmod 644 {} +
|
||||||
|
chmod +x /home/distribution/core/publish/Distribution
|
||||||
|
- name: Restart service
|
||||||
|
run: systemctl restart erpdistribution.service
|
||||||
|
- name: Verify
|
||||||
|
run: |
|
||||||
|
systemctl is-active erpdistribution.service && echo "✅ Service running" || echo "❌ Service failed"
|
||||||
|
|
||||||
|
build-and-deploy-frontend:
|
||||||
|
name: Build & Deploy Frontend
|
||||||
|
runs-on: linux
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
git clone \
|
||||||
|
--depth 1 \
|
||||||
|
--branch production \
|
||||||
|
https://oauth2:${{ secrets.DISTRIBUTION }}@gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution.git \
|
||||||
|
.
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build --progress=plain --no-cache \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL=https://distribution.hexdive.com/api \
|
||||||
|
-t erpdistributionfe:latest \
|
||||||
|
./distribution_fe
|
||||||
|
- name: Stop & remove existing container
|
||||||
|
run: |
|
||||||
|
docker stop erpdistributionfe 2>/dev/null || true
|
||||||
|
docker rm erpdistributionfe 2>/dev/null || true
|
||||||
|
- name: Start container
|
||||||
|
run: |
|
||||||
|
docker run -d \
|
||||||
|
--name erpdistributionfe \
|
||||||
|
-p 8801:3000 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--memory="512m" \
|
||||||
|
--cpus="1.0" \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--security-opt no-new-privileges \
|
||||||
|
erpdistributionfe:latest
|
||||||
|
- name: Remove old images
|
||||||
|
run: docker image prune -f
|
||||||
|
- name: Verify
|
||||||
|
run: |
|
||||||
|
docker ps --filter name=erpdistributionfe --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
|
||||||
|
| grep erpdistributionfe && echo "✅ Container running" || echo "❌ Container failed"
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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,6 +18,7 @@ public class RegionServiceTests
|
|||||||
public RegionServiceTests()
|
public RegionServiceTests()
|
||||||
{
|
{
|
||||||
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||||
|
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||||
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +149,123 @@ public class RegionServiceTests
|
|||||||
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Region code
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_GeneratesFirstCode_WhenNoRegionsExist()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync([]);
|
||||||
|
|
||||||
|
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
|
||||||
|
|
||||||
|
Assert.Equal("REG-0001", result.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_GeneratesNextCode_FromHighestExisting_IgnoringNonMatching()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0002", "REG-0009", "CUSTOM", "REG-abc", ""]);
|
||||||
|
|
||||||
|
var result = await _sut.CreateAsync(new CreateRegionRequest("North"));
|
||||||
|
|
||||||
|
Assert.Equal("REG-0010", result.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_StoresProvidedCode_AsEntered()
|
||||||
|
{
|
||||||
|
var result = await _sut.CreateAsync(new CreateRegionRequest("North", " nth-1 "));
|
||||||
|
|
||||||
|
Assert.Equal("nth-1", result.Code);
|
||||||
|
_repo.Verify(r => r.GetAllCodesAsync(), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_WhenProvidedCodeInUse()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.CodeExistsAsync("NTH-1", null)).ReturnsAsync(true);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.CreateAsync(new CreateRegionRequest("North", "NTH-1")));
|
||||||
|
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextCodeAsync_ReturnsSuggestion()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetAllCodesAsync()).ReturnsAsync(["REG-0003"]);
|
||||||
|
|
||||||
|
var result = await _sut.GetNextCodeAsync();
|
||||||
|
|
||||||
|
Assert.Equal("REG-0004", result.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ChangesCode_StoredAsEntered()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
region.Code = "REG-0001";
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.CodeExistsAsync("north-1", region.Id)).ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " north-1 "));
|
||||||
|
|
||||||
|
Assert.Equal("north-1", result!.Code);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_KeepsCode_WhenCodeBlank()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
region.Code = "REG-0001";
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
|
||||||
|
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", " "));
|
||||||
|
|
||||||
|
Assert.Equal("REG-0001", result!.Code);
|
||||||
|
_repo.Verify(r => r.CodeExistsAsync(It.IsAny<string>(), It.IsAny<Guid?>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_Throws_WhenCodeUsedByAnotherRegion()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
region.Code = "REG-0001";
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.CodeExistsAsync("REG-0002", region.Id)).ReturnsAsync(true);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0002")));
|
||||||
|
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_AllowsResubmittingOwnCode()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
region.Code = "REG-0001";
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
|
||||||
|
var result = await _sut.UpdateAsync(region.Id, new UpdateRegionRequest("North", "REG-0001"));
|
||||||
|
|
||||||
|
Assert.Equal("REG-0001", result!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsCode()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
region.Code = "REG-0007";
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
|
||||||
|
var result = await _sut.GetByIdAsync(region.Id);
|
||||||
|
|
||||||
|
Assert.Equal("REG-0007", result!.Code);
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteAsync
|
// DeleteAsync
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -195,4 +313,93 @@ public class RegionServiceTests
|
|||||||
|
|
||||||
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Region manager
|
||||||
|
|
||||||
|
private static User ManagerUser(string role = "RegionalManager", bool active = true) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Username = "mgr", FullName = "Mgr One", IsActive = active,
|
||||||
|
Role = new Role { Name = role, Level = 50 }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_ReturnsNull_WhenRegionMissing()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Region?)null);
|
||||||
|
|
||||||
|
Assert.Null(await _sut.SetManagerAsync(Guid.NewGuid(), new SetRegionManagerRequest(Guid.NewGuid())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_Throws_WhenUserNotRegionalManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser("SalesRep");
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id)));
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_ReplacesExistingManager_AndAddsScope()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser();
|
||||||
|
var old = new UserRegionScope { FK_UserId = Guid.NewGuid(), FK_RegionId = region.Id };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([old]);
|
||||||
|
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync((UserRegionScope?)null);
|
||||||
|
|
||||||
|
var result = await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||||
|
|
||||||
|
Assert.Equal(user.Id, result!.UserId);
|
||||||
|
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => s.Single() == old)), Times.Once);
|
||||||
|
_repo.Verify(r => r.AddRegionScopeAsync(It.Is<UserRegionScope>(s => s.FK_UserId == user.Id && s.FK_RegionId == region.Id)), Times.Once);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetManagerAsync_DoesNotDuplicateScope_WhenAlreadyManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser();
|
||||||
|
var existing = new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetUserWithRoleAsync(user.Id)).ReturnsAsync(user);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([existing]);
|
||||||
|
_repo.Setup(r => r.GetRegionScopeAsync(user.Id, region.Id)).ReturnsAsync(existing);
|
||||||
|
|
||||||
|
await _sut.SetManagerAsync(region.Id, new SetRegionManagerRequest(user.Id));
|
||||||
|
|
||||||
|
_repo.Verify(r => r.AddRegionScopeAsync(It.IsAny<UserRegionScope>()), Times.Never);
|
||||||
|
_repo.Verify(r => r.RemoveRegionScopes(It.Is<IEnumerable<UserRegionScope>>(s => !s.Any())), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveManagerAsync_ReturnsFalse_WhenNoManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id)).ReturnsAsync([]);
|
||||||
|
|
||||||
|
Assert.False(await _sut.RemoveManagerAsync(region.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetManagerAsync_ReturnsManager()
|
||||||
|
{
|
||||||
|
var region = ActiveRegion();
|
||||||
|
var user = ManagerUser();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(region.Id)).ReturnsAsync(region);
|
||||||
|
_repo.Setup(r => r.GetManagerScopesAsync(region.Id))
|
||||||
|
.ReturnsAsync([new UserRegionScope { FK_UserId = user.Id, FK_RegionId = region.Id, User = user }]);
|
||||||
|
|
||||||
|
var result = await _sut.GetManagerAsync(region.Id);
|
||||||
|
|
||||||
|
Assert.Equal("mgr", result!.Username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
False
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
bf7d0202-9a86-43b3-8323-402073a8c61a
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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=
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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=
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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=
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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==
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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==
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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==
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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=
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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=
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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=
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
10.0.300
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
10.0.303.1
|
||||||
@@ -16,10 +16,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<Uom> Uoms { get; set; }
|
public DbSet<Uom> Uoms { get; set; }
|
||||||
public DbSet<UomConversion> UomConversions { get; set; }
|
public DbSet<UomConversion> UomConversions { get; set; }
|
||||||
public DbSet<Item> Items { get; set; }
|
public DbSet<Item> Items { get; set; }
|
||||||
|
public DbSet<ItemBatch> ItemBatches { get; set; }
|
||||||
|
public DbSet<ItemSerialNumber> ItemSerialNumbers { get; set; }
|
||||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||||
public DbSet<Sale> Sales { get; set; }
|
public DbSet<ShopPO> ShopPOs { get; set; }
|
||||||
public DbSet<Order> Orders { get; set; }
|
public DbSet<ShopPOItem> ShopPOItems { get; set; }
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
public DbSet<Invoice> Invoices { get; set; }
|
||||||
|
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||||||
|
public DbSet<InvoiceVersion> InvoiceVersions { get; set; }
|
||||||
|
public DbSet<InvoiceVersionItem> InvoiceVersionItems { get; set; }
|
||||||
|
public DbSet<LoadingSheetOrder> LoadingSheetOrders { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
public DbSet<Role> Roles { get; set; }
|
public DbSet<Role> Roles { get; set; }
|
||||||
@@ -30,13 +36,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
|
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
|
||||||
public DbSet<StockBalance> StockBalances { get; set; }
|
public DbSet<StockBalance> StockBalances { get; set; }
|
||||||
public DbSet<StockMovement> StockMovements { get; set; }
|
public DbSet<StockMovement> StockMovements { get; set; }
|
||||||
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
|
|
||||||
public DbSet<PriceList> PriceLists { get; set; }
|
public DbSet<PriceList> PriceLists { get; set; }
|
||||||
public DbSet<PriceListItem> PriceListItems { get; set; }
|
public DbSet<PriceListItem> PriceListItems { get; set; }
|
||||||
public DbSet<Supplier> Suppliers { get; set; }
|
public DbSet<Supplier> Suppliers { get; set; }
|
||||||
public DbSet<DriverProfile> DriverProfiles { get; set; }
|
public DbSet<DriverProfile> DriverProfiles { get; set; }
|
||||||
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
|
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
|
||||||
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
|
|
||||||
public DbSet<Scheme> Schemes { get; set; }
|
public DbSet<Scheme> Schemes { get; set; }
|
||||||
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
|
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
|
||||||
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
||||||
@@ -48,6 +52,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||||
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||||
|
public DbSet<GoodsReceiptItemBatch> GoodsReceiptItemBatches { get; set; }
|
||||||
|
public DbSet<GoodsReceiptItemSerial> GoodsReceiptItemSerials { get; set; }
|
||||||
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||||
public DbSet<SaleReturn> SaleReturns { get; set; }
|
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||||
@@ -61,11 +67,25 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
||||||
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||||
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||||
|
public DbSet<SysConfig> SysConfigs { get; set; }
|
||||||
|
public DbSet<BankAccount> BankAccounts { get; set; }
|
||||||
|
public DbSet<ChequeBook> ChequeBooks { get; set; }
|
||||||
|
public DbSet<ChequePage> ChequePages { get; set; }
|
||||||
|
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
|
||||||
|
public DbSet<SupplierPayment> SupplierPayments { get; set; }
|
||||||
|
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||||
|
public DbSet<ExpenseCategory> ExpenseCategories { get; set; }
|
||||||
|
public DbSet<ExpenseVendor> ExpenseVendors { get; set; }
|
||||||
|
public DbSet<Expense> Expenses { get; set; }
|
||||||
|
public DbSet<ExpenseAttachment> ExpenseAttachments { get; set; }
|
||||||
|
public DbSet<RecurringExpenseTemplate> RecurringExpenseTemplates { get; set; }
|
||||||
|
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<OrderItem>()
|
// --- Composite PKs ---
|
||||||
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
|
|
||||||
|
|
||||||
modelBuilder.Entity<RolePermission>()
|
modelBuilder.Entity<RolePermission>()
|
||||||
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
|
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
|
||||||
@@ -79,28 +99,40 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<UserStoreScope>()
|
modelBuilder.Entity<UserStoreScope>()
|
||||||
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
|
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
|
||||||
|
|
||||||
modelBuilder.Entity<User>()
|
modelBuilder.Entity<LoadingSheetOrder>()
|
||||||
.HasOne(u => u.ReportsTo)
|
.HasKey(lo => new { lo.FK_LoadingSheetId, lo.FK_ShopPOId });
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(u => u.FK_ReportsToUserId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
|
// --- 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>()
|
modelBuilder.Entity<StockBalance>()
|
||||||
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
|
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<ItemSerialNumber>()
|
||||||
|
.HasIndex(s => new { s.FK_ItemId, s.SerialNumber })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<PriceListItem>()
|
modelBuilder.Entity<PriceListItem>()
|
||||||
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<Order>()
|
|
||||||
.HasIndex(o => o.FK_SaleId)
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<DriverProfile>()
|
modelBuilder.Entity<DriverProfile>()
|
||||||
.HasIndex(dp => dp.FK_UserId)
|
.HasIndex(dp => dp.FK_UserId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
// --- User ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<User>()
|
||||||
|
.HasOne(u => u.ReportsTo)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(u => u.FK_ReportsToUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Vehicle ---
|
||||||
|
|
||||||
modelBuilder.Entity<Vehicle>()
|
modelBuilder.Entity<Vehicle>()
|
||||||
.HasOne<Warehouse>()
|
.HasOne<Warehouse>()
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -108,11 +140,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired(false);
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryRun>()
|
// --- DeliveryRun ---
|
||||||
.HasOne(r => r.SourceWarehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(r => r.FK_SourceWarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryRun>()
|
modelBuilder.Entity<DeliveryRun>()
|
||||||
.HasOne(r => r.DriverProfile)
|
.HasOne(r => r.DriverProfile)
|
||||||
@@ -120,31 +148,146 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(r => r.FK_DriverProfileId)
|
.HasForeignKey(r => r.FK_DriverProfileId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>()
|
// --- ShopPO ---
|
||||||
.HasOne(s => s.Store)
|
|
||||||
|
modelBuilder.Entity<ShopPO>()
|
||||||
|
.HasOne(p => p.Store)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(s => s.FK_StoreId)
|
.HasForeignKey(p => p.FK_StoreId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>()
|
// InvoiceId is a plain column — no FK constraint
|
||||||
.HasOne(s => s.CreatedByUser)
|
modelBuilder.Entity<ShopPO>()
|
||||||
.WithMany()
|
.Property(p => p.InvoiceId)
|
||||||
.HasForeignKey(s => s.CreatedByUserId)
|
.HasColumnName("InvoiceId");
|
||||||
|
|
||||||
|
// --- ShopPOItem ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<ShopPOItem>()
|
||||||
|
.HasOne(i => i.ShopPO)
|
||||||
|
.WithMany(p => p.Items)
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>()
|
modelBuilder.Entity<ShopPOItem>()
|
||||||
.HasOne(s => s.Ride)
|
.HasOne(i => i.Item)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(s => s.FK_RideId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.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)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired(false);
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<Order>()
|
modelBuilder.Entity<InvoiceItem>()
|
||||||
.HasOne(o => o.Sale)
|
.HasOne(i => i.Item)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(o => o.FK_SaleId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasOne(v => v.Invoice)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(v => v.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersion>()
|
||||||
|
.HasIndex(v => new { v.FK_InvoiceId, v.VersionNumber })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.InvoiceVersion)
|
||||||
|
.WithMany(v => v.Items)
|
||||||
|
.HasForeignKey(i => i.FK_InvoiceVersionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.ShopPOItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ShopPOItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<InvoiceVersionItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Invoice>()
|
||||||
|
.HasMany(i => i.Payments)
|
||||||
|
.WithOne(p => p.Invoice)
|
||||||
|
.HasForeignKey(p => p.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- LoadingSheet ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<LoadingSheet>()
|
||||||
|
.HasOne(s => s.Ride)
|
||||||
|
.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)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
// --- SchemeRedemption ---
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Scheme)
|
.HasOne(r => r.Scheme)
|
||||||
.WithMany(s => s.Redemptions)
|
.WithMany(s => s.Redemptions)
|
||||||
@@ -152,10 +295,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Order)
|
.HasOne(r => r.ShopPO)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(r => r.FK_OrderId)
|
.HasForeignKey(r => r.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.FreeItem)
|
.HasOne(r => r.FreeItem)
|
||||||
@@ -163,6 +307,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(r => r.FK_FreeItemId)
|
.HasForeignKey(r => r.FK_FreeItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Scheme ---
|
||||||
|
|
||||||
modelBuilder.Entity<Scheme>()
|
modelBuilder.Entity<Scheme>()
|
||||||
.HasOne(s => s.TriggerItem)
|
.HasOne(s => s.TriggerItem)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -191,23 +337,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
|
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheet>()
|
// --- PurchaseOrder ---
|
||||||
.HasOne(s => s.SourceWarehouse)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(s => s.FK_SourceWarehouseId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheet>()
|
|
||||||
.HasOne(s => s.Ride)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(s => s.FK_RideId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheetItem>()
|
|
||||||
.HasOne(i => i.Item)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(i => i.FK_ItemId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseOrder>()
|
modelBuilder.Entity<PurchaseOrder>()
|
||||||
.HasOne(po => po.Supplier)
|
.HasOne(po => po.Supplier)
|
||||||
@@ -227,12 +357,24 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ItemId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderId);
|
||||||
|
|
||||||
|
// --- GoodsReceipt ---
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceipt>()
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
.HasOne(gr => gr.PurchaseOrder)
|
.HasOne(gr => gr.PurchaseOrder)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
|
.HasOne(gr => gr.Supplier)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(gr => gr.FK_SupplierId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceipt>()
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
.HasOne(gr => gr.Warehouse)
|
.HasOne(gr => gr.Warehouse)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -251,6 +393,94 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
||||||
|
|
||||||
|
// --- ItemBatch / ItemSerialNumber ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<ItemBatch>()
|
||||||
|
.HasOne(b => b.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ItemBatch>()
|
||||||
|
.HasIndex(b => new { b.FK_ItemId, b.BatchNumber });
|
||||||
|
|
||||||
|
modelBuilder.Entity<ItemSerialNumber>()
|
||||||
|
.HasOne(s => s.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- StockBalance / StockMovement batch & serial links ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockBalance>()
|
||||||
|
.HasOne(sb => sb.ItemBatch)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(sb => sb.FK_ItemBatchId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockBalance>()
|
||||||
|
.HasOne(sb => sb.ItemSerialNumber)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(sb => sb.FK_ItemSerialNumberId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockMovement>()
|
||||||
|
.HasOne(sm => sm.ItemBatch)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(sm => sm.FK_ItemBatchId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockMovement>()
|
||||||
|
.HasOne(sm => sm.ItemSerialNumber)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(sm => sm.FK_ItemSerialNumberId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
// --- GoodsReceiptItemBatch / GoodsReceiptItemSerial ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItemBatch>()
|
||||||
|
.HasOne(b => b.GoodsReceiptItem)
|
||||||
|
.WithMany(i => i.Batches)
|
||||||
|
.HasForeignKey(b => b.FK_GoodsReceiptItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItemBatch>()
|
||||||
|
.HasOne(b => b.ItemBatch)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.FK_ItemBatchId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItemSerial>()
|
||||||
|
.HasOne(s => s.GoodsReceiptItem)
|
||||||
|
.WithMany(i => i.Serials)
|
||||||
|
.HasForeignKey(s => s.FK_GoodsReceiptItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItemSerial>()
|
||||||
|
.HasOne(s => s.ItemSerialNumber)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_ItemSerialNumberId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- PurchaseReturn ---
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseReturn>()
|
modelBuilder.Entity<PurchaseReturn>()
|
||||||
.HasOne(pr => pr.PurchaseOrder)
|
.HasOne(pr => pr.PurchaseOrder)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -269,22 +499,15 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ItemId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseOrderItem>()
|
|
||||||
.HasIndex(i => i.FK_PurchaseOrderId);
|
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceiptItem>()
|
|
||||||
.HasIndex(i => i.FK_GoodsReceiptId);
|
|
||||||
|
|
||||||
modelBuilder.Entity<GoodsReceiptItem>()
|
|
||||||
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
|
||||||
|
|
||||||
modelBuilder.Entity<PurchaseReturnItem>()
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
.HasIndex(i => i.FK_PurchaseReturnId);
|
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||||
|
|
||||||
|
// --- SaleReturn ---
|
||||||
|
|
||||||
modelBuilder.Entity<SaleReturn>()
|
modelBuilder.Entity<SaleReturn>()
|
||||||
.HasOne(r => r.Order)
|
.HasOne(r => r.ShopPO)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(r => r.FK_OrderId)
|
.HasForeignKey(r => r.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<SaleReturn>()
|
modelBuilder.Entity<SaleReturn>()
|
||||||
@@ -308,12 +531,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SaleReturnItem>()
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
.HasIndex(i => i.FK_SaleReturnId);
|
.HasIndex(i => i.FK_SaleReturnId);
|
||||||
|
|
||||||
|
// --- CreditNote ---
|
||||||
|
|
||||||
modelBuilder.Entity<CreditNote>()
|
modelBuilder.Entity<CreditNote>()
|
||||||
.HasOne(n => n.SaleReturn)
|
.HasOne(n => n.SaleReturn)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(n => n.FK_SaleReturnId)
|
.HasForeignKey(n => n.FK_SaleReturnId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Claims ---
|
||||||
|
|
||||||
modelBuilder.Entity<Claim>()
|
modelBuilder.Entity<Claim>()
|
||||||
.HasOne(c => c.AgencyUser)
|
.HasOne(c => c.AgencyUser)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -366,6 +593,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<SupplierClaimItem>()
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
.HasIndex(i => i.FK_SupplierClaimId);
|
.HasIndex(i => i.FK_SupplierClaimId);
|
||||||
|
|
||||||
|
// --- DeliveryTask ---
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryTask>()
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
.HasOne(t => t.Ride)
|
.HasOne(t => t.Ride)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -373,11 +602,22 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryTask>()
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
.HasOne(t => t.Order)
|
.HasOne(t => t.ShopPO)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(t => t.FK_OrderId)
|
.HasForeignKey(t => t.FK_ShopPOId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// --- Regional ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<Region>()
|
||||||
|
.Property(r => r.Code)
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Region>()
|
||||||
|
.HasIndex(r => r.Code)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
|
||||||
modelBuilder.Entity<RegionalTarget>()
|
modelBuilder.Entity<RegionalTarget>()
|
||||||
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -400,11 +640,271 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(r => r.FK_RegionId)
|
.HasIndex(r => r.FK_RegionId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
// --- SysConfig ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<SysConfig>()
|
||||||
|
.HasKey(c => c.Key);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SysConfig>()
|
||||||
|
.Property(c => c.Key)
|
||||||
|
.HasMaxLength(100);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SysConfig>().HasData(
|
||||||
|
new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null },
|
||||||
|
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||||
|
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||||
|
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||||
|
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||||
|
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null },
|
||||||
|
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Payment service: Bank Accounts & Cheques ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<BankAccount>()
|
||||||
|
.Property(a => a.AccountName).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<BankAccount>()
|
||||||
|
.Property(a => a.BankName).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<BankAccount>()
|
||||||
|
.Property(a => a.CashAccountTypeName).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<BankAccount>()
|
||||||
|
.Property(a => a.AccountNumber).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<BankAccount>()
|
||||||
|
.Property(a => a.CurrencyCode).HasMaxLength(10);
|
||||||
|
modelBuilder.Entity<BankAccount>()
|
||||||
|
.HasIndex(a => new { a.AccountType, a.AccountName })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<BankAccount>().HasData(new BankAccount
|
||||||
|
{
|
||||||
|
Id = new Guid("11111111-1111-1111-1111-000000000001"),
|
||||||
|
CreatedAt = new DateTime(2026, 10, 19),
|
||||||
|
IsActive = true,
|
||||||
|
AccountType = BankAccountType.Cash,
|
||||||
|
AccountName = "Petty Cash",
|
||||||
|
CashAccountTypeName = "Petty Cash",
|
||||||
|
CurrencyCode = "LKR",
|
||||||
|
IsDefault = true
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ChequeBook>()
|
||||||
|
.Property(b => b.ChequeBookNo).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<ChequeBook>()
|
||||||
|
.Property(b => b.StartChequeNo).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ChequeBook>()
|
||||||
|
.Property(b => b.EndChequeNo).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ChequeBook>()
|
||||||
|
.HasIndex(b => b.ChequeBookNo).IsUnique();
|
||||||
|
modelBuilder.Entity<ChequeBook>()
|
||||||
|
.HasOne(b => b.BankAccount)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(b => b.FK_BankAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ChequePage>()
|
||||||
|
.Property(p => p.ChequeNo).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ChequePage>()
|
||||||
|
.Property(p => p.CurrencyCode).HasMaxLength(10);
|
||||||
|
modelBuilder.Entity<ChequePage>()
|
||||||
|
.HasIndex(p => p.ChequeNo).IsUnique();
|
||||||
|
modelBuilder.Entity<ChequePage>()
|
||||||
|
.HasOne(p => p.ChequeBook)
|
||||||
|
.WithMany(b => b.Pages)
|
||||||
|
.HasForeignKey(p => p.FK_ChequeBookId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ReceivedCheque>()
|
||||||
|
.Property(c => c.ChequeNo).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ReceivedCheque>()
|
||||||
|
.Property(c => c.ReceivedFromName).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<ReceivedCheque>()
|
||||||
|
.HasIndex(c => c.ChequeNo);
|
||||||
|
modelBuilder.Entity<ReceivedCheque>()
|
||||||
|
.HasOne(c => c.DepositBankAccount)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_DepositBankAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
// --- SupplierPayment ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPayment>()
|
||||||
|
.HasOne(p => p.Supplier)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.FK_SupplierId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPayment>()
|
||||||
|
.HasOne(p => p.BankAccount)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.FK_BankAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPayment>()
|
||||||
|
.HasOne(p => p.ChequePage)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.FK_ChequePageId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPayment>()
|
||||||
|
.Property(p => p.Amount).HasPrecision(18, 2);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||||
|
.HasOne(a => a.SupplierPayment)
|
||||||
|
.WithMany(p => p.Allocations)
|
||||||
|
.HasForeignKey(a => a.FK_SupplierPaymentId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||||
|
.HasOne(a => a.GoodsReceipt)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.FK_GoodsReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierPaymentGRN>()
|
||||||
|
.HasIndex(a => a.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<Notification>()
|
||||||
|
.HasIndex(n => new { n.EntityType, n.EntityId });
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasOne(r => r.Notification)
|
||||||
|
.WithMany(n => n.Recipients)
|
||||||
|
.HasForeignKey(r => r.FK_NotificationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasOne(r => r.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasIndex(r => new { r.FK_UserId, r.IsRead });
|
||||||
|
|
||||||
|
modelBuilder.Entity<NotificationRecipient>()
|
||||||
|
.HasIndex(r => new { r.FK_NotificationId, r.FK_UserId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
// --- StoreCreditMovement ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
.HasOne(m => m.Store)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(m => m.FK_StoreId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
.HasOne(m => m.Invoice)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(m => m.FK_InvoiceId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
.HasOne(m => m.Payment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(m => m.FK_PaymentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
.Property(m => m.Amount).HasPrecision(18, 2);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreCreditMovement>()
|
||||||
|
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
|
||||||
|
|
||||||
|
// --- Expense ---
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.HasOne(c => c.Parent)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_ParentCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.Property(c => c.Name).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.Property(c => c.Code).HasMaxLength(50);
|
||||||
|
modelBuilder.Entity<ExpenseCategory>()
|
||||||
|
.HasIndex(c => new { c.FK_ParentCategoryId, c.Name }).IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseVendor>()
|
||||||
|
.Property(v => v.Name).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<ExpenseVendor>()
|
||||||
|
.HasIndex(v => v.Name).IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Category).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Warehouse).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.Vendor).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_VendorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.BankAccount).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_BankAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.ChequePage).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_ChequePageId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasOne(e => e.RecurringTemplate).WithMany()
|
||||||
|
.HasForeignKey(e => e.FK_RecurringTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull).IsRequired(false);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.Property(e => e.Payee).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.Property(e => e.BillRef).HasMaxLength(100);
|
||||||
|
modelBuilder.Entity<Expense>()
|
||||||
|
.HasIndex(e => new { e.Status, e.BillDate });
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.HasOne(a => a.Expense).WithMany(e => e.Attachments)
|
||||||
|
.HasForeignKey(a => a.FK_ExpenseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.OriginalFileName).HasMaxLength(260);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.StoredPath).HasMaxLength(500);
|
||||||
|
modelBuilder.Entity<ExpenseAttachment>()
|
||||||
|
.Property(a => a.ContentType).HasMaxLength(100);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Category).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_CategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Warehouse).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasOne(t => t.Vendor).WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_VendorId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.Property(t => t.Name).HasMaxLength(200);
|
||||||
|
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||||
|
.HasIndex(t => new { t.IsPaused, t.NextRunDate });
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
|
Seed.ExpenseSeed.Apply(modelBuilder);
|
||||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||||
Seed.ProcurementSeed.Apply(modelBuilder);
|
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||||
Seed.CommerceSeed.Apply(modelBuilder);
|
Seed.CommerceSeed.Apply(modelBuilder);
|
||||||
Seed.OperationsExtrasSeed.Apply(modelBuilder);
|
Seed.OperationsExtrasSeed.Apply(modelBuilder);
|
||||||
|
Seed.AdminSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class AdminSeed
|
||||||
|
{
|
||||||
|
private static readonly DateTime SeedDate = new(2026, 10, 13);
|
||||||
|
|
||||||
|
// Same GUID as the AuthHex user — the sub claim from the JWT maps to this Id
|
||||||
|
internal static readonly Guid SuperAdminUserId = new("00000001-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<User>().HasData(new User
|
||||||
|
{
|
||||||
|
Id = SuperAdminUserId,
|
||||||
|
Username = "superadmin",
|
||||||
|
FullName = "Super Admin",
|
||||||
|
FK_RoleId = RoleAndPermissionSeed.SuperAdminRoleId,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = SeedDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ public static class CommerceSeed
|
|||||||
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
|
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
|
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Payment2Id = new("47000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid Payment3Id = new("47000000-0000-0000-0000-000000000003");
|
||||||
|
private static readonly Guid Payment4Id = new("47000000-0000-0000-0000-000000000004");
|
||||||
|
|
||||||
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
|
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
|
||||||
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
|
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
|
||||||
@@ -80,15 +83,18 @@ public static class CommerceSeed
|
|||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>().HasData(
|
modelBuilder.Entity<SchemeRedemption>().HasData(
|
||||||
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) }
|
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) }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Payment>().HasData(
|
modelBuilder.Entity<Payment>().HasData(
|
||||||
new Payment { Id = Payment1Id, FK_SaleId = OperationsDataSeed.Sale1Id, FK_StoreId = MasterDataSeed.FortStoreId, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true }
|
new Payment { Id = 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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<SaleReturn>().HasData(
|
modelBuilder.Entity<SaleReturn>().HasData(
|
||||||
new SaleReturn { Id = SaleReturn1Id, FK_OrderId = OperationsDataSeed.Order1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
new SaleReturn { Id = SaleReturn1Id, FK_ShopPOId = OperationsDataSeed.ShopPO1Id, Mode = ReturnMode.CreditDeduction, TotalCreditAmount = 300m, CreatedByUserId = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<SaleReturnItem>().HasData(
|
modelBuilder.Entity<SaleReturnItem>().HasData(
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class ExpenseSeed
|
||||||
|
{
|
||||||
|
private static readonly DateTime SeedDate = new(2026, 10, 21);
|
||||||
|
|
||||||
|
// Top-level categories
|
||||||
|
private static readonly Guid CatSalaries = new("30000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid CatUtilities = new("30000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid CatRent = new("30000000-0000-0000-0000-000000000003");
|
||||||
|
private static readonly Guid CatVehicle = new("30000000-0000-0000-0000-000000000004");
|
||||||
|
private static readonly Guid CatOffice = new("30000000-0000-0000-0000-000000000005");
|
||||||
|
private static readonly Guid CatBankFees = new("30000000-0000-0000-0000-000000000006");
|
||||||
|
private static readonly Guid CatMisc = new("30000000-0000-0000-0000-000000000007");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ExpenseCategory>().HasData(
|
||||||
|
// Top-level
|
||||||
|
new ExpenseCategory { Id = CatSalaries, Name = "Salaries", Code = "SAL", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatUtilities, Name = "Utilities", Code = "UTL", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatRent, Name = "Rent", Code = "RNT", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatVehicle, Name = "Vehicle", Code = "VEH", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatOffice, Name = "Office", Code = "OFF", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatBankFees, Name = "Bank Fees", Code = "BNK", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = CatMisc, Name = "Miscellaneous", Code = "MSC", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Salaries
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000101"), Name = "Basic Salary", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000102"), Name = "Bonus", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000103"), Name = "Overtime", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Utilities
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000201"), Name = "Electricity", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000202"), Name = "Water", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000203"), Name = "Internet", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Rent
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000301"), Name = "Warehouse Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000302"), Name = "Office Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Vehicle
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000401"), Name = "Fuel", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000402"), Name = "Maintenance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000403"), Name = "Insurance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Office
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000501"), Name = "Stationery", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000502"), Name = "Cleaning", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Bank Fees
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000601"), Name = "Service Charges", FK_ParentCategoryId = CatBankFees, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// Titles under Misc
|
||||||
|
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000701"), Name = "Other", FK_ParentCategoryId = CatMisc, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,6 @@ public static class MasterDataSeed
|
|||||||
{
|
{
|
||||||
private static readonly DateTime SeedDate = new(2026, 8, 20);
|
private static readonly DateTime SeedDate = new(2026, 8, 20);
|
||||||
|
|
||||||
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
|
|
||||||
// Implementation Notes in the plan; not derivable from HasData at migration time.
|
|
||||||
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
|
|
||||||
|
|
||||||
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
|
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
|
||||||
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
|
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
|
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
|
||||||
@@ -84,8 +80,8 @@ public static class MasterDataSeed
|
|||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Region>().HasData(
|
modelBuilder.Entity<Region>().HasData(
|
||||||
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
new Region { Id = WesternRegionId, Code = "REG-0001", Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
new Region { Id = CentralRegionId, Code = "REG-0002", Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Models.Route>().HasData(
|
modelBuilder.Entity<Models.Route>().HasData(
|
||||||
@@ -96,12 +92,12 @@ public static class MasterDataSeed
|
|||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Store>().HasData(
|
modelBuilder.Entity<Store>().HasData(
|
||||||
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", CreatedAt = SeedDate, IsActive = true },
|
||||||
new Store { Id = 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 = 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, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true },
|
||||||
new Store { Id = 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 = 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, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
|
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Warehouse>().HasData(
|
modelBuilder.Entity<Warehouse>().HasData(
|
||||||
@@ -115,12 +111,12 @@ public static class MasterDataSeed
|
|||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<User>().HasData(
|
modelBuilder.Entity<User>().HasData(
|
||||||
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
|
new User { Id = Supervisor1UserId, Username = "sunil.fernando",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 = SalesRep1UserId, Username = "nimal.perera",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 = SalesRep2UserId, Username = "kamal.silva",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 = Driver1UserId, Username = "ruwan.jayasuriya",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 = Driver2UserId, Username = "priyantha.kumara",FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
|
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<DriverProfile>().HasData(
|
modelBuilder.Entity<DriverProfile>().HasData(
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ public static class OperationsDataSeed
|
|||||||
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
|
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
|
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
// Kept for OperationsExtrasSeed stock movement reference (ReferenceId on MovementType.Sale)
|
||||||
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
|
internal static readonly Guid Sale1Id = new("32000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid Sale2Id = new("32000000-0000-0000-0000-000000000002");
|
|
||||||
|
|
||||||
internal static readonly Guid Order1Id = new("33000000-0000-0000-0000-000000000001");
|
internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid Order2Id = new("33000000-0000-0000-0000-000000000002");
|
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 OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
|
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
|
||||||
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
|
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
|
||||||
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
|
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 LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
|
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
@@ -31,16 +36,12 @@ public static class OperationsDataSeed
|
|||||||
new DeliveryRun
|
new DeliveryRun
|
||||||
{
|
{
|
||||||
Id = ClosedSaleRunId,
|
Id = ClosedSaleRunId,
|
||||||
RideType = RideType.Sale,
|
|
||||||
Status = RideStatus.Closed,
|
Status = RideStatus.Closed,
|
||||||
FK_VehicleId = MasterDataSeed.VanVehicleId,
|
FK_VehicleId = MasterDataSeed.VanVehicleId,
|
||||||
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
|
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
|
||||||
FK_RouteId = MasterDataSeed.ColomboRouteId,
|
|
||||||
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
|
||||||
Meterstart = 10500,
|
Meterstart = 10500,
|
||||||
Meterend = 10550,
|
Meterend = 10550,
|
||||||
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
|
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
|
||||||
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
|
|
||||||
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
|
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
@@ -48,86 +49,101 @@ public static class OperationsDataSeed
|
|||||||
new DeliveryRun
|
new DeliveryRun
|
||||||
{
|
{
|
||||||
Id = ActiveDeliveryRunId,
|
Id = ActiveDeliveryRunId,
|
||||||
RideType = RideType.Delivery,
|
Status = RideStatus.Open,
|
||||||
Status = RideStatus.Active,
|
|
||||||
FK_VehicleId = MasterDataSeed.TruckVehicleId,
|
FK_VehicleId = MasterDataSeed.TruckVehicleId,
|
||||||
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
|
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
|
||||||
FK_RouteId = MasterDataSeed.KandyRouteId,
|
|
||||||
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
|
|
||||||
Meterstart = 20300,
|
Meterstart = 20300,
|
||||||
Meterend = 0,
|
Meterend = 0,
|
||||||
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
|
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
|
||||||
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
|
|
||||||
ClosedAt = null,
|
ClosedAt = null,
|
||||||
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
|
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Sale>().HasData(
|
modelBuilder.Entity<ShopPO>().HasData(
|
||||||
new Sale
|
new ShopPO
|
||||||
{
|
{
|
||||||
Id = Sale1Id,
|
Id = ShopPO1Id,
|
||||||
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
|
||||||
FK_StoreId = MasterDataSeed.FortStoreId,
|
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||||
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
|
RaisedById = MasterDataSeed.SalesRep1UserId,
|
||||||
FK_RideId = ClosedSaleRunId,
|
InvoiceId = Invoice1Id,
|
||||||
TotalAmount = 3000m,
|
TotalAmount = 3000m,
|
||||||
Status = SaleStatus.Completed,
|
Status = ShopPOStatus.Fulfilled,
|
||||||
PaymentStatus = PaymentStatus.Paid,
|
ConfirmedAt = new DateTime(2026, 8, 25, 8, 30, 0),
|
||||||
FulfillImmediately = false,
|
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
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),
|
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
},
|
},
|
||||||
new Order
|
new ShopPO
|
||||||
{
|
{
|
||||||
Id = Order2Id,
|
Id = ShopPO2Id,
|
||||||
FK_SaleId = Sale2Id,
|
|
||||||
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
|
||||||
FK_StoreId = MasterDataSeed.KandyStoreId,
|
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||||
|
RaisedById = MasterDataSeed.SalesRep2UserId,
|
||||||
|
InvoiceId = null,
|
||||||
TotalAmount = 1200m,
|
TotalAmount = 1200m,
|
||||||
Status = OrderStatus.Pending,
|
Status = ShopPOStatus.Pending,
|
||||||
|
ConfirmedAt = null,
|
||||||
|
ConfirmedBy = null,
|
||||||
FulfilledAt = null,
|
FulfilledAt = null,
|
||||||
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
|
},
|
||||||
|
new ShopPO
|
||||||
|
{
|
||||||
|
Id = ShopPO3Id,
|
||||||
|
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||||
|
RaisedById = MasterDataSeed.SalesRep2UserId,
|
||||||
|
InvoiceId = Invoice2Id,
|
||||||
|
TotalAmount = 2400m,
|
||||||
|
Status = ShopPOStatus.Fulfilled,
|
||||||
|
ConfirmedAt = new DateTime(2026, 8, 31, 8, 30, 0),
|
||||||
|
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
|
FulfilledAt = new DateTime(2026, 8, 31, 13, 0, 0),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
|
||||||
|
IsActive = true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<OrderItem>().HasData(
|
modelBuilder.Entity<ShopPOItem>().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 ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||||
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||||
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
|
new ShopPOItem { Id = 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<LoadingSheet>().HasData(
|
modelBuilder.Entity<LoadingSheet>().HasData(
|
||||||
@@ -135,20 +151,19 @@ public static class OperationsDataSeed
|
|||||||
{
|
{
|
||||||
Id = LoadingSheet1Id,
|
Id = LoadingSheet1Id,
|
||||||
FK_RideId = ClosedSaleRunId,
|
FK_RideId = ClosedSaleRunId,
|
||||||
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
Status = LoadingSheetStatus.Confirmed,
|
||||||
Status = LoadingSheetStatus.ReceiptConfirmed,
|
PopulatedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
|
PopulatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
ConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
|
||||||
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
|
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
|
|
||||||
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
IsActive = true
|
IsActive = true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<LoadingSheetItem>().HasData(
|
modelBuilder.Entity<LoadingSheetItem>().HasData(
|
||||||
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
|
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, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
|
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, QuantityDispatched = 30m, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryTask>().HasData(
|
modelBuilder.Entity<DeliveryTask>().HasData(
|
||||||
@@ -156,7 +171,7 @@ public static class OperationsDataSeed
|
|||||||
{
|
{
|
||||||
Id = DeliveryTask1Id,
|
Id = DeliveryTask1Id,
|
||||||
FK_RideId = ActiveDeliveryRunId,
|
FK_RideId = ActiveDeliveryRunId,
|
||||||
FK_OrderId = Order2Id,
|
FK_ShopPOId = ShopPO2Id,
|
||||||
Sequence = 1,
|
Sequence = 1,
|
||||||
Status = DeliveryTaskStatus.Pending,
|
Status = DeliveryTaskStatus.Pending,
|
||||||
CustomerConfirmed = false,
|
CustomerConfirmed = false,
|
||||||
|
|||||||
@@ -27,13 +27,9 @@ public static class OperationsExtrasSeed
|
|||||||
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
|
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
|
||||||
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
|
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
|
|
||||||
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
|
|
||||||
|
|
||||||
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
|
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
|
|
||||||
|
|
||||||
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
|
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
|
||||||
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
|
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
@@ -47,7 +43,7 @@ public static class OperationsExtrasSeed
|
|||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<User>().HasData(
|
modelBuilder.Entity<User>().HasData(
|
||||||
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Uom>().HasData(
|
modelBuilder.Entity<Uom>().HasData(
|
||||||
@@ -85,31 +81,10 @@ public static class OperationsExtrasSeed
|
|||||||
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
||||||
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
|
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<StoreKycDocument>().HasData(
|
|
||||||
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
|
|
||||||
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
|
|
||||||
);
|
|
||||||
|
|
||||||
modelBuilder.Entity<DriverComplianceConfig>().HasData(
|
modelBuilder.Entity<DriverComplianceConfig>().HasData(
|
||||||
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
|
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<RideCancelRequest>().HasData(
|
|
||||||
new RideCancelRequest
|
|
||||||
{
|
|
||||||
Id = RideCancelRequest1Id,
|
|
||||||
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
|
|
||||||
TypeCode = CancelRequestType.VehicleBreakdown,
|
|
||||||
Reason = "Reported minor engine issue, resolved after inspection",
|
|
||||||
RequestedBy = MasterDataSeed.Driver2UserId,
|
|
||||||
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
|
|
||||||
Status = CancelRequestStatus.Rejected,
|
|
||||||
ApprovedBy = MasterDataSeed.Supervisor1UserId,
|
|
||||||
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
modelBuilder.Entity<RouteVisitLog>().HasData(
|
modelBuilder.Entity<RouteVisitLog>().HasData(
|
||||||
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||||
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
|
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
|
||||||
|
|||||||
@@ -13,6 +13,68 @@ public static class RoleAndPermissionSeed
|
|||||||
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
|
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
|
||||||
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
|
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
|
||||||
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
|
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
|
||||||
|
internal static readonly Guid SuperAdminRoleId = new("10000000-0000-0000-0000-000000000007");
|
||||||
|
|
||||||
|
// ── Legacy broad permissions (nav.ts uses these for sidebar visibility) ────
|
||||||
|
private static readonly Guid PermOrdersView = new("20000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid PermRidesView = new("20000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid PermLoadingView = new("20000000-0000-0000-0000-000000000003");
|
||||||
|
private static readonly Guid PermStoresView = new("20000000-0000-0000-0000-000000000004");
|
||||||
|
private static readonly Guid PermSalesVisitsCheckin = new("20000000-0000-0000-0000-000000000005");
|
||||||
|
private static readonly Guid PermItemsView = new("20000000-0000-0000-0000-000000000006");
|
||||||
|
private static readonly Guid PermCategoriesView = new("20000000-0000-0000-0000-000000000007");
|
||||||
|
private static readonly Guid PermWarehousesView = new("20000000-0000-0000-0000-000000000008");
|
||||||
|
private static readonly Guid PermPurchaseOrdersView = new("20000000-0000-0000-0000-000000000009");
|
||||||
|
private static readonly Guid PermPricelistsView = new("20000000-0000-0000-0000-000000000010");
|
||||||
|
private static readonly Guid PermSchemesView = new("20000000-0000-0000-0000-000000000011");
|
||||||
|
private static readonly Guid PermSuppliersView = new("20000000-0000-0000-0000-000000000012");
|
||||||
|
private static readonly Guid PermDriverProfilesView = new("20000000-0000-0000-0000-000000000013");
|
||||||
|
private static readonly Guid PermComplianceManage = new("20000000-0000-0000-0000-000000000014");
|
||||||
|
private static readonly Guid PermUsersView = new("20000000-0000-0000-0000-000000000015");
|
||||||
|
private static readonly Guid PermRolesView = new("20000000-0000-0000-0000-000000000016");
|
||||||
|
private static readonly Guid PermRegionsView = new("20000000-0000-0000-0000-000000000017");
|
||||||
|
private static readonly Guid PermRoutesView = new("20000000-0000-0000-0000-000000000018");
|
||||||
|
private static readonly Guid PermVehiclesView = new("20000000-0000-0000-0000-000000000019");
|
||||||
|
|
||||||
|
// ── Granular API permissions (used in [Authorize("...")] on controller actions) ─
|
||||||
|
private static readonly Guid PermClaimView = new("20000000-0000-0000-0000-000000000020");
|
||||||
|
private static readonly Guid PermClaimManage = new("20000000-0000-0000-0000-000000000021");
|
||||||
|
private static readonly Guid PermDeliveryTaskView = new("20000000-0000-0000-0000-000000000022");
|
||||||
|
private static readonly Guid PermDeliveryTaskManage = new("20000000-0000-0000-0000-000000000023");
|
||||||
|
private static readonly Guid PermDriverView = new("20000000-0000-0000-0000-000000000024");
|
||||||
|
private static readonly Guid PermDriverManage = new("20000000-0000-0000-0000-000000000025");
|
||||||
|
private static readonly Guid PermGrnView = new("20000000-0000-0000-0000-000000000026");
|
||||||
|
private static readonly Guid PermGrnManage = new("20000000-0000-0000-0000-000000000027");
|
||||||
|
private static readonly Guid PermLoadingSheetView = new("20000000-0000-0000-0000-000000000028");
|
||||||
|
private static readonly Guid PermLoadingSheetManage = new("20000000-0000-0000-0000-000000000029");
|
||||||
|
private static readonly Guid PermPriceListView = new("20000000-0000-0000-0000-000000000030");
|
||||||
|
private static readonly Guid PermPriceListManage = new("20000000-0000-0000-0000-000000000031");
|
||||||
|
private static readonly Guid PermPurchaseOrderView = new("20000000-0000-0000-0000-000000000032");
|
||||||
|
private static readonly Guid PermPurchaseOrderManage= new("20000000-0000-0000-0000-000000000033");
|
||||||
|
private static readonly Guid PermPurchaseReturnManage=new("20000000-0000-0000-0000-000000000034");
|
||||||
|
private static readonly Guid PermRegionView = new("20000000-0000-0000-0000-000000000035");
|
||||||
|
private static readonly Guid PermRegionManage = new("20000000-0000-0000-0000-000000000036");
|
||||||
|
private static readonly Guid PermReturnView = new("20000000-0000-0000-0000-000000000037");
|
||||||
|
private static readonly Guid PermReturnManage = new("20000000-0000-0000-0000-000000000038");
|
||||||
|
private static readonly Guid PermRouteView = new("20000000-0000-0000-0000-000000000039");
|
||||||
|
private static readonly Guid PermRouteManage = new("20000000-0000-0000-0000-000000000040");
|
||||||
|
private static readonly Guid PermSchemeView = new("20000000-0000-0000-0000-000000000041");
|
||||||
|
private static readonly Guid PermSchemeManage = new("20000000-0000-0000-0000-000000000042");
|
||||||
|
private static readonly Guid PermStockView = new("20000000-0000-0000-0000-000000000043");
|
||||||
|
private static readonly Guid PermStockManage = new("20000000-0000-0000-0000-000000000044");
|
||||||
|
private static readonly Guid PermStoreView = new("20000000-0000-0000-0000-000000000045");
|
||||||
|
private static readonly Guid PermStoreManage = new("20000000-0000-0000-0000-000000000046");
|
||||||
|
private static readonly Guid PermSupplierClaimView = new("20000000-0000-0000-0000-000000000047");
|
||||||
|
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||||
|
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||||
|
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||||
|
private static readonly Guid PermExpenseView = new("20000000-0000-0000-0000-000000000051");
|
||||||
|
private static readonly Guid PermExpenseCreate = new("20000000-0000-0000-0000-000000000052");
|
||||||
|
private static readonly Guid PermExpensePost = new("20000000-0000-0000-0000-000000000053");
|
||||||
|
private static readonly Guid PermExpensePay = new("20000000-0000-0000-0000-000000000054");
|
||||||
|
private static readonly Guid PermExpenseVoid = new("20000000-0000-0000-0000-000000000055");
|
||||||
|
private static readonly Guid PermExpenseManageCats = new("20000000-0000-0000-0000-000000000056");
|
||||||
|
private static readonly Guid PermExpenseManageRec = new("20000000-0000-0000-0000-000000000057");
|
||||||
|
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -22,7 +84,135 @@ public static class RoleAndPermissionSeed
|
|||||||
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
|
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
|
||||||
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
|
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
|
||||||
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, CreatedAt = SeedDate, IsActive = true },
|
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, CreatedAt = SeedDate, IsActive = true },
|
||||||
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
|
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Role { Id = SuperAdminRoleId, Name = "SuperAdmin", Level = 100, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Permission>().HasData(
|
||||||
|
// ── Legacy broad permissions ──────────────────────────────────────────
|
||||||
|
new Permission { Id = PermOrdersView, Code = "orders.view", Description = "View shop POs, invoices, payments and returns", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRidesView, Code = "rides.view", Description = "View and manage delivery rides and delivery tasks", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermLoadingView, Code = "loading.view", Description = "View and manage loading sheets", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermStoresView, Code = "stores.view", Description = "View store accounts, credit limits and balances", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSalesVisitsCheckin, Code = "sales_visits.checkin", Description = "Check in and out of scheduled store visits", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermItemsView, Code = "items.view", Description = "View and manage catalogue products", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermCategoriesView, Code = "categories.view", Description = "View and manage product categories and brands", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermWarehousesView, Code = "warehouses.view", Description = "View stock balances, movements, warehouses and reports", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPurchaseOrdersView, Code = "purchase_orders.view", Description = "View and manage purchase orders, GRNs and supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPricelistsView, Code = "pricelists.view", Description = "View and manage regional price lists", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSchemesView, Code = "schemes.view", Description = "View and manage pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSuppliersView, Code = "suppliers.view", Description = "View and manage supplier accounts", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermDriverProfilesView, Code = "driver_profiles.view", Description = "View and manage driver profiles and licensing", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermComplianceManage, Code = "compliance.manage", Description = "Manage driver compliance rules", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermUsersView, Code = "users.view", Description = "View and manage staff user accounts", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRolesView, Code = "roles.view", Description = "View and manage roles and their permission sets", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRegionsView, Code = "regions.view", Description = "View and manage regions and region configuration", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRoutesView, Code = "routes.view", Description = "View and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermVehiclesView, Code = "vehicles.view", Description = "View and manage fleet vehicles", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// ── Granular API permissions ──────────────────────────────────────────
|
||||||
|
new Permission { Id = PermClaimView, Code = "claim.view", Description = "View agency claims and settlement status", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermClaimManage, Code = "claim.manage", Description = "Create and settle agency claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermDeliveryTaskView, Code = "delivery-task.view", Description = "View delivery tasks within a ride", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermDeliveryTaskManage, Code = "delivery-task.manage", Description = "Confirm and fail delivery tasks", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermDriverView, Code = "driver.view", Description = "View driver profiles and license details", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermDriverManage, Code = "driver.manage", Description = "Create and update driver profiles", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermGrnView, Code = "grn.view", Description = "View goods receipt notes", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermGrnManage, Code = "grn.manage", Description = "Create and confirm goods receipts", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermLoadingSheetView, Code = "loading-sheet.view", Description = "View loading sheets and their items", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermLoadingSheetManage, Code = "loading-sheet.manage", Description = "Create, edit, and confirm loading sheets", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPriceListView, Code = "price-list.view", Description = "View regional price lists", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPriceListManage, Code = "price-list.manage", Description = "Create and manage item price lists", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPurchaseOrderView, Code = "purchase-order.view", Description = "View purchase orders and line items", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPurchaseOrderManage,Code = "purchase-order.manage", Description = "Create, confirm, and close purchase orders", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermPurchaseReturnManage,Code= "purchase-return.manage",Description = "Create and manage purchase returns to suppliers", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRegionView, Code = "region.view", Description = "View regions and their configuration", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRegionManage, Code = "region.manage", Description = "Create and update regions, targets, and budgets", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermReturnView, Code = "return.view", Description = "View order returns", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermReturnManage, Code = "return.manage", Description = "Create and manage order returns", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRouteView, Code = "route.view", Description = "View delivery routes", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermRouteManage, Code = "route.manage", Description = "Create and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSchemeView, Code = "scheme.view", Description = "View pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSchemeManage, Code = "scheme.manage", Description = "Create and configure pricing schemes", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermStockView, Code = "stock.view", Description = "View stock balances and movements", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermStockManage, Code = "stock.manage", Description = "Transfer stock and record damage or adjustments", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermStoreView, Code = "store.view", Description = "View store accounts and balances", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermStoreManage, Code = "store.manage", Description = "Create and update store accounts", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseView, Code = "expense.view", Description = "View expenses, categories, vendors and recurring templates", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseCreate, Code = "expense.create", Description = "Create and edit draft expenses", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpensePost, Code = "expense.post", Description = "Post a draft expense (approve it)", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpensePay, Code = "expense.pay", Description = "Pay a posted expense (debit a bank account or issue a cheque)", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseVoid, Code = "expense.void", Description = "Void an expense", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseManageCats, Code = "expense.manage_categories", Description = "Manage expense categories, titles and vendors", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Permission { Id = PermExpenseManageRec, Code = "expense.manage_recurring", Description = "Manage recurring expense templates", CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// SuperAdmin gets every permission
|
||||||
|
modelBuilder.Entity<RolePermission>().HasData(
|
||||||
|
// ── Legacy broad permissions ──────────────────────────────────────────
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermOrdersView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRidesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoresView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSalesVisitsCheckin, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermItemsView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermCategoriesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermWarehousesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrdersView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPricelistsView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSuppliersView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverProfilesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermComplianceManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermUsersView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRolesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionsView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRoutesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermVehiclesView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
|
||||||
|
// ── Granular API permissions ──────────────────────────────────────────
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderManage,CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseReturnManage,CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseView, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseCreate, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePost, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePay, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseVoid, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageCats, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageRec, CreatedAt = SeedDate, IsActive = true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using Distribution.Features.User;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -6,32 +8,93 @@ namespace Distribution.Features.Auth;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
|
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : ControllerBase
|
||||||
{
|
{
|
||||||
private static IActionResult ToActionResult(AuthHexResponse result) =>
|
private static IActionResult ToActionResult(AuthHexResponse result) =>
|
||||||
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
|
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
|
[AllowAnonymous]
|
||||||
ToActionResult(await service.LoginAsync(request, ct));
|
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.LoginAsync(request, ct);
|
||||||
|
|
||||||
|
if (!result.Success || result.Data is not { } data)
|
||||||
|
return ToActionResult(result);
|
||||||
|
|
||||||
|
string? token = null;
|
||||||
|
TimeSpan maxAge = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
if (data.TryGetProperty("accessToken", out var tokenEl))
|
||||||
|
token = tokenEl.GetString();
|
||||||
|
|
||||||
|
if (data.TryGetProperty("expiresIn", out var expEl))
|
||||||
|
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
Response.Cookies.Append("access-token", token, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = "/",
|
||||||
|
MaxAge = maxAge,
|
||||||
|
Secure = Request.IsHttps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
MeResponse? profile = null;
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
|
||||||
|
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
|
||||||
|
if (Guid.TryParse(userIdStr, out var userId))
|
||||||
|
{
|
||||||
|
var user = await userRepo.GetByIdAsync(userId);
|
||||||
|
if (user is not null)
|
||||||
|
profile = new MeResponse(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.FK_RoleId,
|
||||||
|
user.Role?.Name ?? string.Empty,
|
||||||
|
user.Role?.Level ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
|
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
|
||||||
ToActionResult(await service.RegisterAsync(request, ct));
|
ToActionResult(await service.RegisterAsync(request, ct));
|
||||||
|
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Me(CancellationToken ct)
|
public async Task<IActionResult> Me()
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
if (currentUser.UserId is null) return Unauthorized();
|
||||||
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
|
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
return Ok(new MeResponse(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.FK_RoleId,
|
||||||
|
user.Role?.Name ?? string.Empty,
|
||||||
|
user.Role?.Level ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("logout")]
|
[HttpPost("logout")]
|
||||||
[Authorize]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Logout(CancellationToken ct)
|
public IActionResult Logout()
|
||||||
{
|
{
|
||||||
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
|
Response.Cookies.Delete("access-token", new CookieOptions
|
||||||
return ToActionResult(await service.LogoutAsync(token, ct));
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = "/",
|
||||||
|
Secure = Request.IsHttps,
|
||||||
|
});
|
||||||
|
return Ok(new { success = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(
|
||||||
|
string Identifier,
|
||||||
|
string Password,
|
||||||
|
Guid? UserTypeId = null,
|
||||||
|
string DeviceName = "Unknown Device");
|
||||||
|
|
||||||
public record RegisterRequest(string Username, string Password, string? FullName);
|
public record RegisterRequest(string Username, string email, string Password, string? FullName);
|
||||||
|
|
||||||
|
public record MeResponse(
|
||||||
|
Guid UserId,
|
||||||
|
string Username,
|
||||||
|
Guid RoleId,
|
||||||
|
string RoleCode,
|
||||||
|
int RoleLevel);
|
||||||
|
|||||||
+3
-4
@@ -1,12 +1,11 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
|
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
|
||||||
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
|
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates;
|
||||||
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
|
/// this app never sees or stores a plaintext/hashed password.
|
||||||
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthHexClient(HttpClient http) : IAuthHexClient
|
public class AuthHexClient(HttpClient http) : IAuthHexClient
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
|
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
|
||||||
public record AuthHexResponse(
|
public record AuthHexResponse(
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
using Distribution.Infra.Authorization;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
|
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
|
||||||
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
|
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
|
||||||
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
|
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates.
|
||||||
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthService(IAuthHexClient authHex)
|
public class AuthService(IAuthHexClient authHex)
|
||||||
{
|
{
|
||||||
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
||||||
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
authHex.CallAsync("loginUser", new
|
||||||
|
{
|
||||||
|
identifier = request.Identifier,
|
||||||
|
password = request.Password,
|
||||||
|
userTypeId = request.UserTypeId,
|
||||||
|
deviceName = request.DeviceName
|
||||||
|
}, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
|
|
||||||
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||||
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
namespace Distribution.Features.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Typed HttpClient that proxies requests to the external Auth Service.
|
|
||||||
/// Registered via AddHttpClient<AuthServiceClient> in Program.cs.
|
|
||||||
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
|
|
||||||
///
|
|
||||||
/// TODO: Implement methods once Auth Service contract is agreed.
|
|
||||||
/// </summary>
|
|
||||||
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
|
|
||||||
{
|
|
||||||
// TODO: implement proxy methods, e.g.:
|
|
||||||
//
|
|
||||||
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
|
|
||||||
// {
|
|
||||||
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
|
|
||||||
// if (!response.IsSuccessStatusCode) return null;
|
|
||||||
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Distribution.Features.Auth;
|
|
||||||
|
|
||||||
public class AuthServiceOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "AuthService";
|
|
||||||
|
|
||||||
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
|
|
||||||
public string BaseUrl { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace Distribution.Infra.Authorization;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
public interface IAuthHexClient
|
public interface IAuthHexClient
|
||||||
{
|
{
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Distribution.Features.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Connector to the external Auth Service. This backend proxies all auth
|
|
||||||
/// traffic from the Next.js frontend through to the Auth Service — the
|
|
||||||
/// frontend never calls the Auth Service directly.
|
|
||||||
///
|
|
||||||
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAuthServiceClient
|
|
||||||
{
|
|
||||||
// TODO: define methods based on Auth Service contract, e.g.:
|
|
||||||
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
|
|
||||||
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
|
|
||||||
// Task LogoutAsync(string token, CancellationToken ct);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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 }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Claims;
|
namespace Distribution.Features.Claims;
|
||||||
@@ -7,10 +8,12 @@ namespace Distribution.Features.Claims;
|
|||||||
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/claims")]
|
[HttpGet("api/claims")]
|
||||||
|
[Authorize("claim.view")]
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("api/claims/{id:guid}")]
|
[HttpGet("api/claims/{id:guid}")]
|
||||||
|
[Authorize("claim.view")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -18,6 +21,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/claims")]
|
[HttpPost("api/claims")]
|
||||||
|
[Authorize("claim.manage")]
|
||||||
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -29,6 +33,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/claims/{id:guid}")]
|
[HttpPut("api/claims/{id:guid}")]
|
||||||
|
[Authorize("claim.manage")]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -40,6 +45,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/claims/{id:guid}/settle")]
|
[HttpPost("api/claims/{id:guid}/settle")]
|
||||||
|
[Authorize("claim.manage")]
|
||||||
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? request.SettledByUserId;
|
var userId = currentUser.UserId ?? request.SettledByUserId;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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,12 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.DeliveryRide;
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api")]
|
[Route("api")]
|
||||||
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||||
|
[Authorize("delivery-task.manage")]
|
||||||
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -20,13 +24,26 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
|
|
||||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||||
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||||
|
[Authorize("delivery-task.view")]
|
||||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByRideIdAsync(rideId);
|
var result = await service.GetByRideIdAsync(rideId);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("delivery-tasks")]
|
||||||
|
//[Authorize("delivery-task.view")]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] DeliveryTaskStatus? status,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(status, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("delivery-tasks/{id:guid}")]
|
[HttpGet("delivery-tasks/{id:guid}")]
|
||||||
|
[Authorize("delivery-task.view")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -34,6 +51,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
||||||
|
[Authorize("delivery-task.manage")]
|
||||||
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -46,11 +64,12 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
||||||
|
[Authorize("delivery-task.manage")]
|
||||||
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.ConfirmAsync(id, ct);
|
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -58,6 +77,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
||||||
|
[Authorize("delivery-task.manage")]
|
||||||
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.DeliveryRide;
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantit
|
|||||||
public record DeliveryTaskResponse(
|
public record DeliveryTaskResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid FK_RideId,
|
Guid FK_RideId,
|
||||||
Guid FK_OrderId,
|
Guid FK_ShopPOId,
|
||||||
Guid StoreId,
|
Guid StoreId,
|
||||||
string StoreName,
|
string StoreName,
|
||||||
int Sequence,
|
int Sequence,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -8,27 +8,48 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
|
|||||||
{
|
{
|
||||||
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
||||||
await context.DeliveryTasks
|
await context.DeliveryTasks
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.Store)
|
.ThenInclude(o => o.Store)
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.OrderItems)
|
.ThenInclude(o => o.Items.Where(i => i.IsActive))
|
||||||
.ThenInclude(i => i.Item)
|
.ThenInclude(i => i.Item)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id);
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
||||||
await context.DeliveryTasks
|
await context.DeliveryTasks
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.Store)
|
.ThenInclude(o => o.Store)
|
||||||
.Include(t => t.Order)
|
.Include(t => t.ShopPO)
|
||||||
.ThenInclude(o => o.OrderItems)
|
.ThenInclude(o => o.Items.Where(i => i.IsActive))
|
||||||
.ThenInclude(i => i.Item)
|
.ThenInclude(i => i.Item)
|
||||||
.Where(t => t.FK_RideId == rideId)
|
.Where(t => t.FK_RideId == rideId)
|
||||||
.OrderBy(t => t.Sequence)
|
.OrderBy(t => t.Sequence)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
|
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var query = context.DeliveryTasks
|
||||||
|
.Include(t => t.ShopPO)
|
||||||
|
.ThenInclude(o => o.Store)
|
||||||
|
.Include(t => t.ShopPO)
|
||||||
|
.ThenInclude(o => o.Items.Where(i => i.IsActive))
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.AsQueryable();
|
||||||
|
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
|
||||||
|
return query.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
|
||||||
|
{
|
||||||
|
var query = context.DeliveryTasks.AsQueryable();
|
||||||
|
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
|
||||||
|
return query.CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
|
||||||
await context.DeliveryTasks
|
await context.DeliveryTasks
|
||||||
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
|
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
|
||||||
|
|
||||||
public async Task AddAsync(DeliveryTask task) =>
|
public async Task AddAsync(DeliveryTask task) =>
|
||||||
await context.DeliveryTasks.AddAsync(task);
|
await context.DeliveryTasks.AddAsync(task);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Distribution.Features.Order;
|
using Distribution.Features.LoadingEvent;
|
||||||
|
using Distribution.Features.Notifications;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.Sale;
|
using Distribution.Features.ShopPO;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -10,23 +11,33 @@ namespace Distribution.Features.DeliveryRide;
|
|||||||
public class DeliveryTaskService(
|
public class DeliveryTaskService(
|
||||||
IDeliveryTaskRepository repo,
|
IDeliveryTaskRepository repo,
|
||||||
IRideRepository rideRepo,
|
IRideRepository rideRepo,
|
||||||
IOrderRepository orderRepo,
|
IShopPORepository shopPORepo,
|
||||||
ISaleRepository saleRepo,
|
ILoadingSheetRepository loadingSheetRepo,
|
||||||
IStockRepository stockRepo,
|
StockMover mover,
|
||||||
|
INotificationService notifications,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
|
private const string DeliveriesLink = "/operations/deliveries";
|
||||||
|
|
||||||
|
// Route owner + managers whose scope covers the store being delivered to.
|
||||||
|
private static List<RecipientRule> DeliveryRecipients(Guid storeId) =>
|
||||||
|
[
|
||||||
|
RecipientRule.RouteOwner(ScopeKind.Store, storeId),
|
||||||
|
RecipientRule.PermissionInScope("delivery-task.manage", ScopeKind.Store, storeId),
|
||||||
|
];
|
||||||
|
|
||||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
new(t.Id,
|
new(t.Id,
|
||||||
t.FK_RideId,
|
t.FK_RideId,
|
||||||
t.FK_OrderId,
|
t.FK_ShopPOId,
|
||||||
t.Order.FK_StoreId,
|
t.ShopPO.FK_StoreId,
|
||||||
t.Order.Store?.StoreName ?? string.Empty,
|
t.ShopPO.Store?.StoreName ?? string.Empty,
|
||||||
t.Sequence,
|
t.Sequence,
|
||||||
t.Status,
|
t.Status,
|
||||||
t.CustomerConfirmed,
|
t.CustomerConfirmed,
|
||||||
t.ConfirmedAt,
|
t.ConfirmedAt,
|
||||||
t.Note,
|
t.Note,
|
||||||
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
|
t.ShopPO.Items.Select(i => new DeliveryTaskItemResponse(
|
||||||
i.FK_ItemId,
|
i.FK_ItemId,
|
||||||
i.Item?.Name ?? string.Empty,
|
i.Item?.Name ?? string.Empty,
|
||||||
i.Quantity,
|
i.Quantity,
|
||||||
@@ -39,43 +50,43 @@ public class DeliveryTaskService(
|
|||||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
?? throw new KeyNotFoundException("Ride not found");
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
if (ride.Status != RideStatus.Active)
|
var confirmedSheets = await loadingSheetRepo.GetAllAsync(rideId, LoadingSheetStatus.Confirmed, null, null, 1, int.MaxValue);
|
||||||
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
|
if (confirmedSheets.Count == 0)
|
||||||
|
throw new InvalidOperationException("Ride has no confirmed loading sheet to generate delivery tasks from");
|
||||||
|
|
||||||
if (ride.RideType != RideType.Delivery)
|
var shopPOIds = confirmedSheets
|
||||||
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
|
.SelectMany(s => s.Orders)
|
||||||
|
.Select(o => o.FK_ShopPOId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
|
var existingTasks = await repo.GetByRideIdAsync(rideId);
|
||||||
|
var nextSequence = existingTasks.Count > 0 ? existingTasks.Max(t => t.Sequence) + 1 : 1;
|
||||||
|
|
||||||
var created = new List<DeliveryTask>();
|
var newTasks = new List<DeliveryTask>();
|
||||||
int sequence = 1;
|
foreach (var shopPOId in shopPOIds)
|
||||||
|
|
||||||
foreach (var order in orders)
|
|
||||||
{
|
{
|
||||||
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
|
if (await repo.ExistsForOrderAndRideAsync(shopPOId, rideId)) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
var task = new DeliveryTask
|
newTasks.Add(new DeliveryTask
|
||||||
{
|
{
|
||||||
FK_RideId = rideId,
|
FK_RideId = rideId,
|
||||||
FK_OrderId = order.Id,
|
FK_ShopPOId = shopPOId,
|
||||||
Sequence = sequence++,
|
Sequence = nextSequence++,
|
||||||
Status = DeliveryTaskStatus.Pending
|
Status = DeliveryTaskStatus.Pending
|
||||||
};
|
});
|
||||||
created.Add(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (created.Count == 0)
|
if (newTasks.Count > 0)
|
||||||
return new List<DeliveryTaskResponse>();
|
{
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var task in created)
|
foreach (var task in newTasks) await repo.AddAsync(task);
|
||||||
await repo.AddAsync(task);
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
var saved = await repo.GetByRideIdAsync(rideId);
|
var saved = await repo.GetByRideIdAsync(rideId);
|
||||||
return saved.Select(ToResponse).ToList();
|
return saved.Select(ToResponse).ToList();
|
||||||
@@ -87,6 +98,13 @@ public class DeliveryTaskService(
|
|||||||
return tasks.Select(ToResponse).ToList();
|
return tasks.Select(ToResponse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResponse<DeliveryTaskResponse>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var items = await repo.GetAllAsync(status, page, pageSize);
|
||||||
|
var total = await repo.GetCountAsync(status);
|
||||||
|
return new PagedResponse<DeliveryTaskResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var task = await repo.GetByIdAsync(id);
|
var task = await repo.GetByIdAsync(id);
|
||||||
@@ -103,16 +121,16 @@ public class DeliveryTaskService(
|
|||||||
|
|
||||||
foreach (var input in request.Items)
|
foreach (var input in request.Items)
|
||||||
{
|
{
|
||||||
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
var shopPOItem = task.ShopPO.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this ShopPO");
|
||||||
|
|
||||||
if (input.QuantityFulfilled < 0)
|
if (input.QuantityFulfilled < 0)
|
||||||
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
||||||
|
|
||||||
if (input.QuantityFulfilled > orderItem.Quantity)
|
if (input.QuantityFulfilled > shopPOItem.Quantity)
|
||||||
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
||||||
|
|
||||||
orderItem.QuantityFulfilled = input.QuantityFulfilled;
|
shopPOItem.QuantityFulfilled = input.QuantityFulfilled;
|
||||||
}
|
}
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
@@ -127,7 +145,7 @@ public class DeliveryTaskService(
|
|||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
|
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var task = await repo.GetByIdAsync(taskId)
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
?? throw new KeyNotFoundException("Delivery task not found");
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
@@ -135,7 +153,7 @@ public class DeliveryTaskService(
|
|||||||
if (task.Status != DeliveryTaskStatus.Pending)
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
||||||
|
|
||||||
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
|
if (!task.ShopPO.Items.Any(i => i.QuantityFulfilled > 0))
|
||||||
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
|
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
|
||||||
|
|
||||||
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
||||||
@@ -143,63 +161,44 @@ public class DeliveryTaskService(
|
|||||||
|
|
||||||
var vehicleId = ride.FK_VehicleId;
|
var vehicleId = ride.FK_VehicleId;
|
||||||
|
|
||||||
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
|
bool fullyDelivered = task.ShopPO.Items.All(i => i.QuantityFulfilled == i.Quantity);
|
||||||
|
|
||||||
task.CustomerConfirmed = true;
|
task.CustomerConfirmed = true;
|
||||||
task.ConfirmedAt = DateTime.Now;
|
task.ConfirmedAt = DateTime.Now;
|
||||||
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
||||||
|
|
||||||
var order = task.Order;
|
var shopPO = task.ShopPO;
|
||||||
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
|
shopPO.Status = fullyDelivered ? ShopPOStatus.Fulfilled : ShopPOStatus.PartiallyFulfilled;
|
||||||
if (fullyDelivered)
|
if (fullyDelivered)
|
||||||
order.FulfilledAt = DateTime.Now;
|
shopPO.FulfilledAt = DateTime.Now;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
|
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
|
||||||
{
|
{
|
||||||
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
|
||||||
var availableQuantity = vehicleBalance?.Quantity ?? 0;
|
|
||||||
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
|
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
|
||||||
|
|
||||||
if (availableQuantity < item.QuantityFulfilled)
|
await mover.MoveAsync(item.FK_ItemId, itemName,
|
||||||
throw new InvalidOperationException(
|
vehicleId, LocationType.Vehicle,
|
||||||
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
|
StockService.ExternalLocationId, LocationType.External,
|
||||||
|
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
|
||||||
vehicleBalance!.Quantity -= item.QuantityFulfilled;
|
|
||||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
|
||||||
stockRepo.UpdateBalance(vehicleBalance);
|
|
||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
|
||||||
{
|
|
||||||
FK_ItemId = item.FK_ItemId,
|
|
||||||
FromLocationId = vehicleId,
|
|
||||||
FromLocationType = LocationType.Vehicle,
|
|
||||||
ToLocationId = StockService.ExternalLocationId,
|
|
||||||
ToLocationType = LocationType.External,
|
|
||||||
Quantity = item.QuantityFulfilled,
|
|
||||||
Type = MovementType.Sale,
|
|
||||||
ReferenceId = task.FK_OrderId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.Update(task);
|
repo.Update(task);
|
||||||
orderRepo.Update(order);
|
shopPORepo.Update(shopPO);
|
||||||
|
|
||||||
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);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (!fullyDelivered && shopPO.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskPartiallyDelivered,
|
||||||
|
DeliveryRecipients(shopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { shopPO.Store.StoreName }, "DeliveryTask", task.Id, DeliveriesLink, userId),
|
||||||
|
ct);
|
||||||
|
|
||||||
var saved = await repo.GetByIdAsync(taskId);
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
return ToResponse(saved!);
|
return ToResponse(saved!);
|
||||||
}
|
}
|
||||||
@@ -223,6 +222,13 @@ public class DeliveryTaskService(
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
if (task.ShopPO?.Store is not null)
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
NotificationTypes.DeliveryTaskFailed,
|
||||||
|
DeliveryRecipients(task.ShopPO.FK_StoreId),
|
||||||
|
new NotificationArgs(new { task.ShopPO.Store.StoreName, Note = note }, "DeliveryTask", task.Id, DeliveriesLink),
|
||||||
|
ct);
|
||||||
|
|
||||||
return ToResponse(task);
|
return ToResponse(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public interface IDeliveryTaskRepository
|
|||||||
{
|
{
|
||||||
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||||
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
||||||
|
Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(DeliveryTaskStatus? status);
|
||||||
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
||||||
Task AddAsync(DeliveryTask task);
|
Task AddAsync(DeliveryTask task);
|
||||||
void Update(DeliveryTask task);
|
void Update(DeliveryTask task);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
@@ -12,6 +13,7 @@ public class DriverProfileController(
|
|||||||
ICurrentUserService currentUser) : ControllerBase
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[Authorize("driver.view")]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
{
|
{
|
||||||
var result = await service.GetAllAsync(page, pageSize);
|
var result = await service.GetAllAsync(page, pageSize);
|
||||||
@@ -19,6 +21,7 @@ public class DriverProfileController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize("driver.view")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -26,6 +29,7 @@ public class DriverProfileController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[Authorize("driver.manage")]
|
||||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -44,6 +48,7 @@ public class DriverProfileController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("driver.manage")]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -51,6 +56,7 @@ public class DriverProfileController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
|
[Authorize("driver.manage")]
|
||||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var success = await service.DeactivateAsync(id, ct);
|
var success = await service.DeactivateAsync(id, ct);
|
||||||
@@ -58,6 +64,7 @@ public class DriverProfileController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("compliance")]
|
[HttpGet("compliance")]
|
||||||
|
[Authorize("driver.view")]
|
||||||
public async Task<IActionResult> GetCompliance()
|
public async Task<IActionResult> GetCompliance()
|
||||||
{
|
{
|
||||||
var result = await complianceService.GetAsync();
|
var result = await complianceService.GetAsync();
|
||||||
@@ -65,6 +72,7 @@ public class DriverProfileController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("compliance")]
|
[HttpPut("compliance")]
|
||||||
|
[Authorize("driver.manage")]
|
||||||
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UploadAsync(expenseId, file, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/expense-attachments/{id:guid}/download")]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.DownloadAsync(id, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("api/expense-attachments/{id:guid}")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||||
|
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseAttachmentService(
|
||||||
|
AppDBContext context,
|
||||||
|
IConfiguration config,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private const long MaxBytes = 10 * 1024 * 1024;
|
||||||
|
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
|
||||||
|
private static readonly string[] AllowedContentTypes =
|
||||||
|
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
private string AttachmentsRoot =>
|
||||||
|
config["Expense:AttachmentsRoot"]
|
||||||
|
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
|
||||||
|
|
||||||
|
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
|
||||||
|
if (expense is null) return null;
|
||||||
|
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
throw new InvalidOperationException("File is empty");
|
||||||
|
if (file.Length > MaxBytes)
|
||||||
|
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext))
|
||||||
|
throw new InvalidOperationException($"File type '{ext}' is not allowed");
|
||||||
|
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||||
|
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
|
||||||
|
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
|
||||||
|
Directory.CreateDirectory(absoluteDir);
|
||||||
|
|
||||||
|
var storedName = $"{Guid.NewGuid()}{ext}";
|
||||||
|
var absolutePath = Path.Combine(absoluteDir, storedName);
|
||||||
|
|
||||||
|
await using (var stream = File.Create(absolutePath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachment = new Models.ExpenseAttachment
|
||||||
|
{
|
||||||
|
FK_ExpenseId = expenseId,
|
||||||
|
OriginalFileName = file.FileName,
|
||||||
|
StoredPath = absolutePath,
|
||||||
|
ContentType = file.ContentType,
|
||||||
|
SizeBytes = file.Length,
|
||||||
|
UploadedByUserId = userId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.ExpenseAttachments.AddAsync(attachment, ct);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
try { File.Delete(absolutePath); } catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
|
||||||
|
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||||
|
if (a is null) return null;
|
||||||
|
if (!File.Exists(a.StoredPath)) return null;
|
||||||
|
|
||||||
|
var stream = File.OpenRead(a.StoredPath);
|
||||||
|
return (stream, a.ContentType, a.OriginalFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||||
|
if (a is null) return false;
|
||||||
|
|
||||||
|
a.IsActive = false;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
// Best-effort file removal — keep DB soft-delete even if the file is gone.
|
||||||
|
try { File.Delete(a.StoredPath); } catch { }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expenses")]
|
||||||
|
public class ExpenseController(
|
||||||
|
ExpenseService service,
|
||||||
|
IPermissionService permissions,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
private async Task<bool> HasAsync(string code)
|
||||||
|
{
|
||||||
|
if (currentUser.RoleId is not { } roleId) return false;
|
||||||
|
return await permissions.HasPermissionAsync(roleId, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] ExpenseStatus? status,
|
||||||
|
[FromQuery] Guid? categoryId,
|
||||||
|
[FromQuery] Guid? warehouseId,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20) =>
|
||||||
|
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
|
||||||
|
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
|
||||||
|
/// (requires expense.post + expense.pay). Draft is the default.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
CreateExpenseRequest request,
|
||||||
|
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
|
||||||
|
return Forbid();
|
||||||
|
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
|
||||||
|
return Forbid();
|
||||||
|
|
||||||
|
try { return Ok(await service.CreateAsync(request, action, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.create")]
|
||||||
|
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateDraftAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/post")]
|
||||||
|
[Authorize("expense.post")]
|
||||||
|
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PostAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/pay")]
|
||||||
|
[Authorize("expense.pay")]
|
||||||
|
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PayAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/void")]
|
||||||
|
[Authorize("expense.void")]
|
||||||
|
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.VoidAsync(id, request?.Reason, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public record ExpensePaymentRequest(
|
||||||
|
PaymentMethod Method,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ChequePageId);
|
||||||
|
|
||||||
|
public record CreateExpenseRequest(
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description,
|
||||||
|
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
|
||||||
|
ExpensePaymentRequest? Payment);
|
||||||
|
|
||||||
|
public record UpdateExpenseDraftRequest(
|
||||||
|
Guid CategoryId,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
public record VoidExpenseRequest(string? Reason);
|
||||||
|
|
||||||
|
public record ExpenseAttachmentResponse(
|
||||||
|
Guid Id,
|
||||||
|
string OriginalFileName,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record ExpenseResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid CategoryId,
|
||||||
|
string CategoryName,
|
||||||
|
Guid? ParentCategoryId,
|
||||||
|
string? ParentCategoryName,
|
||||||
|
Guid? WarehouseId,
|
||||||
|
string? WarehouseName,
|
||||||
|
decimal Amount,
|
||||||
|
DateTime BillDate,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? PeriodCovered,
|
||||||
|
Guid? VendorId,
|
||||||
|
string? VendorName,
|
||||||
|
string? Payee,
|
||||||
|
string? BillRef,
|
||||||
|
string? Description,
|
||||||
|
ExpenseStatus Status,
|
||||||
|
PaymentMethod? Method,
|
||||||
|
Guid? BankAccountId,
|
||||||
|
Guid? ChequePageId,
|
||||||
|
string? ChequeNo,
|
||||||
|
DateTime? PaidAt,
|
||||||
|
Guid RecordedByUserId,
|
||||||
|
Guid? PostedByUserId,
|
||||||
|
Guid? PaidByUserId,
|
||||||
|
Guid? VoidedByUserId,
|
||||||
|
string? VoidReason,
|
||||||
|
Guid? RecurringTemplateId,
|
||||||
|
List<ExpenseAttachmentResponse> Attachments,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
|
||||||
|
{
|
||||||
|
private IQueryable<Models.Expense> Base() =>
|
||||||
|
context.Expenses
|
||||||
|
.Include(e => e.Category!).ThenInclude(c => c.Parent)
|
||||||
|
.Include(e => e.Warehouse)
|
||||||
|
.Include(e => e.Vendor)
|
||||||
|
.Include(e => e.ChequePage)
|
||||||
|
.Include(e => e.Attachments.Where(a => a.IsActive));
|
||||||
|
|
||||||
|
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
|
||||||
|
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
|
||||||
|
|
||||||
|
private IQueryable<Models.Expense> Filtered(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||||
|
Base()
|
||||||
|
.Where(e => e.IsActive)
|
||||||
|
.Where(e => !status.HasValue || e.Status == status)
|
||||||
|
.Where(e => !categoryId.HasValue
|
||||||
|
|| e.FK_CategoryId == categoryId
|
||||||
|
|| e.Category!.FK_ParentCategoryId == categoryId)
|
||||||
|
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
|
||||||
|
.Where(e => !from.HasValue || e.BillDate >= from)
|
||||||
|
.Where(e => !to.HasValue || e.BillDate <= to);
|
||||||
|
|
||||||
|
public async Task<List<Models.Expense>> ListAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||||
|
int page, int pageSize) =>
|
||||||
|
await Filtered(status, categoryId, warehouseId, from, to)
|
||||||
|
.OrderByDescending(e => e.BillDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||||
|
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Expense entity) =>
|
||||||
|
await context.Expenses.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Expense entity) =>
|
||||||
|
context.Expenses.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Features.BankAccount;
|
||||||
|
using Distribution.Features.ChequeBook;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public class ExpenseService(
|
||||||
|
AppDBContext context,
|
||||||
|
IExpenseRepository repo,
|
||||||
|
IBankAccountRepository bankAccountRepo,
|
||||||
|
IChequeBookRepository chequeBookRepo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
|
||||||
|
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
|
||||||
|
|
||||||
|
private static ExpenseResponse ToResponse(Models.Expense e) =>
|
||||||
|
new(e.Id,
|
||||||
|
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
|
||||||
|
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
|
||||||
|
e.FK_WarehouseId, e.Warehouse?.Name,
|
||||||
|
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
|
||||||
|
e.FK_VendorId, e.Vendor?.Name,
|
||||||
|
e.Payee, e.BillRef, e.Description,
|
||||||
|
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
|
||||||
|
e.PaidAt,
|
||||||
|
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
|
||||||
|
e.FK_RecurringTemplateId,
|
||||||
|
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
|
||||||
|
e.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedExpensesResponse> ListAsync(
|
||||||
|
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||||
|
int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||||
|
|
||||||
|
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
|
||||||
|
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
|
||||||
|
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> GetAsync(Guid id)
|
||||||
|
{
|
||||||
|
var e = await repo.GetByIdAsync(id);
|
||||||
|
return e is null ? null : ToResponse(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ValidateBase(request);
|
||||||
|
|
||||||
|
var category = await context.ExpenseCategories.Include(c => c.Parent)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||||
|
?? throw new InvalidOperationException("Category not found");
|
||||||
|
if (category.FK_ParentCategoryId is null)
|
||||||
|
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
var entity = new Models.Expense
|
||||||
|
{
|
||||||
|
FK_CategoryId = request.CategoryId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
BillDate = request.BillDate.Date,
|
||||||
|
DueDate = request.DueDate?.Date,
|
||||||
|
PeriodCovered = request.PeriodCovered?.Date,
|
||||||
|
FK_VendorId = request.VendorId,
|
||||||
|
Payee = request.Payee,
|
||||||
|
BillRef = request.BillRef,
|
||||||
|
Description = request.Description,
|
||||||
|
RecordedByUserId = userId,
|
||||||
|
Status = ExpenseStatus.Draft
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(entity);
|
||||||
|
|
||||||
|
if (action is ExpenseAction.Post or ExpenseAction.Pay)
|
||||||
|
{
|
||||||
|
entity.Status = ExpenseStatus.Posted;
|
||||||
|
entity.PostedByUserId = userId;
|
||||||
|
entity.PostedAt = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == ExpenseAction.Pay)
|
||||||
|
{
|
||||||
|
if (request.Payment is null)
|
||||||
|
throw new InvalidOperationException("Payment details are required when action = Pay");
|
||||||
|
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Only Draft expenses can be edited");
|
||||||
|
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
|
||||||
|
entity.FK_CategoryId = request.CategoryId;
|
||||||
|
entity.FK_WarehouseId = request.WarehouseId;
|
||||||
|
entity.Amount = request.Amount;
|
||||||
|
entity.BillDate = request.BillDate.Date;
|
||||||
|
entity.DueDate = request.DueDate?.Date;
|
||||||
|
entity.PeriodCovered = request.PeriodCovered?.Date;
|
||||||
|
entity.FK_VendorId = request.VendorId;
|
||||||
|
entity.Payee = request.Payee;
|
||||||
|
entity.BillRef = request.BillRef;
|
||||||
|
entity.Description = request.Description;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Only Draft expenses can be posted");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
entity.Status = ExpenseStatus.Posted;
|
||||||
|
entity.PostedByUserId = userId;
|
||||||
|
entity.PostedAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status != ExpenseStatus.Posted)
|
||||||
|
throw new InvalidOperationException("Only Posted expenses can be paid");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyPaymentAsync(entity, payment, userId, ct);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
if (entity.Status == ExpenseStatus.Void)
|
||||||
|
throw new InvalidOperationException("Already voided");
|
||||||
|
if (entity.Status == ExpenseStatus.Paid)
|
||||||
|
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId
|
||||||
|
?? throw new InvalidOperationException("Authenticated user is required");
|
||||||
|
|
||||||
|
entity.Status = ExpenseStatus.Void;
|
||||||
|
entity.VoidedByUserId = userId;
|
||||||
|
entity.VoidedAt = DateTime.Now;
|
||||||
|
entity.VoidReason = reason;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ValidateBase(CreateExpenseRequest request)
|
||||||
|
{
|
||||||
|
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||||
|
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
|
||||||
|
throw new InvalidOperationException("Due date cannot be earlier than bill date");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
|
||||||
|
/// Cheque → consume an Unused ChequePage from an Active cheque book.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (payment.Method == PaymentMethod.Credit)
|
||||||
|
throw new InvalidOperationException("Credit is not a valid method for expense payments");
|
||||||
|
|
||||||
|
if (payment.Method == PaymentMethod.Cash)
|
||||||
|
{
|
||||||
|
if (payment.BankAccountId is null)
|
||||||
|
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||||
|
|
||||||
|
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
|
||||||
|
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||||
|
if (account.IsActive is false)
|
||||||
|
throw new InvalidOperationException("The selected account is inactive");
|
||||||
|
|
||||||
|
entity.FK_BankAccountId = account.Id;
|
||||||
|
}
|
||||||
|
else // Cheque
|
||||||
|
{
|
||||||
|
if (payment.ChequePageId is null)
|
||||||
|
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
|
||||||
|
|
||||||
|
var page = await context.ChequePages
|
||||||
|
.Include(p => p.ChequeBook)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == payment.ChequePageId.Value, ct)
|
||||||
|
?? throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
|
||||||
|
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||||
|
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
|
||||||
|
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
|
||||||
|
throw new InvalidOperationException("The cheque book is not active");
|
||||||
|
|
||||||
|
page.IssueStatus = ChequePageIssueStatus.Issued;
|
||||||
|
page.PayeeType = PayeeType.Other;
|
||||||
|
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
|
||||||
|
page.IssueDate = DateTime.Now;
|
||||||
|
page.Amount = entity.Amount;
|
||||||
|
page.CurrencyCode = "LKR";
|
||||||
|
page.PrintedAt = DateTime.Now;
|
||||||
|
|
||||||
|
chequeBookRepo.UpdatePage(page);
|
||||||
|
entity.FK_ChequePageId = page.Id;
|
||||||
|
entity.ChequePage = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Method = payment.Method;
|
||||||
|
entity.Status = ExpenseStatus.Paid;
|
||||||
|
entity.PaidByUserId = userId;
|
||||||
|
entity.PaidAt = DateTime.Now;
|
||||||
|
|
||||||
|
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||||
|
// Debit Expense (category)
|
||||||
|
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Expense;
|
||||||
|
|
||||||
|
public interface IExpenseRepository
|
||||||
|
{
|
||||||
|
Task<Models.Expense?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Expense>> ListAsync(
|
||||||
|
ExpenseStatus? status,
|
||||||
|
Guid? categoryId,
|
||||||
|
Guid? warehouseId,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
int page,
|
||||||
|
int pageSize);
|
||||||
|
Task<int> CountAsync(
|
||||||
|
ExpenseStatus? status,
|
||||||
|
Guid? categoryId,
|
||||||
|
Guid? warehouseId,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to);
|
||||||
|
Task AddAsync(Models.Expense entity);
|
||||||
|
void Update(Models.Expense entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-categories")]
|
||||||
|
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public record ExpenseCategoryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
Guid? ParentCategoryId,
|
||||||
|
bool IsActive,
|
||||||
|
List<ExpenseCategoryResponse> Children);
|
||||||
|
|
||||||
|
public record CreateExpenseCategoryRequest(
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
Guid? ParentCategoryId);
|
||||||
|
|
||||||
|
public record UpdateExpenseCategoryRequest(
|
||||||
|
string Name,
|
||||||
|
string? Code,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
|
||||||
|
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.ExpenseCategory entity) =>
|
||||||
|
await context.ExpenseCategories.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.ExpenseCategory entity) =>
|
||||||
|
context.ExpenseCategories.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
|
||||||
|
{
|
||||||
|
var all = await repo.GetAllAsync();
|
||||||
|
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
|
||||||
|
|
||||||
|
return all
|
||||||
|
.Where(c => c.FK_ParentCategoryId is null)
|
||||||
|
.Select(c => Map(c, byParent))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
|
||||||
|
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
|
||||||
|
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
throw new InvalidOperationException("Name is required");
|
||||||
|
|
||||||
|
if (request.ParentCategoryId is { } parentId)
|
||||||
|
{
|
||||||
|
var parent = await repo.GetByIdAsync(parentId)
|
||||||
|
?? throw new InvalidOperationException("Parent category not found");
|
||||||
|
if (parent.FK_ParentCategoryId is not null)
|
||||||
|
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new Models.ExpenseCategory
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
|
||||||
|
FK_ParentCategoryId = request.ParentCategoryId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
entity.Name = request.Name.Trim();
|
||||||
|
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseCategory;
|
||||||
|
|
||||||
|
public interface IExpenseCategoryRepository
|
||||||
|
{
|
||||||
|
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.ExpenseCategory>> GetAllAsync();
|
||||||
|
Task AddAsync(Models.ExpenseCategory entity);
|
||||||
|
void Update(Models.ExpenseCategory entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-vendors")]
|
||||||
|
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("expense.view")]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50) =>
|
||||||
|
Ok(await service.ListAsync(search, page, pageSize));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize("expense.manage_categories")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
|
||||||
|
|
||||||
|
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
|
||||||
|
|
||||||
|
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
|
||||||
|
await context.ExpenseVendors
|
||||||
|
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
|
||||||
|
.OrderBy(v => v.Name)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> CountAsync(string? search) =>
|
||||||
|
await context.ExpenseVendors
|
||||||
|
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
|
||||||
|
|
||||||
|
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
|
||||||
|
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.ExpenseVendor entity) =>
|
||||||
|
await context.ExpenseVendors.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.ExpenseVendor entity) =>
|
||||||
|
context.ExpenseVendors.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
|
||||||
|
|
||||||
|
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
|
||||||
|
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
|
||||||
|
|
||||||
|
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||||
|
|
||||||
|
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
|
||||||
|
var items = await repo.ListAsync(search, page, pageSize);
|
||||||
|
var total = await repo.CountAsync(search);
|
||||||
|
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var name = (request.Name ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||||
|
if (await repo.NameExistsAsync(name, null))
|
||||||
|
throw new InvalidOperationException("A vendor with this name already exists");
|
||||||
|
|
||||||
|
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
if (entity is null) return null;
|
||||||
|
|
||||||
|
var name = (request.Name ?? "").Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||||
|
if (await repo.NameExistsAsync(name, id))
|
||||||
|
throw new InvalidOperationException("A vendor with this name already exists");
|
||||||
|
|
||||||
|
entity.Name = name;
|
||||||
|
entity.ContactInfo = request.ContactInfo;
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.ExpenseVendor;
|
||||||
|
|
||||||
|
public interface IExpenseVendorRepository
|
||||||
|
{
|
||||||
|
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
|
||||||
|
Task<int> CountAsync(string? search);
|
||||||
|
Task<bool> NameExistsAsync(string name, Guid? excludeId);
|
||||||
|
Task AddAsync(Models.ExpenseVendor entity);
|
||||||
|
void Update(Models.ExpenseVendor entity);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Grn;
|
namespace Distribution.Features.Grn;
|
||||||
@@ -7,6 +8,7 @@ namespace Distribution.Features.Grn;
|
|||||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/goods-receipts")]
|
[HttpGet("api/goods-receipts")]
|
||||||
|
[Authorize("grn.view")]
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? purchaseOrderId,
|
[FromQuery] Guid? purchaseOrderId,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
@@ -17,6 +19,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||||
|
[Authorize("grn.view")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -24,6 +27,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/goods-receipts")]
|
[HttpPost("api/goods-receipts")]
|
||||||
|
[Authorize("grn.manage")]
|
||||||
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
@@ -32,18 +36,36 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
|||||||
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
var inner = new CreateGrnRequest(request.WarehouseId, request.Items,
|
||||||
|
DiscountPercent: request.DiscountPercent, VatPercent: request.VatPercent, NbtPercent: request.NbtPercent);
|
||||||
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
|
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Goods received without a purchase order.</summary>
|
||||||
|
[HttpPost("api/goods-receipts/direct")]
|
||||||
|
[Authorize("grn.manage")]
|
||||||
|
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateDirectAsync(request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
|
[Authorize("grn.view")]
|
||||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
|
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
|
[Authorize("grn.manage")]
|
||||||
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||||
|
|||||||
@@ -1,35 +1,104 @@
|
|||||||
namespace Distribution.Features.Grn;
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
|
/// <summary>BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches).</summary>
|
||||||
|
public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
|
||||||
|
|
||||||
|
public record GrnItemRequest(
|
||||||
|
// Set for a PO-based receipt; null for a direct receipt (ItemId is used instead).
|
||||||
|
Guid? PurchaseOrderItemId,
|
||||||
|
int AcceptedQuantity,
|
||||||
|
int RejectedQuantity,
|
||||||
|
// Populated only when the item has TracksBatches and/or TracksExpiryDate set.
|
||||||
|
List<GrnBatchLineRequest>? Batches = null,
|
||||||
|
// Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries.
|
||||||
|
List<string>? SerialNumbers = null,
|
||||||
|
// Set for a direct receipt line (no purchase order).
|
||||||
|
Guid? ItemId = null,
|
||||||
|
// Cost per base-UOM unit. Null on a PO line falls back to the PO line's unit cost; null on a direct line is 0.
|
||||||
|
decimal? UnitCost = null,
|
||||||
|
// Null falls back to the GRN-wide percentage.
|
||||||
|
decimal? DiscountPercent = null,
|
||||||
|
decimal? VatPercent = null,
|
||||||
|
// Free-issue units: stocked but not costed. Serial/batch entries must cover accepted + free.
|
||||||
|
int FreeQuantity = 0,
|
||||||
|
// Ignored when NBT is disabled in configuration; null falls back to the GRN-wide percentage.
|
||||||
|
decimal? NbtPercent = null);
|
||||||
|
|
||||||
public record CreateGrnRequest(
|
public record CreateGrnRequest(
|
||||||
Guid WarehouseId,
|
Guid WarehouseId,
|
||||||
List<GrnItemRequest> Items,
|
List<GrnItemRequest> Items,
|
||||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
Guid? ReceivedByUserId = null);
|
Guid? ReceivedByUserId = null,
|
||||||
|
// GRN-wide defaults for lines that don't set their own.
|
||||||
|
decimal? DiscountPercent = null,
|
||||||
|
decimal? VatPercent = null,
|
||||||
|
decimal? NbtPercent = null);
|
||||||
|
|
||||||
public record CreateFlatGrnRequest(
|
public record CreateFlatGrnRequest(
|
||||||
Guid PurchaseOrderId,
|
Guid PurchaseOrderId,
|
||||||
Guid WarehouseId,
|
Guid WarehouseId,
|
||||||
List<GrnItemRequest> Items,
|
List<GrnItemRequest> Items,
|
||||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
Guid? ReceivedByUserId = null);
|
Guid? ReceivedByUserId = null,
|
||||||
|
decimal? DiscountPercent = null,
|
||||||
|
decimal? VatPercent = null,
|
||||||
|
decimal? NbtPercent = null);
|
||||||
|
|
||||||
|
/// <summary>A goods receipt with no purchase order. SupplierId is optional.</summary>
|
||||||
|
public record CreateDirectGrnRequest(
|
||||||
|
Guid WarehouseId,
|
||||||
|
List<GrnItemRequest> Items,
|
||||||
|
Guid? SupplierId = null,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? ReceivedByUserId = null,
|
||||||
|
decimal? DiscountPercent = null,
|
||||||
|
decimal? VatPercent = null,
|
||||||
|
decimal? NbtPercent = null);
|
||||||
|
|
||||||
|
public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity);
|
||||||
|
|
||||||
public record GrnItemResponse(
|
public record GrnItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderItemId,
|
Guid? PurchaseOrderItemId,
|
||||||
Guid ItemId,
|
Guid ItemId,
|
||||||
string Sku,
|
string Sku,
|
||||||
string ItemName,
|
string ItemName,
|
||||||
int AcceptedQuantity,
|
int AcceptedQuantity,
|
||||||
int RejectedQuantity);
|
int RejectedQuantity,
|
||||||
|
List<GrnBatchLineResponse> Batches,
|
||||||
|
List<string> SerialNumbers,
|
||||||
|
decimal UnitCost,
|
||||||
|
decimal DiscountPercent,
|
||||||
|
decimal VatPercent,
|
||||||
|
// Accepted qty x unit cost, before discount.
|
||||||
|
decimal GrossAmount,
|
||||||
|
decimal DiscountAmount,
|
||||||
|
decimal VatAmount,
|
||||||
|
// Gross - discount + VAT.
|
||||||
|
decimal LineTotal,
|
||||||
|
int FreeQuantity,
|
||||||
|
decimal NbtPercent,
|
||||||
|
decimal NbtAmount);
|
||||||
|
|
||||||
public record GrnResponse(
|
public record GrnResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderId,
|
Guid? PurchaseOrderId,
|
||||||
Guid ReceivedByUserId,
|
Guid ReceivedByUserId,
|
||||||
Guid WarehouseId,
|
Guid WarehouseId,
|
||||||
string WarehouseName,
|
string WarehouseName,
|
||||||
|
Guid? SupplierId,
|
||||||
|
string? SupplierName,
|
||||||
DateTime ReceivedAt,
|
DateTime ReceivedAt,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<GrnItemResponse> Items);
|
List<GrnItemResponse> Items,
|
||||||
|
decimal DiscountPercent,
|
||||||
|
decimal VatPercent,
|
||||||
|
// Sum of line gross amounts.
|
||||||
|
decimal SubTotal,
|
||||||
|
decimal DiscountAmount,
|
||||||
|
// SubTotal - DiscountAmount (the base NBT is charged on; VAT is charged on this plus NBT).
|
||||||
|
decimal TaxableAmount,
|
||||||
|
decimal VatAmount,
|
||||||
|
decimal TotalAmount,
|
||||||
|
decimal NbtPercent,
|
||||||
|
decimal NbtAmount);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public record LineAmounts(decimal Gross, decimal Discount, decimal Nbt, decimal Vat, decimal Total);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GRN costing. Per line: gross = accepted qty x unit cost; discount = gross x disc%;
|
||||||
|
/// NBT = (gross - discount) x NBT%; VAT = (gross - discount + NBT) x VAT%;
|
||||||
|
/// total = gross - discount + NBT + VAT. Rejected units are never costed.
|
||||||
|
/// Each amount is rounded to 2 dp per line so the GRN totals equal the sum of what the lines show.
|
||||||
|
/// </summary>
|
||||||
|
public static class GrnPricing
|
||||||
|
{
|
||||||
|
public static decimal Round(decimal value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
public static LineAmounts Line(
|
||||||
|
int acceptedQty, decimal unitCost, decimal discountPercent, decimal nbtPercent, decimal vatPercent)
|
||||||
|
{
|
||||||
|
var gross = Round(acceptedQty * unitCost);
|
||||||
|
var discount = Round(gross * discountPercent / 100m);
|
||||||
|
var net = gross - discount;
|
||||||
|
var nbt = Round(net * nbtPercent / 100m);
|
||||||
|
var vat = Round((net + nbt) * vatPercent / 100m);
|
||||||
|
return new LineAmounts(gross, discount, nbt, vat, net + nbt + vat);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ValidatePercent(decimal value, string label)
|
||||||
|
{
|
||||||
|
if (value < 0 || value > 100)
|
||||||
|
throw new InvalidOperationException($"{label} must be between 0 and 100.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ValidateCost(decimal value, string label)
|
||||||
|
{
|
||||||
|
if (value < 0)
|
||||||
|
throw new InvalidOperationException($"{label} cannot be negative.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.Include(gr => gr.Warehouse)
|
||||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
.Include(gr => gr.Supplier)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Item)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
|
||||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||||
|
|
||||||
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.Include(gr => gr.Warehouse)
|
||||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
.Include(gr => gr.Supplier)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Item)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
|
||||||
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
.OrderByDescending(gr => gr.ReceivedAt)
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
@@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.Include(gr => gr.Warehouse)
|
||||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
.Include(gr => gr.Supplier)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Item)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber)
|
||||||
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||||
.OrderByDescending(gr => gr.ReceivedAt)
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceiptItems
|
await context.GoodsReceiptItems
|
||||||
.Include(i => i.PurchaseOrderItem)
|
.Include(i => i.PurchaseOrderItem)
|
||||||
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
.Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
.GroupBy(i => i.PurchaseOrderItem!.FK_ItemId)
|
||||||
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
||||||
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.SysConfig;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
@@ -9,15 +10,88 @@ public class GrnService(
|
|||||||
IGrnRepository repo,
|
IGrnRepository repo,
|
||||||
IPoRepository poRepo,
|
IPoRepository poRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow,
|
||||||
|
Distribution.Features.Item.IItemRepository itemRepo,
|
||||||
|
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||||
|
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||||
{
|
{
|
||||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
|
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||||
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
|
{
|
||||||
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
|
// Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line.
|
||||||
|
var item = i.Item ?? i.PurchaseOrderItem!.Item;
|
||||||
|
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
|
||||||
|
return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name,
|
||||||
|
i.AcceptedQuantity, i.RejectedQuantity,
|
||||||
|
i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(),
|
||||||
|
i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList(),
|
||||||
|
i.UnitCost, i.DiscountPercent, i.VatPercent, a.Gross, a.Discount, a.Vat, a.Total, i.FreeQuantity,
|
||||||
|
i.NbtPercent, a.Nbt);
|
||||||
|
}
|
||||||
|
|
||||||
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
/// <summary>Units that actually enter stock: accepted plus free-issue. Serial/batch entries must cover this.</summary>
|
||||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
private static int StockQty(GrnItemRequest line) => line.AcceptedQuantity + line.FreeQuantity;
|
||||||
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
|
||||||
|
private static GrnResponse Map(Models.GoodsReceipt gr)
|
||||||
|
{
|
||||||
|
var items = gr.Items.Where(i => i.IsActive).Select(MapItem).ToList();
|
||||||
|
var subTotal = items.Sum(i => i.GrossAmount);
|
||||||
|
var discount = items.Sum(i => i.DiscountAmount);
|
||||||
|
var vat = items.Sum(i => i.VatAmount);
|
||||||
|
var nbt = items.Sum(i => i.NbtAmount);
|
||||||
|
return new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||||
|
gr.FK_SupplierId, gr.Supplier?.Name,
|
||||||
|
gr.ReceivedAt, gr.CreatedAt, items,
|
||||||
|
gr.DiscountPercent, gr.VatPercent,
|
||||||
|
subTotal, discount, subTotal - discount, vat, subTotal - discount + nbt + vat,
|
||||||
|
gr.NbtPercent, nbt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The percentages a receipt is saved with, after the configured tax rules were applied.</summary>
|
||||||
|
private record GrnHeader(decimal Discount, decimal Vat, decimal Nbt, decimal? RequestedVat, decimal? RequestedNbt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates and resolves the GRN-wide percentages that lines fall back to. A disabled tax is
|
||||||
|
/// forced to 0 whatever the client sent.
|
||||||
|
/// </summary>
|
||||||
|
private static GrnHeader ResolveHeader(GrnTaxSettings tax, decimal? discountPercent, decimal? vatPercent, decimal? nbtPercent)
|
||||||
|
{
|
||||||
|
var discount = discountPercent ?? 0m;
|
||||||
|
var vat = tax.VatMode == TaxMode.Disabled ? 0m : vatPercent ?? 0m;
|
||||||
|
var nbt = tax.NbtMode == TaxMode.Disabled ? 0m : nbtPercent ?? 0m;
|
||||||
|
GrnPricing.ValidatePercent(discount, "GRN discount %");
|
||||||
|
GrnPricing.ValidatePercent(vat, "GRN VAT %");
|
||||||
|
GrnPricing.ValidatePercent(nbt, "GRN NBT %");
|
||||||
|
return new GrnHeader(discount, vat, nbt, vatPercent, nbtPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A line's rate for one tax: 0 when the tax is disabled; otherwise the line's own value, else the
|
||||||
|
/// GRN-wide one. A Mandatory tax must be supplied somewhere; an Optional one defaults to 0.
|
||||||
|
/// </summary>
|
||||||
|
private static decimal ResolveTax(TaxMode mode, decimal? headerValue, decimal? lineValue, string label)
|
||||||
|
{
|
||||||
|
if (mode == TaxMode.Disabled) return 0m;
|
||||||
|
var value = lineValue ?? headerValue;
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
if (mode == TaxMode.Mandatory) throw new InvalidOperationException($"{label} is mandatory.");
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
GrnPricing.ValidatePercent(value.Value, label);
|
||||||
|
return value.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies a line's resolved cost, discount %, NBT % and VAT % onto the entity after validating them.</summary>
|
||||||
|
private static void ApplyPricing(Models.GoodsReceiptItem entity, GrnItemRequest line, decimal fallbackUnitCost,
|
||||||
|
GrnHeader header, GrnTaxSettings tax, string sku)
|
||||||
|
{
|
||||||
|
entity.UnitCost = line.UnitCost ?? fallbackUnitCost;
|
||||||
|
entity.DiscountPercent = line.DiscountPercent ?? header.Discount;
|
||||||
|
entity.VatPercent = ResolveTax(tax.VatMode, header.RequestedVat, line.VatPercent, $"VAT % for item {sku}");
|
||||||
|
entity.NbtPercent = ResolveTax(tax.NbtMode, header.RequestedNbt, line.NbtPercent, $"NBT % for item {sku}");
|
||||||
|
GrnPricing.ValidateCost(entity.UnitCost, $"Unit cost for item {sku}");
|
||||||
|
GrnPricing.ValidatePercent(entity.DiscountPercent, $"Discount % for item {sku}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||||
{
|
{
|
||||||
@@ -36,10 +110,61 @@ public class GrnService(
|
|||||||
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an
|
||||||
|
/// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for
|
||||||
|
/// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not
|
||||||
|
/// yet linked into the ledger (follow-up work).
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateLineShape(Models.Item item, GrnItemRequest line)
|
||||||
|
{
|
||||||
|
if (item.TracksSerialNumbers)
|
||||||
|
{
|
||||||
|
if (StockQty(line) > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != StockQty(line)))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Item {item.Sku} tracks serial numbers — expected {StockQty(line)} serial number(s).");
|
||||||
|
|
||||||
|
if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count)
|
||||||
|
throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}.");
|
||||||
|
}
|
||||||
|
else if (line.SerialNumbers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers.");
|
||||||
|
}
|
||||||
|
else if (item.TracksBatches || item.TracksExpiryDate)
|
||||||
|
{
|
||||||
|
if (StockQty(line) > 0)
|
||||||
|
{
|
||||||
|
if (line.Batches is null || line.Batches.Count == 0)
|
||||||
|
throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information.");
|
||||||
|
if (line.Batches.Sum(b => b.Quantity) != StockQty(line))
|
||||||
|
throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var b in line.Batches ?? [])
|
||||||
|
{
|
||||||
|
if (b.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive.");
|
||||||
|
if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber))
|
||||||
|
throw new InvalidOperationException($"Item {item.Sku} requires a batch number.");
|
||||||
|
if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber))
|
||||||
|
throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers.");
|
||||||
|
if (item.TracksExpiryDate && b.ExpiryDate is null)
|
||||||
|
throw new InvalidOperationException($"Item {item.Sku} requires an expiry date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (line.Batches is { Count: > 0 })
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
||||||
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
|
/// StockMovement for AcceptedQuantity + FreeQuantity (RejectedQuantity never touches stock; free
|
||||||
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
|
/// units don't count against the PO's ordered quantity), creates
|
||||||
|
/// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to
|
||||||
|
/// PartiallyReceived/Received. All in one unit of work.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -56,37 +181,63 @@ public class GrnService(
|
|||||||
if (request.Items is null || request.Items.Count == 0)
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||||
|
|
||||||
|
var tax = await sysConfig.GetGrnTaxSettingsAsync();
|
||||||
|
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
|
||||||
|
|
||||||
var goodsReceipt = new Models.GoodsReceipt
|
var goodsReceipt = new Models.GoodsReceipt
|
||||||
{
|
{
|
||||||
FK_PurchaseOrderId = po.Id,
|
FK_PurchaseOrderId = po.Id,
|
||||||
FK_ReceivedByUserId = receivedByUserId,
|
FK_ReceivedByUserId = receivedByUserId,
|
||||||
FK_WarehouseId = request.WarehouseId,
|
FK_WarehouseId = request.WarehouseId,
|
||||||
ReceivedAt = DateTime.Now
|
ReceivedAt = DateTime.Now,
|
||||||
|
DiscountPercent = header.Discount,
|
||||||
|
VatPercent = header.Vat,
|
||||||
|
NbtPercent = header.Nbt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
||||||
|
|
||||||
foreach (var line in request.Items)
|
foreach (var line in request.Items)
|
||||||
{
|
{
|
||||||
|
if (line.PurchaseOrderItemId is null)
|
||||||
|
throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId.");
|
||||||
|
|
||||||
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
||||||
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||||
|
|
||||||
if (poItem.IsCancelled)
|
if (poItem.IsCancelled)
|
||||||
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
||||||
|
|
||||||
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
|
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
|
||||||
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||||
|
|
||||||
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
||||||
|
|
||||||
|
ValidateLineShape(poItem.Item, line);
|
||||||
|
|
||||||
|
if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var serialNumber in line.SerialNumbers)
|
||||||
|
{
|
||||||
|
if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber))
|
||||||
|
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||||
|
|
||||||
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
var entity = new Models.GoodsReceiptItem
|
||||||
{
|
{
|
||||||
FK_PurchaseOrderItemId = poItem.Id,
|
FK_PurchaseOrderItemId = poItem.Id,
|
||||||
AcceptedQuantity = line.AcceptedQuantity,
|
AcceptedQuantity = line.AcceptedQuantity,
|
||||||
|
FreeQuantity = line.FreeQuantity,
|
||||||
RejectedQuantity = line.RejectedQuantity
|
RejectedQuantity = line.RejectedQuantity
|
||||||
});
|
};
|
||||||
|
ApplyPricing(entity, line, poItem.UnitCost, header, tax, poItem.Item.Sku);
|
||||||
|
goodsReceipt.Items.Add(entity);
|
||||||
|
grLines.Add((entity, line, poItem.Item));
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||||
@@ -94,48 +245,238 @@ public class GrnService(
|
|||||||
? PurchaseOrderStatus.Received
|
? PurchaseOrderStatus.Received
|
||||||
: PurchaseOrderStatus.PartiallyReceived;
|
: PurchaseOrderStatus.PartiallyReceived;
|
||||||
|
|
||||||
|
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct);
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// A receipt with no purchase order: lines name the item directly. Stock, serial and batch
|
||||||
|
/// handling is identical to a PO receipt; there is no PO quantity or status to update.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GrnResponse> CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||||
|
|
||||||
|
if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true })
|
||||||
|
throw new InvalidOperationException("Supplier not found.");
|
||||||
|
|
||||||
|
var tax = await sysConfig.GetGrnTaxSettingsAsync();
|
||||||
|
var header = ResolveHeader(tax, request.DiscountPercent, request.VatPercent, request.NbtPercent);
|
||||||
|
|
||||||
|
var goodsReceipt = new Models.GoodsReceipt
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = null,
|
||||||
|
FK_SupplierId = request.SupplierId,
|
||||||
|
FK_ReceivedByUserId = receivedByUserId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
ReceivedAt = DateTime.Now,
|
||||||
|
DiscountPercent = header.Discount,
|
||||||
|
VatPercent = header.Vat,
|
||||||
|
NbtPercent = header.Nbt
|
||||||
|
};
|
||||||
|
|
||||||
|
var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>();
|
||||||
|
var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>();
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.ItemId is null)
|
||||||
|
throw new InvalidOperationException("Each line of a direct receipt needs an ItemId.");
|
||||||
|
|
||||||
|
var item = await itemRepo.GetByIdAsync(line.ItemId.Value);
|
||||||
|
if (item is null || !item.IsActive)
|
||||||
|
throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0 || line.FreeQuantity < 0)
|
||||||
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity + line.RejectedQuantity + line.FreeQuantity == 0)
|
||||||
|
throw new InvalidOperationException($"Line for item {item.Sku} has no quantity.");
|
||||||
|
|
||||||
|
ValidateLineShape(item, line);
|
||||||
|
|
||||||
|
if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var serialNumber in line.SerialNumbers)
|
||||||
|
{
|
||||||
|
if (!serialsInRequest.Add((item.Id, serialNumber)))
|
||||||
|
throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}.");
|
||||||
|
if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber))
|
||||||
|
throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new Models.GoodsReceiptItem
|
||||||
|
{
|
||||||
|
FK_ItemId = item.Id,
|
||||||
|
FreeQuantity = line.FreeQuantity,
|
||||||
|
AcceptedQuantity = line.AcceptedQuantity,
|
||||||
|
RejectedQuantity = line.RejectedQuantity
|
||||||
|
};
|
||||||
|
ApplyPricing(entity, line, 0m, header, tax, item.Sku);
|
||||||
|
goodsReceipt.Items.Add(entity);
|
||||||
|
grLines.Add((entity, line, item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO
|
||||||
|
/// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<GrnResponse> PostReceiptAsync(
|
||||||
|
Models.GoodsReceipt goodsReceipt,
|
||||||
|
List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines,
|
||||||
|
Guid warehouseId,
|
||||||
|
Guid receivedByUserId,
|
||||||
|
Models.PurchaseOrder? po,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await repo.AddAsync(goodsReceipt);
|
await repo.AddAsync(goodsReceipt);
|
||||||
|
|
||||||
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
foreach (var (entity, line, item) in grLines.Where(x => StockQty(x.Line) > 0))
|
||||||
{
|
{
|
||||||
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
|
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 balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
// Warranty is a customer-facing promise: the clock starts when the serial is sold,
|
||||||
|
// not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt
|
||||||
|
// stays null until the sale/delivery flow sets it.
|
||||||
|
if (item.HasWarranty && item.AllowCustomWarrantyPeriod)
|
||||||
|
serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays;
|
||||||
|
|
||||||
|
await stockRepo.AddItemSerialNumberAsync(serial);
|
||||||
|
entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial });
|
||||||
|
|
||||||
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = item.Id,
|
||||||
|
LocationId = warehouseId,
|
||||||
|
LocationType = LocationType.Warehouse,
|
||||||
|
FK_ItemSerialNumberId = serial.Id,
|
||||||
|
Quantity = 1
|
||||||
|
});
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = item.Id,
|
||||||
|
FromLocationId = StockService.ExternalLocationId,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
|
ToLocationId = warehouseId,
|
||||||
|
ToLocationType = LocationType.Warehouse,
|
||||||
|
FK_ItemSerialNumberId = serial.Id,
|
||||||
|
Quantity = 1,
|
||||||
|
Type = MovementType.PurchaseReceipt,
|
||||||
|
ReferenceId = goodsReceipt.Id,
|
||||||
|
CreatedBy = receivedByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (item.TracksBatches || item.TracksExpiryDate)
|
||||||
|
{
|
||||||
|
foreach (var b in line.Batches!)
|
||||||
|
{
|
||||||
|
var batch = !string.IsNullOrWhiteSpace(b.BatchNumber)
|
||||||
|
? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
batch = new ItemBatch
|
||||||
|
{
|
||||||
|
FK_ItemId = item.Id,
|
||||||
|
BatchNumber = b.BatchNumber,
|
||||||
|
ExpiryDate = b.ExpiryDate
|
||||||
|
};
|
||||||
|
await stockRepo.AddItemBatchAsync(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity });
|
||||||
|
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null);
|
||||||
if (balance is null)
|
if (balance is null)
|
||||||
{
|
{
|
||||||
await stockRepo.AddBalanceAsync(new StockBalance
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
{
|
{
|
||||||
FK_ItemId = poItem.FK_ItemId,
|
FK_ItemId = item.Id,
|
||||||
LocationId = request.WarehouseId,
|
LocationId = warehouseId,
|
||||||
LocationType = LocationType.Warehouse,
|
LocationType = LocationType.Warehouse,
|
||||||
Quantity = line.AcceptedQuantity
|
FK_ItemBatchId = batch.Id,
|
||||||
|
Quantity = b.Quantity
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
balance.Quantity += line.AcceptedQuantity;
|
balance.Quantity += b.Quantity;
|
||||||
balance.UpdatedAt = DateTime.Now;
|
balance.UpdatedAt = DateTime.Now;
|
||||||
stockRepo.UpdateBalance(balance);
|
stockRepo.UpdateBalance(balance);
|
||||||
}
|
}
|
||||||
|
|
||||||
await stockRepo.AddMovementAsync(new StockMovement
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = poItem.FK_ItemId,
|
FK_ItemId = item.Id,
|
||||||
FromLocationId = StockService.ExternalLocationId,
|
FromLocationId = StockService.ExternalLocationId,
|
||||||
FromLocationType = LocationType.External,
|
FromLocationType = LocationType.External,
|
||||||
ToLocationId = request.WarehouseId,
|
ToLocationId = warehouseId,
|
||||||
ToLocationType = LocationType.Warehouse,
|
ToLocationType = LocationType.Warehouse,
|
||||||
Quantity = line.AcceptedQuantity,
|
FK_ItemBatchId = batch.Id,
|
||||||
|
Quantity = b.Quantity,
|
||||||
Type = MovementType.PurchaseReceipt,
|
Type = MovementType.PurchaseReceipt,
|
||||||
ReferenceId = goodsReceipt.Id,
|
ReferenceId = goodsReceipt.Id,
|
||||||
CreatedBy = receivedByUserId
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
poRepo.Update(po);
|
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);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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 }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,14 @@ public record CreateItemRequest(
|
|||||||
Guid? BrandId,
|
Guid? BrandId,
|
||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
List<UomConversionRequest>? Conversions);
|
List<UomConversionRequest>? Conversions,
|
||||||
|
decimal? ReorderLevel = null,
|
||||||
|
bool TracksSerialNumbers = false,
|
||||||
|
bool TracksBatches = false,
|
||||||
|
bool TracksExpiryDate = false,
|
||||||
|
bool HasWarranty = false,
|
||||||
|
int? WarrantyPeriodDays = null,
|
||||||
|
bool AllowCustomWarrantyPeriod = false);
|
||||||
|
|
||||||
public record UpdateItemRequest(
|
public record UpdateItemRequest(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -21,7 +28,14 @@ public record UpdateItemRequest(
|
|||||||
Guid? BrandId,
|
Guid? BrandId,
|
||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
bool IsActive = true);
|
bool IsActive = true,
|
||||||
|
decimal? ReorderLevel = null,
|
||||||
|
bool TracksSerialNumbers = false,
|
||||||
|
bool TracksBatches = false,
|
||||||
|
bool TracksExpiryDate = false,
|
||||||
|
bool HasWarranty = false,
|
||||||
|
int? WarrantyPeriodDays = null,
|
||||||
|
bool AllowCustomWarrantyPeriod = false);
|
||||||
|
|
||||||
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
||||||
|
|
||||||
@@ -40,6 +54,13 @@ public record ItemSummaryResponse(
|
|||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
string BaseUomName,
|
string BaseUomName,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
|
decimal? ReorderLevel,
|
||||||
|
bool TracksSerialNumbers,
|
||||||
|
bool TracksBatches,
|
||||||
|
bool TracksExpiryDate,
|
||||||
|
bool HasWarranty,
|
||||||
|
int? WarrantyPeriodDays,
|
||||||
|
bool AllowCustomWarrantyPeriod,
|
||||||
Models.SyncSource SyncSource,
|
Models.SyncSource SyncSource,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
@@ -58,6 +79,13 @@ public record ItemDetailResponse(
|
|||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
string BaseUomName,
|
string BaseUomName,
|
||||||
decimal? SalePrice,
|
decimal? SalePrice,
|
||||||
|
decimal? ReorderLevel,
|
||||||
|
bool TracksSerialNumbers,
|
||||||
|
bool TracksBatches,
|
||||||
|
bool TracksExpiryDate,
|
||||||
|
bool HasWarranty,
|
||||||
|
int? WarrantyPeriodDays,
|
||||||
|
bool AllowCustomWarrantyPeriod,
|
||||||
Models.SyncSource SyncSource,
|
Models.SyncSource SyncSource,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Distribution.Infra.UoW;
|
|||||||
|
|
||||||
namespace Distribution.Features.Item;
|
namespace Distribution.Features.Item;
|
||||||
|
|
||||||
public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig)
|
||||||
{
|
{
|
||||||
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
|
private static UomConversionResponse MapConversion(Models.UomConversion c) =>
|
||||||
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
|
new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor);
|
||||||
@@ -13,7 +13,10 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
i.FK_SubCategoryId, i.SubCategory?.Name,
|
i.FK_SubCategoryId, i.SubCategory?.Name,
|
||||||
i.FK_BrandId, i.Brand?.Name,
|
i.FK_BrandId, i.Brand?.Name,
|
||||||
i.FK_BaseUomId, i.BaseUom.Name,
|
i.FK_BaseUomId, i.BaseUom.Name,
|
||||||
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt);
|
i.SalePrice, i.ReorderLevel,
|
||||||
|
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
|
||||||
|
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
|
||||||
|
i.SyncSource, i.IsActive, i.CreatedAt);
|
||||||
|
|
||||||
private static ItemDetailResponse MapDetail(Models.Item i) =>
|
private static ItemDetailResponse MapDetail(Models.Item i) =>
|
||||||
new(i.Id, i.Sku, i.Name, i.Description,
|
new(i.Id, i.Sku, i.Name, i.Description,
|
||||||
@@ -21,9 +24,44 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
i.FK_SubCategoryId, i.SubCategory?.Name,
|
i.FK_SubCategoryId, i.SubCategory?.Name,
|
||||||
i.FK_BrandId, i.Brand?.Name,
|
i.FK_BrandId, i.Brand?.Name,
|
||||||
i.FK_BaseUomId, i.BaseUom.Name,
|
i.FK_BaseUomId, i.BaseUom.Name,
|
||||||
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt,
|
i.SalePrice, i.ReorderLevel,
|
||||||
|
i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate,
|
||||||
|
i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod,
|
||||||
|
i.SyncSource, i.IsActive, i.CreatedAt,
|
||||||
i.Conversions.Select(MapConversion).ToList());
|
i.Conversions.Select(MapConversion).ToList());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejects per-item tracking flags that are off system-wide, and warranty-period values that
|
||||||
|
/// don't make sense given HasWarranty/AllowCustomWarrantyPeriod.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches,
|
||||||
|
bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod)
|
||||||
|
{
|
||||||
|
if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync())
|
||||||
|
throw new InvalidOperationException("Serial number tracking is disabled system-wide.");
|
||||||
|
|
||||||
|
if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync())
|
||||||
|
throw new InvalidOperationException("Batch tracking is disabled system-wide.");
|
||||||
|
|
||||||
|
if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync())
|
||||||
|
throw new InvalidOperationException("Expiry date tracking is disabled system-wide.");
|
||||||
|
|
||||||
|
if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync())
|
||||||
|
throw new InvalidOperationException("Warranty tracking is disabled system-wide.");
|
||||||
|
|
||||||
|
if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync())
|
||||||
|
throw new InvalidOperationException("Custom warranty period override is disabled system-wide.");
|
||||||
|
|
||||||
|
if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod))
|
||||||
|
throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true.");
|
||||||
|
|
||||||
|
if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod)
|
||||||
|
throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true.");
|
||||||
|
|
||||||
|
if (warrantyPeriodDays is < 0)
|
||||||
|
throw new InvalidOperationException("WarrantyPeriodDays cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
|
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
|
||||||
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
|
(await repo.GetAllAsync(categoryId, subCategoryId, brandId))
|
||||||
.Select(MapSummary)
|
.Select(MapSummary)
|
||||||
@@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
if (await repo.SkuExistsAsync(request.Sku))
|
if (await repo.SkuExistsAsync(request.Sku))
|
||||||
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
|
throw new InvalidOperationException($"SKU '{request.Sku}' already exists.");
|
||||||
|
|
||||||
|
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
|
||||||
|
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
|
||||||
|
|
||||||
var item = new Models.Item
|
var item = new Models.Item
|
||||||
{
|
{
|
||||||
Sku = request.Sku,
|
Sku = request.Sku,
|
||||||
@@ -51,6 +92,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
FK_BrandId = request.BrandId,
|
FK_BrandId = request.BrandId,
|
||||||
FK_BaseUomId = request.BaseUomId,
|
FK_BaseUomId = request.BaseUomId,
|
||||||
SalePrice = request.SalePrice,
|
SalePrice = request.SalePrice,
|
||||||
|
ReorderLevel = request.ReorderLevel,
|
||||||
|
TracksSerialNumbers = request.TracksSerialNumbers,
|
||||||
|
TracksBatches = request.TracksBatches,
|
||||||
|
TracksExpiryDate = request.TracksExpiryDate,
|
||||||
|
HasWarranty = request.HasWarranty,
|
||||||
|
WarrantyPeriodDays = request.WarrantyPeriodDays,
|
||||||
|
AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod,
|
||||||
SyncSource = Models.SyncSource.Manual
|
SyncSource = Models.SyncSource.Manual
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
if (item is null) return null;
|
if (item is null) return null;
|
||||||
if (item.SyncSource == Models.SyncSource.Erp) return null;
|
if (item.SyncSource == Models.SyncSource.Erp) return null;
|
||||||
|
|
||||||
|
await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches,
|
||||||
|
request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod);
|
||||||
|
|
||||||
item.Name = request.Name;
|
item.Name = request.Name;
|
||||||
item.Description = request.Description;
|
item.Description = request.Description;
|
||||||
item.FK_CategoryId = request.CategoryId;
|
item.FK_CategoryId = request.CategoryId;
|
||||||
@@ -84,6 +135,13 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
item.FK_BrandId = request.BrandId;
|
item.FK_BrandId = request.BrandId;
|
||||||
item.FK_BaseUomId = request.BaseUomId;
|
item.FK_BaseUomId = request.BaseUomId;
|
||||||
item.SalePrice = request.SalePrice;
|
item.SalePrice = request.SalePrice;
|
||||||
|
item.ReorderLevel = request.ReorderLevel;
|
||||||
|
item.TracksSerialNumbers = request.TracksSerialNumbers;
|
||||||
|
item.TracksBatches = request.TracksBatches;
|
||||||
|
item.TracksExpiryDate = request.TracksExpiryDate;
|
||||||
|
item.HasWarranty = request.HasWarranty;
|
||||||
|
item.WarrantyPeriodDays = request.WarrantyPeriodDays;
|
||||||
|
item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod;
|
||||||
item.IsActive = request.IsActive;
|
item.IsActive = request.IsActive;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ public interface ILoadingSheetRepository
|
|||||||
{
|
{
|
||||||
Task<LoadingSheet?> GetByIdAsync(Guid id);
|
Task<LoadingSheet?> GetByIdAsync(Guid id);
|
||||||
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
|
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
|
||||||
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
|
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize);
|
||||||
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
|
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId);
|
||||||
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
|
|
||||||
Task AddAsync(LoadingSheet sheet);
|
Task AddAsync(LoadingSheet sheet);
|
||||||
void Update(LoadingSheet sheet);
|
void Update(LoadingSheet sheet);
|
||||||
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
||||||
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
|
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
|
||||||
|
Task<List<Guid>> GetShopPOIdsByItemIdsAsync(IEnumerable<Guid> shopPOItemIds);
|
||||||
|
Task ReplaceOrdersAsync(Guid loadingSheetId, List<Guid> shopPOIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,47 @@
|
|||||||
using Distribution.Models;
|
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.LoadingEvent;
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/loading-sheets")]
|
[Route("api/loading-sheets")]
|
||||||
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
public class LoadingSheetController(
|
||||||
|
LoadingSheetService service,
|
||||||
|
IScopeAccessService scopeAccess,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
//[Authorize("loading-sheet.view")]
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? rideId,
|
[FromQuery] Guid? rideId,
|
||||||
[FromQuery] LoadingSheetStatus? status,
|
[FromQuery] LoadingSheetStatus? status,
|
||||||
|
[FromQuery] Guid? routeId,
|
||||||
|
[FromQuery] Guid? regionId,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20)
|
[FromQuery] int pageSize = 20)
|
||||||
{
|
{
|
||||||
var result = await service.GetAllAsync(rideId, status, page, pageSize);
|
if (routeId.HasValue)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId;
|
||||||
|
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, routeId.Value))
|
||||||
|
return StatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionId.HasValue)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId;
|
||||||
|
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
|
||||||
|
return StatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize("loading-sheet.view")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -27,11 +49,16 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[Authorize("loading-sheet.manage")]
|
||||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
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
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, userId.Value, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -39,6 +66,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/items")]
|
[HttpPut("{id:guid}/items")]
|
||||||
|
[Authorize("loading-sheet.manage")]
|
||||||
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -50,90 +78,17 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-dispatch")]
|
[HttpPost("{id:guid}/confirm")]
|
||||||
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
[Authorize("loading-sheet.manage")]
|
||||||
|
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.ConfirmedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
var result = await service.ConfirmAsync(id, userId.Value, ct);
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
|||||||
@@ -5,37 +5,32 @@ namespace Distribution.Features.LoadingEvent;
|
|||||||
public record LoadingSheetResponse(
|
public record LoadingSheetResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid FK_RideId,
|
Guid FK_RideId,
|
||||||
Guid FK_SourceWarehouseId,
|
|
||||||
string WarehouseName,
|
|
||||||
LoadingSheetStatus Status,
|
LoadingSheetStatus Status,
|
||||||
DateTime? DispatchConfirmedAt,
|
Guid PopulatedBy,
|
||||||
Guid? DispatchConfirmedBy,
|
DateTime PopulatedAt,
|
||||||
DateTime? ReceiptConfirmedAt,
|
DateTime? ConfirmedAt,
|
||||||
Guid? ReceiptConfirmedBy,
|
Guid? ConfirmedBy,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<LoadingSheetItemResponse> Items);
|
List<LoadingSheetItemResponse> Items,
|
||||||
|
List<LoadingSheetOrderResponse> Orders);
|
||||||
|
|
||||||
public record LoadingSheetItemResponse(
|
public record LoadingSheetItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid FK_ItemId,
|
Guid FK_ItemId,
|
||||||
string ItemName,
|
string ItemName,
|
||||||
|
Guid FK_WarehouseId,
|
||||||
|
string WarehouseName,
|
||||||
decimal QuantityDispatched,
|
decimal QuantityDispatched,
|
||||||
decimal? QuantityReceived,
|
Guid? FK_ShopPOItemId,
|
||||||
decimal? QuantityResolved);
|
Guid? ShopPOId = null,
|
||||||
|
Guid? StoreId = null,
|
||||||
|
string? StoreName = null);
|
||||||
|
|
||||||
public record CreateLoadingSheetRequest(Guid FK_RideId);
|
public record LoadingSheetOrderResponse(Guid ShopPOId);
|
||||||
|
|
||||||
|
public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null);
|
||||||
|
|
||||||
public record SetItemsRequest(List<SetItemInput> Items);
|
public record SetItemsRequest(List<SetItemInput> Items);
|
||||||
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
|
public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null);
|
||||||
|
|
||||||
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
|
public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null);
|
||||||
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,46 +8,39 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
|||||||
{
|
{
|
||||||
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
|
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
|
||||||
await context.LoadingSheets
|
await context.LoadingSheets
|
||||||
.Include(s => s.SourceWarehouse)
|
|
||||||
.Include(s => s.Items).ThenInclude(i => i.Item)
|
.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);
|
.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
|
|
||||||
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
|
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
|
||||||
await context.LoadingSheets
|
await context.LoadingSheets
|
||||||
.FirstOrDefaultAsync(s =>
|
.FirstOrDefaultAsync(s => s.FK_RideId == rideId && s.Status == LoadingSheetStatus.Planned);
|
||||||
s.FK_RideId == rideId &&
|
|
||||||
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
|
|
||||||
s.Status != LoadingSheetStatus.Resolved);
|
|
||||||
|
|
||||||
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
|
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) =>
|
||||||
await context.LoadingSheets
|
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)
|
||||||
.Where(s =>
|
.Where(s =>
|
||||||
(rideId == null || s.FK_RideId == rideId) &&
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
(status == null || s.Status == status))
|
(status == null || s.Status == status) &&
|
||||||
|
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
|
||||||
|
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)))
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
|
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId) =>
|
||||||
await context.LoadingSheets
|
await context.LoadingSheets
|
||||||
.CountAsync(s =>
|
.CountAsync(s =>
|
||||||
(rideId == null || s.FK_RideId == rideId) &&
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
(status == null || s.Status == status));
|
(status == null || s.Status == status) &&
|
||||||
|
(routeId == null || s.Orders.Any(o => o.ShopPO.Store.FK_RouteId == routeId)) &&
|
||||||
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
(regionId == null || s.Orders.Any(o => o.ShopPO.Store.Route.FK_RegionId == regionId)));
|
||||||
{
|
|
||||||
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) =>
|
public async Task AddAsync(LoadingSheet sheet) =>
|
||||||
await context.LoadingSheets.AddAsync(sheet);
|
await context.LoadingSheets.AddAsync(sheet);
|
||||||
@@ -60,4 +53,25 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
|||||||
|
|
||||||
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
|
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
|
||||||
await context.LoadingSheetItems.AddRangeAsync(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
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user