Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eccc80eaad | |||
| fb62448e2c | |||
| 2000ba67f4 | |||
| b8836a1e87 | |||
| 6c6d30f4f9 | |||
| 9bb4c52efb | |||
| 617c0713a1 | |||
| eb0303fa3f | |||
| f9e4bb3a97 | |||
| 7dbeaa722a | |||
| 58d67495d2 | |||
| e53db290c8 | |||
| 2a5562a355 | |||
| 5a6eca1f07 | |||
| 6ba8d2fd19 | |||
| e267f94d46 | |||
| 81d92e16bb | |||
| ddbe56c5df | |||
| 5f303321d0 | |||
| 19ff0b5578 | |||
| bbf9e5b60e | |||
| 508149dab8 | |||
| eea87c0ee2 | |||
| 3bcb42ea9b | |||
| 2053b61965 | |||
| 7e362be304 | |||
| f5872cedaf | |||
| cff4f4bf30 | |||
| aee3e840a0 | |||
| ef0486829c | |||
| 7e9110b707 | |||
| 68aea8105d | |||
| 04004437bb | |||
| 712a163777 | |||
| dfd51b8273 | |||
| 51aaed8fd1 | |||
| 62bfe27a84 | |||
| 0523dabc5a | |||
| 7d4d07d692 | |||
| 1bdda3a8ab | |||
| 1645eae694 | |||
| d0bf5581c5 | |||
| 8864f8ad6d | |||
| c962ed9425 | |||
| ad74074771 | |||
| 899d04e4eb | |||
| 5e48873422 | |||
| ab2226e394 | |||
| d7546d24d1 | |||
| be66e65e59 | |||
| 63115f18b4 | |||
| 1357f7e3f8 | |||
| 831f33df34 | |||
| 8f5ad872e5 | |||
| 38b893b9f6 | |||
| 5f82090272 | |||
| 5890c03ffe | |||
| 6a8204b584 | |||
| 32f9b079b3 | |||
| b71cfae6c9 | |||
| 35fe512bd1 | |||
| de3dcb8c52 | |||
| 692fc4f290 | |||
| 85a16396aa | |||
| 12ff9e8fef | |||
| 7cf272e311 | |||
| 82a32de966 | |||
| 68b34b3fc3 | |||
| 83167b5eb3 | |||
| e67675a12a | |||
| 2ecda623ac | |||
| da9caa855a | |||
| 5c4e1b590a | |||
| 75b5035354 | |||
| c0432a448f | |||
| bfbfe352ee | |||
| 320da0e1c9 | |||
| f0d7f939ef | |||
| 8b84c8aeb3 | |||
| 9febae6d63 | |||
| df27d58089 | |||
| 4e12e9f8f1 | |||
| 39eab2e3a2 | |||
| 889ca0795d | |||
| a7801a2bc2 | |||
| f4db83654c | |||
| d414ec8e38 | |||
| dc7e7a70fc | |||
| 20b82a2734 | |||
| 83d3272784 | |||
| d6cd9ccd08 | |||
| 1bee5f91c6 | |||
| 08ad17f15b | |||
| 97a1db1343 | |||
| 3268a50c3e | |||
| f1c036d1d8 | |||
| 92fef1dfa8 | |||
| bb33928661 | |||
| b279a121ad | |||
| 916a2b8f0a | |||
| 3caacf44d0 | |||
| fe9ccb4de7 | |||
| f244a04dc0 | |||
| aa543d0d0d | |||
| 87146e4662 | |||
| cefefcbc49 | |||
| fcca03d0b2 | |||
| 6d8b6b3e59 | |||
| 3837facb02 | |||
| a833586e83 | |||
| 3bc33ce08f | |||
| 870b412a06 | |||
| 095d35e1b8 | |||
| d7b03941fb | |||
| 81a49e1a98 | |||
| 2995ce7012 | |||
| db0850f1f0 | |||
| 702dbeb39f | |||
| c8d23afe59 | |||
| ade41b4a60 | |||
| ac1b2fbc4b | |||
| f381e7a634 | |||
| 3680fc9035 | |||
| 6a0322570b | |||
| dc4b1934e1 | |||
| aabace1bba | |||
| f7101800cd | |||
| f6211a8edd | |||
| 83bf715716 |
+1
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Region;
|
using Distribution.Features.Region;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -10,11 +11,14 @@ public class RegionServiceTests
|
|||||||
{
|
{
|
||||||
private readonly Mock<IRegionRepository> _repo = new();
|
private readonly Mock<IRegionRepository> _repo = new();
|
||||||
private readonly Mock<IUnitOfWork> _uow = new();
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||||
|
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||||
private readonly RegionService _sut;
|
private readonly RegionService _sut;
|
||||||
|
|
||||||
public RegionServiceTests()
|
public RegionServiceTests()
|
||||||
{
|
{
|
||||||
_sut = new RegionService(_repo.Object, _uow.Object);
|
_currentUser.Setup(u => u.RoleLevel).Returns(60);
|
||||||
|
_sut = new RegionService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Region ActiveRegion(string name = "North") =>
|
private static Region ActiveRegion(string name = "North") =>
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using Distribution.Features.Region;
|
||||||
|
using Distribution.Features.Route;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Distribution.Tests.Services;
|
||||||
|
|
||||||
|
public class RouteServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IRouteRepository> _repo = new();
|
||||||
|
private readonly Mock<IRegionRepository> _regionRepo = new();
|
||||||
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||||
|
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||||
|
private readonly RouteService _sut;
|
||||||
|
|
||||||
|
private static readonly Guid RepUserId = Guid.NewGuid();
|
||||||
|
|
||||||
|
public RouteServiceTests()
|
||||||
|
{
|
||||||
|
_currentUser.Setup(u => u.RoleLevel).Returns(20);
|
||||||
|
_currentUser.Setup(u => u.UserId).Returns(RepUserId);
|
||||||
|
_sut = new RouteService(_repo.Object, _regionRepo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Models.Route ActiveRoute(Guid? ownerId = null) =>
|
||||||
|
new() { Id = Guid.NewGuid(), RouteName = "R1", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = true, FK_OwnerUserId = ownerId };
|
||||||
|
|
||||||
|
private static Models.Route InactiveRoute() =>
|
||||||
|
new() { Id = Guid.NewGuid(), RouteName = "Old", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = false };
|
||||||
|
|
||||||
|
private static Store ActiveStore(Guid routeId, decimal lat = 10m, decimal lon = 20m) =>
|
||||||
|
new() { Id = Guid.NewGuid(), FK_RouteId = routeId, IsActive = true, Latitude = lat, Longitude = lon };
|
||||||
|
|
||||||
|
// AssignStoresToRouteAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteNotFound()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
|
||||||
|
|
||||||
|
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteInactive()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
|
||||||
|
|
||||||
|
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerLacksRouteScope()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerBelowRoleFloor()
|
||||||
|
{
|
||||||
|
_currentUser.Setup(u => u.RoleLevel).Returns(10);
|
||||||
|
var route = ActiveRoute();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
|
||||||
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenAnyStoreIdMissing()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
var storeIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
|
||||||
|
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync([ActiveStore(route.Id)]); // only one of two found
|
||||||
|
|
||||||
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_UpdatesEachStore_AndCommits()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var otherRouteId = Guid.NewGuid();
|
||||||
|
var stores = new List<Store> { ActiveStore(otherRouteId), ActiveStore(otherRouteId) };
|
||||||
|
var storeIds = stores.Select(s => s.Id).ToList();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
|
||||||
|
|
||||||
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.All(stores, s => Assert.Equal(route.Id, s.FK_RouteId));
|
||||||
|
_repo.Verify(r => r.UpdateStore(It.IsAny<Store>()), Times.Exactly(2));
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignStoresToRouteAsync_Rollbacks_WhenUpdateThrows()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var stores = new List<Store> { ActiveStore(route.Id) };
|
||||||
|
var storeIds = stores.Select(s => s.Id).ToList();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
|
||||||
|
_repo.Setup(r => r.UpdateStore(It.IsAny<Store>())).Throws(new Exception("db error"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<Exception>(() => _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds)));
|
||||||
|
|
||||||
|
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignOwnerAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignOwnerAsync_ReturnsNull_WhenRouteNotFound()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
|
||||||
|
|
||||||
|
var result = await _sut.AssignOwnerAsync(Guid.NewGuid(), new AssignRouteOwnerRequest(Guid.NewGuid()));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignOwnerAsync_ReturnsNull_WhenTargetUserNotActive()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetActiveUserAsync(It.IsAny<Guid>())).ReturnsAsync((User?)null);
|
||||||
|
|
||||||
|
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(Guid.NewGuid()));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignOwnerAsync_SetsOwner_AndCommits()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var salesRep = new User { Id = Guid.NewGuid(), IsActive = true };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetActiveUserAsync(salesRep.Id)).ReturnsAsync(salesRep);
|
||||||
|
|
||||||
|
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(salesRep.Id));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(salesRep.Id, result.OwnerUserId);
|
||||||
|
Assert.Equal(salesRep.Id, route.FK_OwnerUserId);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignOwnerAsync_AllowsReassignment_ToDifferentSalesRep()
|
||||||
|
{
|
||||||
|
var firstOwner = Guid.NewGuid();
|
||||||
|
var route = ActiveRoute(firstOwner);
|
||||||
|
var newRep = new User { Id = Guid.NewGuid(), IsActive = true };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetActiveUserAsync(newRep.Id)).ReturnsAsync(newRep);
|
||||||
|
|
||||||
|
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(newRep.Id));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(newRep.Id, result.OwnerUserId);
|
||||||
|
Assert.NotEqual(firstOwner, result.OwnerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordCheckInAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerNotAuthenticated()
|
||||||
|
{
|
||||||
|
_currentUser.Setup(u => u.UserId).Returns((Guid?)null);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_ReturnsNull_WhenRouteNotFoundOrInactive()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerLacksRouteScope()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_ReturnsNull_WhenStoreNotOnRoute()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var store = ActiveStore(Guid.NewGuid()); // belongs to a different route
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 1, 2, null));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_NotFlagged_WhenGpsMatchesStoreLocation()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m);
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 12.9716m, 77.5946m, null));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.False(result.IsDeviationFlagged);
|
||||||
|
_repo.Verify(r => r.AddVisitLogAsync(It.IsAny<RouteVisitLog>()), Times.Once);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_Flagged_WhenGpsFarFromStoreLocation()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m); // Bengaluru
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||||
|
|
||||||
|
// Mumbai coordinates - hundreds of km away
|
||||||
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 19.0760m, 72.8777m, null));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result.IsDeviationFlagged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckInAsync_UsesCurrentUserId_AsVisitingRep()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var store = ActiveStore(route.Id);
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, store.Latitude, store.Longitude, null));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(RepUserId, result.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordCheckOutAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckOutAsync_ReturnsNull_WhenNoOpenVisit()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, It.IsAny<Guid>())).ReturnsAsync((RouteVisitLog?)null);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(Guid.NewGuid(), null));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckOutAsync_SetsCheckOutAt_AndCommits()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now.AddHours(-1) };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
|
||||||
|
|
||||||
|
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.CheckOutAt);
|
||||||
|
Assert.NotNull(log.CheckOutAt);
|
||||||
|
_repo.Verify(r => r.UpdateVisitLog(log), Times.Once);
|
||||||
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordCheckOutAsync_Rollbacks_WhenUpdateThrows()
|
||||||
|
{
|
||||||
|
var route = ActiveRoute();
|
||||||
|
var storeId = Guid.NewGuid();
|
||||||
|
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
||||||
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
||||||
|
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
|
||||||
|
_repo.Setup(r => r.UpdateVisitLog(It.IsAny<RouteVisitLog>())).Throws(new Exception("db error"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<Exception>(() => _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null)));
|
||||||
|
|
||||||
|
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.Route;
|
||||||
|
using Distribution.Features.Scheme;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.Store;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Distribution.Tests.Services;
|
||||||
|
|
||||||
|
public class StoreServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IStoreRepository> _repo = new();
|
||||||
|
private readonly Mock<IUnitOfWork> _uow = new();
|
||||||
|
private readonly Mock<ICurrentUserService> _currentUser = new();
|
||||||
|
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
||||||
|
private readonly Mock<IRouteRepository> _routeRepo = new();
|
||||||
|
private readonly StoreService _sut;
|
||||||
|
|
||||||
|
public StoreServiceTests()
|
||||||
|
{
|
||||||
|
_currentUser.Setup(u => u.RoleLevel).Returns(50); // global scope, skips per-store scope lookups
|
||||||
|
var schemeService = new SchemeService(
|
||||||
|
Mock.Of<ISchemeRepository>(),
|
||||||
|
Mock.Of<IOrderRepository>(),
|
||||||
|
Mock.Of<IItemRepository>(),
|
||||||
|
_repo.Object,
|
||||||
|
Mock.Of<IStockRepository>(),
|
||||||
|
_uow.Object);
|
||||||
|
_sut = new StoreService(_repo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object, schemeService, _routeRepo.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Store ActiveStore() =>
|
||||||
|
new() { Id = Guid.NewGuid(), IsActive = true };
|
||||||
|
|
||||||
|
// GetVisitHistoryAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVisitHistoryAsync_ReturnsNull_WhenStoreNotFound()
|
||||||
|
{
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Store?)null);
|
||||||
|
|
||||||
|
var result = await _sut.GetVisitHistoryAsync(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVisitHistoryAsync_ReturnsEmpty_WhenNoVisitsLogged()
|
||||||
|
{
|
||||||
|
var store = ActiveStore();
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(store.Id)).ReturnsAsync(store);
|
||||||
|
_routeRepo.Setup(r => r.GetVisitsByStoreIdAsync(store.Id)).ReturnsAsync([]);
|
||||||
|
|
||||||
|
var result = await _sut.GetVisitHistoryAsync(store.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVisitHistoryAsync_MapsVisits_MostRecentFirst()
|
||||||
|
{
|
||||||
|
var store = ActiveStore();
|
||||||
|
var repId = Guid.NewGuid();
|
||||||
|
var older = new RouteVisitLog { Id = Guid.NewGuid(), FK_StoreId = store.Id, FK_UserId = repId, CheckInAt = DateTime.Now.AddDays(-2), CheckOutAt = DateTime.Now.AddDays(-2).AddHours(1) };
|
||||||
|
var newer = new RouteVisitLog { Id = Guid.NewGuid(), FK_StoreId = store.Id, FK_UserId = repId, CheckInAt = DateTime.Now.AddDays(-1), CheckOutAt = null };
|
||||||
|
_repo.Setup(r => r.GetByIdAsync(store.Id)).ReturnsAsync(store);
|
||||||
|
_routeRepo.Setup(r => r.GetVisitsByStoreIdAsync(store.Id)).ReturnsAsync([older, newer]);
|
||||||
|
|
||||||
|
var result = await _sut.GetVisitHistoryAsync(store.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Equal(newer.Id, result[0].VisitId);
|
||||||
|
Assert.Equal("In Progress", result[0].Outcome);
|
||||||
|
Assert.Equal(older.Id, result[1].VisitId);
|
||||||
|
Assert.Equal("Completed", result[1].Outcome);
|
||||||
|
Assert.Equal(repId, result[0].RepUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Distribution.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aa7ea37ab61a149a38d83283c5dabd76bf5a39b3")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85a16396aad31f1e82cf28f330a97dee2eafa51d")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Distribution.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Distribution.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
5c052f1f25bc37b6a51a3abe0e2b79d99d21422d5ab89151faea450c0ddb9cc0
|
100cae0f5643c7476420ef14e72b5b17dcb877fe4318c0b6cb2cff0baa955aa5
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ build_property.EnforceExtendedAnalyzerRules =
|
|||||||
build_property.EntryPointFilePath =
|
build_property.EntryPointFilePath =
|
||||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
build_property.RootNamespace = Distribution.Tests
|
build_property.RootNamespace = Distribution.Tests
|
||||||
build_property.ProjectDir = C:\Users\lenovo\Desktop\NEWGITEA\ERP_Distribution\Distribution.Tests\
|
build_property.ProjectDir = D:\HexDrive Projects\Distribution\ERP_Distribution\Distribution.Tests\
|
||||||
build_property.EnableComHosting =
|
build_property.EnableComHosting =
|
||||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
build_property.EffectiveAnalysisLevelStyle = 9.0
|
build_property.EffectiveAnalysisLevelStyle = 9.0
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"format": 1,
|
"format": 1,
|
||||||
"restore": {
|
"restore": {
|
||||||
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {}
|
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {
|
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
||||||
"projectName": "Distribution.Tests",
|
"projectName": "Distribution.Tests",
|
||||||
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
||||||
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
|
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
|
||||||
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\obj\\",
|
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\obj\\",
|
||||||
"projectStyle": "PackageReference",
|
"projectStyle": "PackageReference",
|
||||||
"fallbackFolders": [
|
"fallbackFolders": [
|
||||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||||
],
|
],
|
||||||
"configFilePaths": [
|
"configFilePaths": [
|
||||||
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||||
],
|
],
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
],
|
],
|
||||||
"sources": {
|
"sources": {
|
||||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||||
|
"C:\\Program Files\\dotnet\\library-packs": {},
|
||||||
"https://api.nuget.org/v3/index.json": {}
|
"https://api.nuget.org/v3/index.json": {}
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
@@ -33,8 +34,8 @@
|
|||||||
"framework": "net9.0",
|
"framework": "net9.0",
|
||||||
"targetAlias": "net9.0",
|
"targetAlias": "net9.0",
|
||||||
"projectReferences": {
|
"projectReferences": {
|
||||||
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
|
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
|
||||||
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj"
|
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
"auditLevel": "low",
|
"auditLevel": "low",
|
||||||
"auditMode": "direct"
|
"auditMode": "direct"
|
||||||
},
|
},
|
||||||
"SdkAnalysisLevel": "10.0.300"
|
"SdkAnalysisLevel": "10.0.400"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
"net9.0": {
|
"net9.0": {
|
||||||
@@ -97,24 +98,24 @@
|
|||||||
"privateAssets": "all"
|
"privateAssets": "all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
|
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
|
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj",
|
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj",
|
||||||
"projectName": "Distribution",
|
"projectName": "Distribution",
|
||||||
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj",
|
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj",
|
||||||
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
|
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
|
||||||
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\obj\\",
|
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\obj\\",
|
||||||
"projectStyle": "PackageReference",
|
"projectStyle": "PackageReference",
|
||||||
"fallbackFolders": [
|
"fallbackFolders": [
|
||||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||||
],
|
],
|
||||||
"configFilePaths": [
|
"configFilePaths": [
|
||||||
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||||
],
|
],
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
],
|
],
|
||||||
"sources": {
|
"sources": {
|
||||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||||
|
"C:\\Program Files\\dotnet\\library-packs": {},
|
||||||
"https://api.nuget.org/v3/index.json": {}
|
"https://api.nuget.org/v3/index.json": {}
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
@@ -142,7 +144,7 @@
|
|||||||
"auditLevel": "low",
|
"auditLevel": "low",
|
||||||
"auditMode": "direct"
|
"auditMode": "direct"
|
||||||
},
|
},
|
||||||
"SdkAnalysisLevel": "10.0.300"
|
"SdkAnalysisLevel": "10.0.400"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
"net9.0": {
|
"net9.0": {
|
||||||
@@ -191,7 +193,7 @@
|
|||||||
"privateAssets": "all"
|
"privateAssets": "all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
|
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||||
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
||||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\lenovo\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
|
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\dell\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
|
||||||
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
<SourceRoot Include="C:\Users\lenovo\.nuget\packages\" />
|
<SourceRoot Include="C:\Users\dell\.nuget\packages\" />
|
||||||
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
|
<SourceRoot Include="C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<Import Project="$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props" Condition="Exists('$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props')" />
|
<Import Project="$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props" Condition="Exists('$(NuGetPackageRoot)microsoft.net.test.sdk\17.12.0\build\netcoreapp3.1\Microsoft.NET.Test.Sdk.props')" />
|
||||||
</ImportGroup>
|
</ImportGroup>
|
||||||
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
<Pkgxunit_analyzers Condition=" '$(Pkgxunit_analyzers)' == '' ">C:\Users\lenovo\.nuget\packages\xunit.analyzers\1.18.0</Pkgxunit_analyzers>
|
<Pkgxunit_analyzers Condition=" '$(Pkgxunit_analyzers)' == '' ">C:\Users\dell\.nuget\packages\xunit.analyzers\1.18.0</Pkgxunit_analyzers>
|
||||||
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\lenovo\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
|
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">C:\Users\dell\.nuget\packages\microsoft.extensions.apidescription.server\6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -2542,23 +2542,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageFolders": {
|
"packageFolders": {
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\": {},
|
"C:\\Users\\dell\\.nuget\\packages\\": {},
|
||||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
|
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
|
||||||
},
|
},
|
||||||
"project": {
|
"project": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
"projectUniqueName": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
||||||
"projectName": "Distribution.Tests",
|
"projectName": "Distribution.Tests",
|
||||||
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
||||||
"packagesPath": "C:\\Users\\lenovo\\.nuget\\packages\\",
|
"packagesPath": "C:\\Users\\dell\\.nuget\\packages\\",
|
||||||
"outputPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\obj\\",
|
"outputPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\obj\\",
|
||||||
"projectStyle": "PackageReference",
|
"projectStyle": "PackageReference",
|
||||||
"fallbackFolders": [
|
"fallbackFolders": [
|
||||||
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||||
],
|
],
|
||||||
"configFilePaths": [
|
"configFilePaths": [
|
||||||
"C:\\Users\\lenovo\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
"C:\\Users\\dell\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||||
],
|
],
|
||||||
@@ -2567,6 +2567,7 @@
|
|||||||
],
|
],
|
||||||
"sources": {
|
"sources": {
|
||||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||||
|
"C:\\Program Files\\dotnet\\library-packs": {},
|
||||||
"https://api.nuget.org/v3/index.json": {}
|
"https://api.nuget.org/v3/index.json": {}
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
@@ -2574,8 +2575,8 @@
|
|||||||
"framework": "net9.0",
|
"framework": "net9.0",
|
||||||
"targetAlias": "net9.0",
|
"targetAlias": "net9.0",
|
||||||
"projectReferences": {
|
"projectReferences": {
|
||||||
"C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj": {
|
"D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj": {
|
||||||
"projectPath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution\\Distribution.csproj"
|
"projectPath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution\\Distribution.csproj"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2590,7 +2591,7 @@
|
|||||||
"auditLevel": "low",
|
"auditLevel": "low",
|
||||||
"auditMode": "direct"
|
"auditMode": "direct"
|
||||||
},
|
},
|
||||||
"SdkAnalysisLevel": "10.0.300"
|
"SdkAnalysisLevel": "10.0.400"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
"net9.0": {
|
"net9.0": {
|
||||||
@@ -2638,7 +2639,7 @@
|
|||||||
"privateAssets": "all"
|
"privateAssets": "all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.302/PortableRuntimeIdentifierGraph.json"
|
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"dgSpecHash": "XdH8cqlThLk=",
|
"dgSpecHash": "JPCR3IFZHKY=",
|
||||||
"success": true,
|
"success": true,
|
||||||
"projectFilePath": "C:\\Users\\lenovo\\Desktop\\NEWGITEA\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
"projectFilePath": "D:\\HexDrive Projects\\Distribution\\ERP_Distribution\\Distribution.Tests\\Distribution.Tests.csproj",
|
||||||
"expectedPackageFiles": [
|
"expectedPackageFiles": [
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\castle.core\\5.1.1\\castle.core.5.1.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\castle.core\\5.1.1\\castle.core.5.1.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\coverlet.collector\\6.0.2\\coverlet.collector.6.0.2.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\coverlet.collector\\6.0.2\\coverlet.collector.6.0.2.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.aspnetcore.authentication.jwtbearer\\9.0.0\\microsoft.aspnetcore.authentication.jwtbearer.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.aspnetcore.authentication.jwtbearer\\9.0.0\\microsoft.aspnetcore.authentication.jwtbearer.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.aspnetcore.openapi\\9.0.0\\microsoft.aspnetcore.openapi.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.aspnetcore.openapi\\9.0.0\\microsoft.aspnetcore.openapi.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.codecoverage\\17.12.0\\microsoft.codecoverage.17.12.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.codecoverage\\17.12.0\\microsoft.codecoverage.17.12.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore\\9.0.0\\microsoft.entityframeworkcore.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore\\9.0.0\\microsoft.entityframeworkcore.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\9.0.0\\microsoft.entityframeworkcore.abstractions.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.abstractions\\9.0.0\\microsoft.entityframeworkcore.abstractions.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\9.0.0\\microsoft.entityframeworkcore.analyzers.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.analyzers\\9.0.0\\microsoft.entityframeworkcore.analyzers.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\9.0.0\\microsoft.entityframeworkcore.relational.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.entityframeworkcore.relational\\9.0.0\\microsoft.entityframeworkcore.relational.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.apidescription.server\\6.0.5\\microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\9.0.0\\microsoft.extensions.caching.abstractions.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.caching.abstractions\\9.0.0\\microsoft.extensions.caching.abstractions.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.caching.memory\\9.0.0\\microsoft.extensions.caching.memory.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.caching.memory\\9.0.0\\microsoft.extensions.caching.memory.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\9.0.0\\microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.configuration.abstractions\\9.0.0\\microsoft.extensions.configuration.abstractions.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\9.0.0\\microsoft.extensions.dependencyinjection.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.dependencyinjection\\9.0.0\\microsoft.extensions.dependencyinjection.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\9.0.0\\microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.dependencyinjection.abstractions\\9.0.0\\microsoft.extensions.dependencyinjection.abstractions.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.logging\\9.0.0\\microsoft.extensions.logging.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.logging\\9.0.0\\microsoft.extensions.logging.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\9.0.0\\microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.logging.abstractions\\9.0.0\\microsoft.extensions.logging.abstractions.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.options\\9.0.0\\microsoft.extensions.options.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.options\\9.0.0\\microsoft.extensions.options.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.extensions.primitives\\9.0.0\\microsoft.extensions.primitives.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.extensions.primitives\\9.0.0\\microsoft.extensions.primitives.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.abstractions\\8.0.1\\microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.abstractions\\8.0.1\\microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.jsonwebtokens\\8.0.1\\microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.jsonwebtokens\\8.0.1\\microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.logging\\8.0.1\\microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.logging\\8.0.1\\microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.protocols\\8.0.1\\microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.protocols\\8.0.1\\microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.protocols.openidconnect\\8.0.1\\microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.protocols.openidconnect\\8.0.1\\microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.identitymodel.tokens\\8.0.1\\microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.identitymodel.tokens\\8.0.1\\microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.net.test.sdk\\17.12.0\\microsoft.net.test.sdk.17.12.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.net.test.sdk\\17.12.0\\microsoft.net.test.sdk.17.12.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.openapi\\1.6.22\\microsoft.openapi.1.6.22.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.openapi\\1.6.22\\microsoft.openapi.1.6.22.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.testplatform.objectmodel\\17.12.0\\microsoft.testplatform.objectmodel.17.12.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.testplatform.objectmodel\\17.12.0\\microsoft.testplatform.objectmodel.17.12.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\microsoft.testplatform.testhost\\17.12.0\\microsoft.testplatform.testhost.17.12.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\microsoft.testplatform.testhost\\17.12.0\\microsoft.testplatform.testhost.17.12.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\moq\\4.20.72\\moq.4.20.72.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\moq\\4.20.72\\moq.4.20.72.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\mysqlconnector\\2.4.0\\mysqlconnector.2.4.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\mysqlconnector\\2.4.0\\mysqlconnector.2.4.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\newtonsoft.json\\13.0.1\\newtonsoft.json.13.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\newtonsoft.json\\13.0.1\\newtonsoft.json.13.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\pomelo.entityframeworkcore.mysql\\9.0.0\\pomelo.entityframeworkcore.mysql.9.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\pomelo.entityframeworkcore.mysql\\9.0.0\\pomelo.entityframeworkcore.mysql.9.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore\\7.3.1\\swashbuckle.aspnetcore.7.3.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore\\7.3.1\\swashbuckle.aspnetcore.7.3.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\7.3.1\\swashbuckle.aspnetcore.swagger.7.3.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swagger\\7.3.1\\swashbuckle.aspnetcore.swagger.7.3.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\7.3.1\\swashbuckle.aspnetcore.swaggergen.7.3.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swaggergen\\7.3.1\\swashbuckle.aspnetcore.swaggergen.7.3.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\7.3.1\\swashbuckle.aspnetcore.swaggerui.7.3.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\swashbuckle.aspnetcore.swaggerui\\7.3.1\\swashbuckle.aspnetcore.swaggerui.7.3.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\system.diagnostics.eventlog\\6.0.0\\system.diagnostics.eventlog.6.0.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\system.diagnostics.eventlog\\6.0.0\\system.diagnostics.eventlog.6.0.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\system.identitymodel.tokens.jwt\\8.0.1\\system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\system.identitymodel.tokens.jwt\\8.0.1\\system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\system.reflection.metadata\\1.6.0\\system.reflection.metadata.1.6.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\system.reflection.metadata\\1.6.0\\system.reflection.metadata.1.6.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit\\2.9.3\\xunit.2.9.3.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit\\2.9.3\\xunit.2.9.3.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.abstractions\\2.0.3\\xunit.abstractions.2.0.3.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.abstractions\\2.0.3\\xunit.abstractions.2.0.3.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.analyzers\\1.18.0\\xunit.analyzers.1.18.0.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.analyzers\\1.18.0\\xunit.analyzers.1.18.0.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.assert\\2.9.3\\xunit.assert.2.9.3.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.assert\\2.9.3\\xunit.assert.2.9.3.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.core\\2.9.3\\xunit.core.2.9.3.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.core\\2.9.3\\xunit.core.2.9.3.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.extensibility.core\\2.9.3\\xunit.extensibility.core.2.9.3.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.extensibility.core\\2.9.3\\xunit.extensibility.core.2.9.3.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.extensibility.execution\\2.9.3\\xunit.extensibility.execution.2.9.3.nupkg.sha512",
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.extensibility.execution\\2.9.3\\xunit.extensibility.execution.2.9.3.nupkg.sha512",
|
||||||
"C:\\Users\\lenovo\\.nuget\\packages\\xunit.runner.visualstudio\\2.8.2\\xunit.runner.visualstudio.2.8.2.nupkg.sha512"
|
"C:\\Users\\dell\\.nuget\\packages\\xunit.runner.visualstudio\\2.8.2\\xunit.runner.visualstudio.2.8.2.nupkg.sha512"
|
||||||
],
|
],
|
||||||
"logs": []
|
"logs": []
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
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<SaleRoute> SaleRoutes { get; set; }
|
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||||
|
public DbSet<Sale> Sales { get; set; }
|
||||||
public DbSet<Order> Orders { get; set; }
|
public DbSet<Order> Orders { get; set; }
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
public DbSet<OrderItem> OrderItems { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
@@ -36,6 +37,30 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
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<RideCancelRequest> RideCancelRequests { get; set; }
|
||||||
|
public DbSet<Scheme> Schemes { get; set; }
|
||||||
|
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
|
||||||
|
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
||||||
|
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
||||||
|
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
|
||||||
|
public DbSet<Payment> Payments { get; set; }
|
||||||
|
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
|
||||||
|
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
|
||||||
|
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||||
|
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||||
|
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||||
|
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||||
|
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||||
|
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||||
|
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
|
||||||
|
public DbSet<CreditNote> CreditNotes { get; set; }
|
||||||
|
public DbSet<Claim> Claims { get; set; }
|
||||||
|
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
||||||
|
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
||||||
|
public DbSet<RegionalTarget> RegionalTargets { get; set; }
|
||||||
|
public DbSet<RegionalBudget> RegionalBudgets { get; set; }
|
||||||
|
public DbSet<RegionAgencyMap> RegionAgencyMaps { get; set; }
|
||||||
|
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
|
||||||
|
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -68,15 +93,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.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();
|
||||||
|
|
||||||
modelBuilder.Entity<Vehicle>()
|
modelBuilder.Entity<Vehicle>()
|
||||||
.HasOne(v => v.DefaultWarehouse)
|
.HasOne<Warehouse>()
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(v => v.FK_DefaultWarehouseId)
|
.HasForeignKey(nameof(Vehicle.DefaultWarehouseId))
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<DeliveryRun>()
|
modelBuilder.Entity<DeliveryRun>()
|
||||||
.HasOne(r => r.SourceWarehouse)
|
.HasOne(r => r.SourceWarehouse)
|
||||||
@@ -90,6 +120,291 @@ 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>()
|
||||||
|
.HasOne(s => s.Store)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_StoreId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Sale>()
|
||||||
|
.HasOne(s => s.CreatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.CreatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Sale>()
|
||||||
|
.HasOne(s => s.Ride)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_RideId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.HasOne(o => o.Sale)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(o => o.FK_SaleId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
|
.HasOne(r => r.Scheme)
|
||||||
|
.WithMany(s => s.Redemptions)
|
||||||
|
.HasForeignKey(r => r.FK_SchemeId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
|
.HasOne(r => r.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
|
.HasOne(r => r.FreeItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_FreeItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Scheme>()
|
||||||
|
.HasOne(s => s.TriggerItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_TriggerItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Scheme>()
|
||||||
|
.HasOne(s => s.FreeItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_FreeItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeBundleComponent>()
|
||||||
|
.HasOne(c => c.Scheme)
|
||||||
|
.WithMany(s => s.BundleComponents)
|
||||||
|
.HasForeignKey(c => c.FK_SchemeId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeBundleComponent>()
|
||||||
|
.HasOne(c => c.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeBundleComponent>()
|
||||||
|
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<LoadingSheet>()
|
||||||
|
.HasOne(s => s.SourceWarehouse)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_SourceWarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<LoadingSheet>()
|
||||||
|
.HasOne(s => s.Ride)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_RideId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<LoadingSheetItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrder>()
|
||||||
|
.HasOne(po => po.Supplier)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(po => po.FK_SupplierId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasOne(i => i.PurchaseOrder)
|
||||||
|
.WithMany(po => po.Items)
|
||||||
|
.HasForeignKey(i => i.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
|
.HasOne(gr => gr.PurchaseOrder)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
|
.HasOne(gr => gr.Warehouse)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(gr => gr.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasOne(i => i.GoodsReceipt)
|
||||||
|
.WithMany(gr => gr.Items)
|
||||||
|
.HasForeignKey(i => i.FK_GoodsReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasOne(i => i.PurchaseOrderItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturn>()
|
||||||
|
.HasOne(pr => pr.PurchaseOrder)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(pr => pr.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
|
.HasOne(i => i.PurchaseReturn)
|
||||||
|
.WithMany(pr => pr.Items)
|
||||||
|
.HasForeignKey(i => i.FK_PurchaseReturnId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturn>()
|
||||||
|
.HasOne(r => r.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturn>()
|
||||||
|
.HasOne(r => r.CreatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.CreatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
|
.HasOne(i => i.SaleReturn)
|
||||||
|
.WithMany(r => r.Items)
|
||||||
|
.HasForeignKey(i => i.FK_SaleReturnId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
|
.HasIndex(i => i.FK_SaleReturnId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CreditNote>()
|
||||||
|
.HasOne(n => n.SaleReturn)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(n => n.FK_SaleReturnId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Claim>()
|
||||||
|
.HasOne(c => c.AgencyUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_AgencyUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Claim>()
|
||||||
|
.HasOne(c => c.SettledByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.SettledByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.PurchaseOrder)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.GoodsReceipt)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_GoodsReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.RaisedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.RaisedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.ResolvedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.ResolvedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
|
.HasOne(i => i.SupplierClaim)
|
||||||
|
.WithMany(c => c.Items)
|
||||||
|
.HasForeignKey(i => i.FK_SupplierClaimId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
|
.HasIndex(i => i.FK_SupplierClaimId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
|
.HasOne(t => t.Ride)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_RideId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
|
.HasOne(t => t.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalTarget>()
|
||||||
|
.HasIndex(t => new { t.FK_RegionId, t.Period })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalBudget>()
|
||||||
|
.HasIndex(b => new { b.FK_RegionId, b.Period })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionAgencyMap>()
|
||||||
|
.HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionAgencyMap>()
|
||||||
|
.HasOne(m => m.AgencyUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(m => m.FK_AgencyUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalTaxRule>()
|
||||||
|
.HasIndex(r => r.FK_RegionId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
|
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||||
|
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||||
|
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||||
|
Seed.CommerceSeed.Apply(modelBuilder);
|
||||||
|
Seed.OperationsExtrasSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class CommerceSeed
|
||||||
|
{
|
||||||
|
private static readonly Guid PriceList1Id = new("39000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid PriceListItem1Id = new("40000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid PriceListItem2Id = new("40000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid Scheme1Id = new("48000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Scheme2Id = new("48000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid SchemeBundleComponent1Id = new("49000000-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 SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid CreditNote1Id = new("54000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid Claim1Id = new("53000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<PriceList>().HasData(
|
||||||
|
new PriceList { Id = PriceList1Id, Name = "Western Province Price List", FK_RegionId = MasterDataSeed.WesternRegionId, EffectiveFrom = new DateTime(2026, 1, 1), EffectiveTo = new DateTime(2026, 12, 31), CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PriceListItem>().HasData(
|
||||||
|
new PriceListItem { Id = PriceListItem1Id, FK_PriceListId = PriceList1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Price = 60m, MinPrice = 55m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true },
|
||||||
|
new PriceListItem { Id = PriceListItem2Id, FK_PriceListId = PriceList1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Price = 50m, MinPrice = 45m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Scheme>().HasData(
|
||||||
|
new Scheme
|
||||||
|
{
|
||||||
|
Id = Scheme1Id,
|
||||||
|
Name = "Buy 10 Coke Get 1 Free",
|
||||||
|
RuleType = SchemeRuleType.BuyXGetY,
|
||||||
|
RuleConfig = null,
|
||||||
|
FK_TriggerItemId = MasterDataSeed.Coke500ItemId,
|
||||||
|
TriggerQty = 10,
|
||||||
|
FK_FreeItemId = MasterDataSeed.Coke500ItemId,
|
||||||
|
FreeQty = 1m,
|
||||||
|
FK_RegionId = MasterDataSeed.WesternRegionId,
|
||||||
|
EligibleSegment = null,
|
||||||
|
DateRangeFrom = new DateTime(2026, 8, 1),
|
||||||
|
DateRangeTo = new DateTime(2026, 12, 31),
|
||||||
|
AgentLimitExempt = false,
|
||||||
|
BaselineExpectedSales = 500000m,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 1),
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new Scheme
|
||||||
|
{
|
||||||
|
Id = Scheme2Id,
|
||||||
|
Name = "Snack Combo Bundle",
|
||||||
|
RuleType = SchemeRuleType.FreeBundle,
|
||||||
|
RuleConfig = null,
|
||||||
|
FK_TriggerItemId = null,
|
||||||
|
TriggerQty = null,
|
||||||
|
FK_FreeItemId = MasterDataSeed.LaysFamilyItemId,
|
||||||
|
FreeQty = 1m,
|
||||||
|
FK_RegionId = MasterDataSeed.CentralRegionId,
|
||||||
|
EligibleSegment = null,
|
||||||
|
DateRangeFrom = new DateTime(2026, 8, 1),
|
||||||
|
DateRangeTo = new DateTime(2026, 12, 31),
|
||||||
|
AgentLimitExempt = false,
|
||||||
|
BaselineExpectedSales = null,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 1),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SchemeBundleComponent>().HasData(
|
||||||
|
new SchemeBundleComponent { Id = SchemeBundleComponent1Id, FK_SchemeId = Scheme2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Qty = 5 }
|
||||||
|
);
|
||||||
|
|
||||||
|
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) }
|
||||||
|
);
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>().HasData(
|
||||||
|
new SaleReturnItem { Id = SaleReturnItem1Id, FK_SaleReturnId = SaleReturn1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 5m, ReasonCode = ReturnReasonCode.Damaged, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CreditNote>().HasData(
|
||||||
|
new CreditNote { Id = CreditNote1Id, FK_SaleReturnId = SaleReturn1Id, Amount = 300m, IssuedAt = new DateTime(2026, 8, 26), CreatedAt = new DateTime(2026, 8, 26), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Claim>().HasData(
|
||||||
|
new Claim
|
||||||
|
{
|
||||||
|
Id = Claim1Id,
|
||||||
|
FK_AgencyUserId = OperationsExtrasSeed.RegionalAgency1UserId,
|
||||||
|
Details = "Distributor margin claim for Q3 promotional shortfall",
|
||||||
|
Status = ClaimStatus.Settled,
|
||||||
|
SettlementAmount = 15000m,
|
||||||
|
SettledByUserId = MasterDataSeed.RegionalManager1UserId,
|
||||||
|
SettledAt = new DateTime(2026, 8, 28),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 27),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class MasterDataSeed
|
||||||
|
{
|
||||||
|
private static readonly DateTime SeedDate = new(2026, 8, 20);
|
||||||
|
|
||||||
|
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
|
||||||
|
// Implementation Notes in the plan; not derivable from HasData at migration time.
|
||||||
|
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
|
||||||
|
|
||||||
|
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
|
||||||
|
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
|
||||||
|
internal static readonly Guid RegionalManagerRoleId = new("10000000-0000-0000-0000-000000000005");
|
||||||
|
|
||||||
|
internal static readonly Guid BeveragesCategoryId = new("20000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid SnacksCategoryId = new("20000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid CocaColaBrandId = new("21000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid LaysBrandId = new("21000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid PieceUomId = new("22000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
internal static readonly Guid Coke500ItemId = new("23000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid Coke1500ItemId = new("23000000-0000-0000-0000-000000000002");
|
||||||
|
internal static readonly Guid LaysClassicItemId = new("23000000-0000-0000-0000-000000000003");
|
||||||
|
internal static readonly Guid LaysFamilyItemId = new("23000000-0000-0000-0000-000000000004");
|
||||||
|
|
||||||
|
internal static readonly Guid WesternRegionId = new("24000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid CentralRegionId = new("24000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid ColomboRouteId = new("25000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid NegomboRouteId = new("25000000-0000-0000-0000-000000000002");
|
||||||
|
internal static readonly Guid KandyRouteId = new("25000000-0000-0000-0000-000000000003");
|
||||||
|
internal static readonly Guid MataleRouteId = new("25000000-0000-0000-0000-000000000004");
|
||||||
|
|
||||||
|
internal static readonly Guid FortStoreId = new("26000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid DehiwalaStoreId = new("26000000-0000-0000-0000-000000000002");
|
||||||
|
internal static readonly Guid WattalaStoreId = new("26000000-0000-0000-0000-000000000003");
|
||||||
|
internal static readonly Guid NegomboStoreId = new("26000000-0000-0000-0000-000000000004");
|
||||||
|
internal static readonly Guid KandyStoreId = new("26000000-0000-0000-0000-000000000005");
|
||||||
|
internal static readonly Guid MataleStoreId = new("26000000-0000-0000-0000-000000000006");
|
||||||
|
|
||||||
|
internal static readonly Guid ColomboWarehouseId = new("27000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid KandyWarehouseId = new("27000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid VanVehicleId = new("28000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid TruckVehicleId = new("28000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid Supervisor1UserId = new("29000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid SalesRep1UserId = new("29000000-0000-0000-0000-000000000002");
|
||||||
|
internal static readonly Guid SalesRep2UserId = new("29000000-0000-0000-0000-000000000003");
|
||||||
|
internal static readonly Guid Driver1UserId = new("29000000-0000-0000-0000-000000000004");
|
||||||
|
internal static readonly Guid Driver2UserId = new("29000000-0000-0000-0000-000000000005");
|
||||||
|
internal static readonly Guid RegionalManager1UserId = new("29000000-0000-0000-0000-000000000006");
|
||||||
|
|
||||||
|
internal static readonly Guid Driver1ProfileId = new("30000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid Driver2ProfileId = new("30000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Category>().HasData(
|
||||||
|
new Category { Id = BeveragesCategoryId, Name = "Beverages", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Category { Id = SnacksCategoryId, Name = "Snacks", CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Brand>().HasData(
|
||||||
|
new Brand { Id = CocaColaBrandId, Name = "Coca-Cola", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Brand { Id = LaysBrandId, Name = "Lays", CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Uom>().HasData(
|
||||||
|
new Uom { Id = PieceUomId, Name = "Piece", CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Item>().HasData(
|
||||||
|
new Item { Id = Coke500ItemId, Sku = "BEV-COKE-500", Name = "Coca-Cola 500ml", Description = "Coca-Cola 500ml bottle", FK_CategoryId = BeveragesCategoryId, FK_BrandId = CocaColaBrandId, FK_BaseUomId = PieceUomId, SalePrice = 60m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Item { Id = Coke1500ItemId, Sku = "BEV-COKE-1500", Name = "Coca-Cola 1.5L", Description = "Coca-Cola 1.5L bottle", FK_CategoryId = BeveragesCategoryId, FK_BrandId = CocaColaBrandId, FK_BaseUomId = PieceUomId, SalePrice = 120m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Item { Id = LaysClassicItemId, Sku = "SNK-LAYS-52", Name = "Lays Classic 52g", Description = "Lays Classic Salted 52g pack", FK_CategoryId = SnacksCategoryId, FK_BrandId = LaysBrandId, FK_BaseUomId = PieceUomId, SalePrice = 50m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Item { Id = LaysFamilyItemId, Sku = "SNK-LAYS-FAM", Name = "Lays Family Pack 150g", Description = "Lays Family Pack 150g", FK_CategoryId = SnacksCategoryId, FK_BrandId = LaysBrandId, FK_BaseUomId = PieceUomId, SalePrice = 150m, SyncSource = SyncSource.Manual, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Region>().HasData(
|
||||||
|
new Region { Id = WesternRegionId, Name = "Western Province", CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Region { Id = CentralRegionId, Name = "Central Province", CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Models.Route>().HasData(
|
||||||
|
new Models.Route { Id = ColomboRouteId, RouteName = "Colombo City Route", StartPoint = "Colombo Fort", EndPoint = "Dehiwala", Length = 18.5m, FK_RegionId = WesternRegionId, FK_OwnerUserId = SalesRep1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Models.Route { Id = NegomboRouteId, RouteName = "Negombo Coastal Route", StartPoint = "Wattala", EndPoint = "Negombo", Length = 32.0m, FK_RegionId = WesternRegionId, FK_OwnerUserId = SalesRep1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Models.Route { Id = KandyRouteId, RouteName = "Kandy Town Route", StartPoint = "Kandy Lake", EndPoint = "Peradeniya", Length = 12.0m, FK_RegionId = CentralRegionId, FK_OwnerUserId = SalesRep2UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Models.Route { Id = MataleRouteId, RouteName = "Matale Route", StartPoint = "Kandy", EndPoint = "Matale", Length = 26.0m, FK_RegionId = CentralRegionId, FK_OwnerUserId = SalesRep2UserId, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Store>().HasData(
|
||||||
|
new Store { Id = FortStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Saman Kumara", StoreName = "Fort Mini Mart", StoreAddress = "12 York Street, Colombo Fort", Latitude = 6.9344m, Longitude = 79.8428m, CreditLimit = 50000m, OutstandingBalance = 0m, Segment = "Gold", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Store { Id = DehiwalaStoreId, FK_RouteId = ColomboRouteId, OwnerName = "Nadeesha Silva", StoreName = "Dehiwala Grocery", StoreAddress = "45 Galle Road, Dehiwala", Latitude = 6.8508m, Longitude = 79.8654m, CreditLimit = 30000m, OutstandingBalance = 5000m, Segment = "Silver", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Store { Id = WattalaStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Ruwan Gunasekara", StoreName = "Wattala Supermarket", StoreAddress = "88 Negombo Road, Wattala", Latitude = 6.9890m, Longitude = 79.8912m, CreditLimit = 40000m, OutstandingBalance = 1200m, Segment = "Gold", KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Store { Id = NegomboStoreId, FK_RouteId = NegomboRouteId, OwnerName = "Chamari Peiris", StoreName = "Negombo Beach Store", StoreAddress = "5 Beach Road, Negombo", Latitude = 7.2094m, Longitude = 79.8380m, CreditLimit = 25000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Store { Id = KandyStoreId, FK_RouteId = KandyRouteId, OwnerName = "Dinesh Wickramasinghe", StoreName = "Kandy Lake View Store", StoreAddress = "3 Lake Road, Kandy", Latitude = 7.2906m, Longitude = 80.6337m, CreditLimit = 35000m, OutstandingBalance = 0m, Segment = "Key Account", KycStatus = KycStatus.Verified, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Store { Id = MataleStoreId, FK_RouteId = MataleRouteId, OwnerName = "Ishara Bandara", StoreName = "Matale Central Store", StoreAddress = "20 Main Street, Matale", Latitude = 7.4675m, Longitude = 80.6234m, CreditLimit = 20000m, OutstandingBalance = 0m, KycStatus = KycStatus.Pending, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Warehouse>().HasData(
|
||||||
|
new Warehouse { Id = ColomboWarehouseId, Name = "Colombo Distribution Center", Location = "Peliyagoda, Colombo", Type = WareHouseType.DistributionCenter, VehicleId = null, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Warehouse { Id = KandyWarehouseId, Name = "Kandy Fulfillment Center", Location = "Katugastota, Kandy", Type = WareHouseType.FulfillmentCenter, VehicleId = null, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Vehicle>().HasData(
|
||||||
|
new Vehicle { Id = VanVehicleId, RegNumber = "WP-CAB-1234", NumberPlate = "CAB-1234", Type = VehicleType.Van, DefaultWarehouseId = ColomboWarehouseId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new Vehicle { Id = TruckVehicleId, RegNumber = "CP-KY-5678", NumberPlate = "KY-5678", Type = VehicleType.Truck, DefaultWarehouseId = KandyWarehouseId, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<User>().HasData(
|
||||||
|
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DriverProfile>().HasData(
|
||||||
|
new DriverProfile { Id = Driver1ProfileId, FK_UserId = Driver1UserId, LicenseNumber = "B1234567", LicenseExpiry = new DateTime(2027, 6, 30), MedicalClearanceExpiry = new DateTime(2027, 1, 31), CreatedAt = SeedDate, IsActive = true },
|
||||||
|
new DriverProfile { Id = Driver2ProfileId, FK_UserId = Driver2UserId, LicenseNumber = "B7654321", LicenseExpiry = new DateTime(2027, 9, 30), MedicalClearanceExpiry = new DateTime(2027, 3, 31), CreatedAt = SeedDate, IsActive = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class OperationsDataSeed
|
||||||
|
{
|
||||||
|
internal static readonly Guid ClosedSaleRunId = new("31000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid ActiveDeliveryRunId = new("31000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
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 Order2Id = new("33000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid OrderItem1Id = new("34000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid OrderItem2Id = new("34000000-0000-0000-0000-000000000002");
|
||||||
|
internal static readonly Guid OrderItem3Id = new("34000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
internal static readonly Guid LoadingSheetItem1Id = new("36000000-0000-0000-0000-000000000001");
|
||||||
|
internal static readonly Guid LoadingSheetItem2Id = new("36000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
internal static readonly Guid DeliveryTask1Id = new("37000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<DeliveryRun>().HasData(
|
||||||
|
new DeliveryRun
|
||||||
|
{
|
||||||
|
Id = ClosedSaleRunId,
|
||||||
|
RideType = RideType.Sale,
|
||||||
|
Status = RideStatus.Closed,
|
||||||
|
FK_VehicleId = MasterDataSeed.VanVehicleId,
|
||||||
|
FK_DriverProfileId = MasterDataSeed.Driver1ProfileId,
|
||||||
|
FK_RouteId = MasterDataSeed.ColomboRouteId,
|
||||||
|
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
||||||
|
Meterstart = 10500,
|
||||||
|
Meterend = 10550,
|
||||||
|
StartedAt = new DateTime(2026, 8, 25, 8, 0, 0),
|
||||||
|
ActivatedAt = new DateTime(2026, 8, 25, 8, 15, 0),
|
||||||
|
ClosedAt = new DateTime(2026, 8, 25, 14, 0, 0),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new DeliveryRun
|
||||||
|
{
|
||||||
|
Id = ActiveDeliveryRunId,
|
||||||
|
RideType = RideType.Delivery,
|
||||||
|
Status = RideStatus.Active,
|
||||||
|
FK_VehicleId = MasterDataSeed.TruckVehicleId,
|
||||||
|
FK_DriverProfileId = MasterDataSeed.Driver2ProfileId,
|
||||||
|
FK_RouteId = MasterDataSeed.KandyRouteId,
|
||||||
|
FK_SourceWarehouseId = MasterDataSeed.KandyWarehouseId,
|
||||||
|
Meterstart = 20300,
|
||||||
|
Meterend = 0,
|
||||||
|
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0),
|
||||||
|
ActivatedAt = new DateTime(2026, 8, 30, 9, 20, 0),
|
||||||
|
ClosedAt = null,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 30, 8, 45, 0),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Sale>().HasData(
|
||||||
|
new Sale
|
||||||
|
{
|
||||||
|
Id = Sale1Id,
|
||||||
|
SaleDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
|
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||||
|
CreatedByUserId = MasterDataSeed.SalesRep1UserId,
|
||||||
|
FK_RideId = ClosedSaleRunId,
|
||||||
|
TotalAmount = 3000m,
|
||||||
|
Status = SaleStatus.Completed,
|
||||||
|
PaymentStatus = PaymentStatus.Paid,
|
||||||
|
FulfillImmediately = false,
|
||||||
|
CompletedAt = new DateTime(2026, 8, 25, 9, 30, 0),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new Sale
|
||||||
|
{
|
||||||
|
Id = Sale2Id,
|
||||||
|
SaleDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
|
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||||
|
CreatedByUserId = MasterDataSeed.SalesRep2UserId,
|
||||||
|
FK_RideId = ActiveDeliveryRunId,
|
||||||
|
TotalAmount = 1200m,
|
||||||
|
Status = SaleStatus.Pending,
|
||||||
|
PaymentStatus = PaymentStatus.Pending,
|
||||||
|
FulfillImmediately = false,
|
||||||
|
CompletedAt = null,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>().HasData(
|
||||||
|
new Order
|
||||||
|
{
|
||||||
|
Id = Order1Id,
|
||||||
|
FK_SaleId = Sale1Id,
|
||||||
|
OrderDate = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
|
FK_StoreId = MasterDataSeed.FortStoreId,
|
||||||
|
TotalAmount = 3000m,
|
||||||
|
Status = OrderStatus.Fulfilled,
|
||||||
|
FulfilledAt = new DateTime(2026, 8, 25, 13, 0, 0),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0),
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new Order
|
||||||
|
{
|
||||||
|
Id = Order2Id,
|
||||||
|
FK_SaleId = Sale2Id,
|
||||||
|
OrderDate = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
|
FK_StoreId = MasterDataSeed.KandyStoreId,
|
||||||
|
TotalAmount = 1200m,
|
||||||
|
Status = OrderStatus.Pending,
|
||||||
|
FulfilledAt = null,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<OrderItem>().HasData(
|
||||||
|
new OrderItem { Id = OrderItem1Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||||
|
new OrderItem { Id = OrderItem2Id, FK_OrderId = Order1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||||
|
new OrderItem { Id = OrderItem3Id, FK_OrderId = Order2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<LoadingSheet>().HasData(
|
||||||
|
new LoadingSheet
|
||||||
|
{
|
||||||
|
Id = LoadingSheet1Id,
|
||||||
|
FK_RideId = ClosedSaleRunId,
|
||||||
|
FK_SourceWarehouseId = MasterDataSeed.ColomboWarehouseId,
|
||||||
|
Status = LoadingSheetStatus.ReceiptConfirmed,
|
||||||
|
DispatchConfirmedAt = new DateTime(2026, 8, 25, 7, 45, 0),
|
||||||
|
DispatchConfirmedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
|
ReceiptConfirmedAt = new DateTime(2026, 8, 25, 14, 5, 0),
|
||||||
|
ReceiptConfirmedBy = MasterDataSeed.Driver1UserId,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<LoadingSheetItem>().HasData(
|
||||||
|
new LoadingSheetItem { Id = LoadingSheetItem1Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, QuantityDispatched = 50m, QuantityReceived = 50m, QuantityResolved = null },
|
||||||
|
new LoadingSheetItem { Id = LoadingSheetItem2Id, FK_LoadingSheetId = LoadingSheet1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, QuantityDispatched = 30m, QuantityReceived = 30m, QuantityResolved = null }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DeliveryTask>().HasData(
|
||||||
|
new DeliveryTask
|
||||||
|
{
|
||||||
|
Id = DeliveryTask1Id,
|
||||||
|
FK_RideId = ActiveDeliveryRunId,
|
||||||
|
FK_OrderId = Order2Id,
|
||||||
|
Sequence = 1,
|
||||||
|
Status = DeliveryTaskStatus.Pending,
|
||||||
|
CustomerConfirmed = false,
|
||||||
|
ConfirmedAt = null,
|
||||||
|
Note = null,
|
||||||
|
CreatedAt = new DateTime(2026, 8, 30, 10, 5, 0),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class OperationsExtrasSeed
|
||||||
|
{
|
||||||
|
// Role id matches RegionalAgency in Data/Seed/RoleAndPermissionSeed.cs (private there).
|
||||||
|
private static readonly Guid RegionalAgencyRoleId = new("10000000-0000-0000-0000-000000000006");
|
||||||
|
|
||||||
|
internal static readonly Guid RegionalAgency1UserId = new("70000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid CaseUomId = new("67000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid UomConversion1Id = new("67000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid RegionAgencyMap1Id = new("57000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid RegionalBudget1Id = new("58000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid RegionalTarget1Id = new("59000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid RegionalTaxRule1Id = new("60000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid StockBalance1Id = new("61000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid StockBalance2Id = new("61000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid StockBalance3Id = new("61000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
private static readonly Guid StockMovement1Id = new("62000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid StockMovement2Id = new("62000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid StockMovement3Id = new("62000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
private static readonly Guid StoreKycDocument1Id = new("63000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid StoreKycDocument2Id = new("63000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid UserRegionScope1Id = new("68000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid UserRouteScope1Id = new("68000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid UserStoreScope1Id = new("68000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
private static readonly Guid SaleRoute1Id = new("69000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid SaleRoute2Id = new("69000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<User>().HasData(
|
||||||
|
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Uom>().HasData(
|
||||||
|
new Uom { Id = CaseUomId, Name = "Case", CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<UomConversion>().HasData(
|
||||||
|
new UomConversion { Id = UomConversion1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FK_FromUomId = CaseUomId, FK_ToUomId = MasterDataSeed.PieceUomId, Factor = 24m }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionAgencyMap>().HasData(
|
||||||
|
new RegionAgencyMap { Id = RegionAgencyMap1Id, FK_RegionId = MasterDataSeed.WesternRegionId, FK_AgencyUserId = RegionalAgency1UserId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalBudget>().HasData(
|
||||||
|
new RegionalBudget { Id = RegionalBudget1Id, FK_RegionId = MasterDataSeed.WesternRegionId, Period = "2026-08", BudgetAmount = 200000m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalTarget>().HasData(
|
||||||
|
new RegionalTarget { Id = RegionalTarget1Id, FK_RegionId = MasterDataSeed.WesternRegionId, Period = "2026-08", TargetValue = 500000m, CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RegionalTaxRule>().HasData(
|
||||||
|
new RegionalTaxRule { Id = RegionalTaxRule1Id, FK_RegionId = MasterDataSeed.WesternRegionId, RuleConfig = "{\"rate\": 0.15}", CreatedAt = new DateTime(2026, 8, 1), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockBalance>().HasData(
|
||||||
|
new StockBalance { Id = StockBalance1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, LocationId = MasterDataSeed.ColomboWarehouseId, LocationType = LocationType.Warehouse, Quantity = 150m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) },
|
||||||
|
new StockBalance { Id = StockBalance2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, LocationId = MasterDataSeed.ColomboWarehouseId, LocationType = LocationType.Warehouse, Quantity = 90m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) },
|
||||||
|
new StockBalance { Id = StockBalance3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, LocationId = MasterDataSeed.VanVehicleId, LocationType = LocationType.Vehicle, Quantity = 20m, UpdatedAt = new DateTime(2026, 8, 25, 9, 30, 0) }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockMovement>().HasData(
|
||||||
|
new StockMovement { Id = StockMovement1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = StockService.ExternalLocationId, FromLocationType = LocationType.External, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
||||||
|
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
|
||||||
|
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = StockService.ExternalLocationId, ToLocationType = LocationType.External, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreKycDocument>().HasData(
|
||||||
|
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
|
||||||
|
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DriverComplianceConfig>().HasData(
|
||||||
|
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RideCancelRequest>().HasData(
|
||||||
|
new RideCancelRequest
|
||||||
|
{
|
||||||
|
Id = RideCancelRequest1Id,
|
||||||
|
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
|
||||||
|
TypeCode = CancelRequestType.VehicleBreakdown,
|
||||||
|
Reason = "Reported minor engine issue, resolved after inspection",
|
||||||
|
RequestedBy = MasterDataSeed.Driver2UserId,
|
||||||
|
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
|
||||||
|
Status = CancelRequestStatus.Rejected,
|
||||||
|
ApprovedBy = MasterDataSeed.Supervisor1UserId,
|
||||||
|
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<RouteVisitLog>().HasData(
|
||||||
|
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
|
||||||
|
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserRegionScope>().HasData(
|
||||||
|
new UserRegionScope { Id = UserRegionScope1Id, FK_UserId = MasterDataSeed.RegionalManager1UserId, FK_RegionId = MasterDataSeed.WesternRegionId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserRouteScope>().HasData(
|
||||||
|
new UserRouteScope { Id = UserRouteScope1Id, FK_UserId = MasterDataSeed.Supervisor1UserId, FK_RouteId = MasterDataSeed.ColomboRouteId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserStoreScope>().HasData(
|
||||||
|
new UserStoreScope { Id = UserStoreScope1Id, FK_UserId = MasterDataSeed.SalesRep2UserId, FK_StoreId = MasterDataSeed.KandyStoreId, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleRoute>().HasData(
|
||||||
|
new SaleRoute { Id = SaleRoute1Id, CreatedAt = new DateTime(2026, 8, 20), IsActive = true },
|
||||||
|
new SaleRoute { Id = SaleRoute2Id, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Data.Seed;
|
||||||
|
|
||||||
|
public static class ProcurementSeed
|
||||||
|
{
|
||||||
|
private static readonly Guid Supplier1Id = new("38000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Supplier2Id = new("38000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid PurchaseOrder1Id = new("41000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid PurchaseOrder2Id = new("41000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid PurchaseOrderItem1Id = new("42000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid PurchaseOrderItem2Id = new("42000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid PurchaseOrderItem3Id = new("42000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
internal static readonly Guid GoodsReceipt1Id = new("43000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid GoodsReceiptItem1Id = new("44000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid GoodsReceiptItem2Id = new("44000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private static readonly Guid PurchaseReturn1Id = new("45000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid PurchaseReturnItem1Id = new("46000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private static readonly Guid SupplierClaim1Id = new("55000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid SupplierClaimItem1Id = new("56000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Supplier>().HasData(
|
||||||
|
new Supplier { Id = Supplier1Id, Name = "Ceylon Beverages Ltd", ContactInfo = "+94 11 234 5678", PerformanceScore = 4.2m, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||||
|
new Supplier { Id = Supplier2Id, Name = "Snack Foods (Pvt) Ltd", ContactInfo = "+94 11 876 5432", PerformanceScore = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrder>().HasData(
|
||||||
|
new PurchaseOrder { Id = PurchaseOrder1Id, FK_SupplierId = Supplier1Id, FK_CreatedByUserId = MasterDataSeed.Supervisor1UserId, Status = PurchaseOrderStatus.Received, CancelReason = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||||
|
new PurchaseOrder { Id = PurchaseOrder2Id, FK_SupplierId = Supplier2Id, FK_CreatedByUserId = MasterDataSeed.Supervisor1UserId, Status = PurchaseOrderStatus.Received, CancelReason = null, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>().HasData(
|
||||||
|
new PurchaseOrderItem { Id = PurchaseOrderItem1Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 200, UnitCost = 40m, ReceivedQuantity = 200, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||||
|
new PurchaseOrderItem { Id = PurchaseOrderItem2Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 100, UnitCost = 80m, ReceivedQuantity = 98, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true },
|
||||||
|
new PurchaseOrderItem { Id = PurchaseOrderItem3Id, FK_PurchaseOrderId = PurchaseOrder2Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 150, UnitCost = 35m, ReceivedQuantity = 150, CancelledQuantity = 0, IsCancelled = false, CreatedAt = new DateTime(2026, 8, 14), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>().HasData(
|
||||||
|
new GoodsReceipt { Id = GoodsReceipt1Id, FK_PurchaseOrderId = PurchaseOrder1Id, FK_ReceivedByUserId = MasterDataSeed.Supervisor1UserId, FK_WarehouseId = MasterDataSeed.ColomboWarehouseId, ReceivedAt = new DateTime(2026, 8, 15), CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>().HasData(
|
||||||
|
new GoodsReceiptItem { Id = GoodsReceiptItem1Id, FK_GoodsReceiptId = GoodsReceipt1Id, FK_PurchaseOrderItemId = PurchaseOrderItem1Id, AcceptedQuantity = 200, RejectedQuantity = 0, CreatedAt = new DateTime(2026, 8, 15), IsActive = true },
|
||||||
|
new GoodsReceiptItem { Id = GoodsReceiptItem2Id, FK_GoodsReceiptId = GoodsReceipt1Id, FK_PurchaseOrderItemId = PurchaseOrderItem2Id, AcceptedQuantity = 98, RejectedQuantity = 2, CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturn>().HasData(
|
||||||
|
new PurchaseReturn { Id = PurchaseReturn1Id, FK_PurchaseOrderId = PurchaseOrder2Id, Reason = "Damaged in transit", CreatedAt = new DateTime(2026, 8, 16), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>().HasData(
|
||||||
|
new PurchaseReturnItem { Id = PurchaseReturnItem1Id, FK_PurchaseReturnId = PurchaseReturn1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 10, CreatedAt = new DateTime(2026, 8, 16), IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>().HasData(
|
||||||
|
new SupplierClaim
|
||||||
|
{
|
||||||
|
Id = SupplierClaim1Id,
|
||||||
|
FK_PurchaseOrderId = PurchaseOrder1Id,
|
||||||
|
FK_GoodsReceiptId = GoodsReceipt1Id,
|
||||||
|
Details = "2 units of Coke 1.5L received damaged",
|
||||||
|
EvidenceUrl = null,
|
||||||
|
Status = SupplierClaimStatus.Approved,
|
||||||
|
ResolutionType = SupplierClaimResolutionType.Credit,
|
||||||
|
ResolutionAmount = 160m,
|
||||||
|
ResolutionNotes = "Credited against next invoice",
|
||||||
|
RaisedByUserId = MasterDataSeed.Supervisor1UserId,
|
||||||
|
ResolvedByUserId = MasterDataSeed.RegionalManager1UserId,
|
||||||
|
ResolvedAt = new DateTime(2026, 8, 16),
|
||||||
|
CreatedAt = new DateTime(2026, 8, 15),
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>().HasData(
|
||||||
|
new SupplierClaimItem { Id = SupplierClaimItem1Id, FK_SupplierClaimId = SupplierClaim1Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 2m, IssueType = SupplierClaimIssueType.Damaged, CreatedAt = new DateTime(2026, 8, 15), IsActive = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,78 +14,6 @@ public static class RoleAndPermissionSeed
|
|||||||
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");
|
||||||
|
|
||||||
private static readonly (string Code, string Description) [] PermissionDefs =
|
|
||||||
[
|
|
||||||
("regions.view", "View regions"),
|
|
||||||
("regions.create", "Create regions"),
|
|
||||||
("regions.update", "Update regions"),
|
|
||||||
("regions.delete", "Delete regions"),
|
|
||||||
|
|
||||||
("routes.view", "View routes"),
|
|
||||||
("routes.create", "Create routes"),
|
|
||||||
("routes.update", "Update routes"),
|
|
||||||
("routes.delete", "Delete routes"),
|
|
||||||
|
|
||||||
("vehicles.view", "View vehicles"),
|
|
||||||
("vehicles.create", "Create vehicles"),
|
|
||||||
("vehicles.update", "Update vehicles"),
|
|
||||||
("vehicles.delete", "Delete vehicles"),
|
|
||||||
|
|
||||||
("stores.view", "View stores"),
|
|
||||||
("stores.create", "Create stores"),
|
|
||||||
("stores.update", "Update stores"),
|
|
||||||
("stores.delete", "Delete stores"),
|
|
||||||
("stores.kyc.verify", "Verify or reject store KYC documents"),
|
|
||||||
("stores.credit.update", "Update store credit limit and outstanding balance"),
|
|
||||||
|
|
||||||
("warehouses.view", "View warehouses"),
|
|
||||||
("warehouses.create", "Create warehouses"),
|
|
||||||
("warehouses.update", "Update warehouses"),
|
|
||||||
("warehouses.delete", "Delete warehouses"),
|
|
||||||
|
|
||||||
("orders.view", "View orders"),
|
|
||||||
("orders.create", "Create orders"),
|
|
||||||
("orders.update", "Update orders"),
|
|
||||||
("orders.delete", "Delete orders"),
|
|
||||||
|
|
||||||
("users.view", "View users"),
|
|
||||||
("users.create", "Create users"),
|
|
||||||
("users.update", "Update users"),
|
|
||||||
("users.deactivate", "Deactivate users"),
|
|
||||||
("users.assign_role", "Assign a role to a user"),
|
|
||||||
("users.assign_scope", "Assign region/route/store scope to a user"),
|
|
||||||
|
|
||||||
("items.view", "View items, brands and UOMs"),
|
|
||||||
("items.create", "Create items, brands and UOMs"),
|
|
||||||
("items.update", "Update items, brands and UOMs"),
|
|
||||||
("items.delete", "Delete items, brands and UOMs"),
|
|
||||||
|
|
||||||
("categories.view", "View categories"),
|
|
||||||
("categories.create", "Create categories and subcategories"),
|
|
||||||
("categories.update", "Update categories and subcategories"),
|
|
||||||
("categories.delete", "Delete categories and subcategories"),
|
|
||||||
|
|
||||||
("driver_profiles.view", "View driver profiles"),
|
|
||||||
("driver_profiles.create", "Create driver profiles"),
|
|
||||||
("driver_profiles.update", "Update driver profiles"),
|
|
||||||
("driver_profiles.deactivate", "Deactivate driver profiles"),
|
|
||||||
|
|
||||||
("compliance.manage", "Manage driver compliance configuration"),
|
|
||||||
|
|
||||||
("rides.view", "View rides"),
|
|
||||||
("rides.create", "Create rides"),
|
|
||||||
("rides.manage", "Manage ride lifecycle"),
|
|
||||||
("rides.cancel.request", "Request ride cancellation"),
|
|
||||||
("rides.cancel.approve", "Approve or reject ride cancellations"),
|
|
||||||
];
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, Guid> PermissionIds = PermissionDefs
|
|
||||||
.Select((p, i) => (p.Code, Id: DeterministicPermissionId(i)))
|
|
||||||
.ToDictionary(x => x.Code, x => x.Id);
|
|
||||||
|
|
||||||
private static Guid DeterministicPermissionId(int index) =>
|
|
||||||
new($"20000000-0000-0000-0000-{index + 1:D12}");
|
|
||||||
|
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<Role>().HasData(
|
modelBuilder.Entity<Role>().HasData(
|
||||||
@@ -96,76 +24,5 @@ public static class RoleAndPermissionSeed
|
|||||||
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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
modelBuilder.Entity<Permission>().HasData(
|
|
||||||
PermissionDefs.Select(p => new Permission
|
|
||||||
{
|
|
||||||
Id = PermissionIds[p.Code],
|
|
||||||
Code = p.Code,
|
|
||||||
Description = p.Description,
|
|
||||||
CreatedAt = SeedDate,
|
|
||||||
IsActive = true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
var itemViewPerms = new[] { "items.view", "categories.view" };
|
|
||||||
var itemManagePerms = new[] { "items.view", "items.create", "items.update", "categories.view" };
|
|
||||||
var itemPerms = new[] { "items.view", "items.create", "items.update", "items.delete", "categories.view", "categories.create", "categories.update", "categories.delete" };
|
|
||||||
|
|
||||||
var salesRepPerms = new[] { "orders.view", "orders.create", "orders.update", "stores.view", "routes.view" }.Concat(itemViewPerms).ToArray();
|
|
||||||
var supervisorPerms = salesRepPerms.Concat(["orders.delete", "stores.create", "stores.update"]).ToArray();
|
|
||||||
var ridePerms = new[]
|
|
||||||
{
|
|
||||||
"driver_profiles.view", "driver_profiles.create", "driver_profiles.update", "driver_profiles.deactivate",
|
|
||||||
"compliance.manage",
|
|
||||||
"rides.view", "rides.create", "rides.manage", "rides.cancel.request", "rides.cancel.approve"
|
|
||||||
};
|
|
||||||
|
|
||||||
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view"]).Concat(itemManagePerms).Concat(ridePerms).Distinct().ToArray();
|
|
||||||
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update"]).Concat(itemPerms).Distinct().ToArray();
|
|
||||||
|
|
||||||
var regionalManagerPerms = new[]
|
|
||||||
{
|
|
||||||
"orders.view", "orders.create", "orders.update", "orders.delete",
|
|
||||||
"stores.view", "stores.create", "stores.update", "stores.delete", "stores.kyc.verify", "stores.credit.update",
|
|
||||||
"routes.view", "routes.create", "routes.update", "routes.delete",
|
|
||||||
"vehicles.view", "vehicles.create", "vehicles.update", "vehicles.delete",
|
|
||||||
"warehouses.view", "warehouses.create", "warehouses.update", "warehouses.delete",
|
|
||||||
"regions.view", "users.view",
|
|
||||||
"driver_profiles.view", "driver_profiles.create", "driver_profiles.update", "driver_profiles.deactivate",
|
|
||||||
"compliance.manage",
|
|
||||||
"rides.view", "rides.create", "rides.manage", "rides.cancel.request", "rides.cancel.approve",
|
|
||||||
"items.view", "items.create", "items.update", "items.delete",
|
|
||||||
"categories.view", "categories.create", "categories.update", "categories.delete"
|
|
||||||
};
|
|
||||||
var regionalAgencyPerms = PermissionDefs.Select(p => p.Code).ToArray();
|
|
||||||
|
|
||||||
var rolePermissionMap = new (Guid RoleId, string[] Codes)[]
|
|
||||||
{
|
|
||||||
(SalesRepId, salesRepPerms),
|
|
||||||
(SupervisorId, supervisorPerms),
|
|
||||||
(AreaManagerId, areaManagerPerms),
|
|
||||||
(AreaSupervisorId, areaSupervisorPerms),
|
|
||||||
(RegionalManagerId, regionalManagerPerms),
|
|
||||||
(RegionalAgencyId, regionalAgencyPerms),
|
|
||||||
};
|
|
||||||
|
|
||||||
modelBuilder.Entity<RolePermission>().HasData(
|
|
||||||
rolePermissionMap.SelectMany(rp => rp.Codes.Select(code => new RolePermission
|
|
||||||
{
|
|
||||||
Id = DeterministicRolePermissionId(rp.RoleId, PermissionIds[code]),
|
|
||||||
FK_RoleId = rp.RoleId,
|
|
||||||
FK_PermissionId = PermissionIds[code],
|
|
||||||
CreatedAt = SeedDate,
|
|
||||||
IsActive = true
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Guid DeterministicRolePermissionId(Guid roleId, Guid permissionId)
|
|
||||||
{
|
|
||||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
|
||||||
var bytes = md5.ComputeHash(roleId.ToByteArray().Concat(permissionId.ToByteArray()).ToArray());
|
|
||||||
return new Guid(bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController(AuthService service) : ControllerBase
|
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
|
private static IActionResult ToActionResult(AuthHexResponse result) =>
|
||||||
|
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login(LoginRequest request)
|
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
|
||||||
|
ToActionResult(await service.LoginAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
|
||||||
|
ToActionResult(await service.RegisterAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpGet("me")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Me(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.LoginAsync(request);
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
return result is null ? Unauthorized() : Ok(result);
|
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return ToActionResult(await service.LogoutAsync(token, ct));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
|
|||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
|
public record RegisterRequest(string Username, string Password, string? FullName);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
using Distribution.Features.User;
|
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
|
/// <summary>
|
||||||
|
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
|
||||||
|
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
|
||||||
|
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
|
||||||
|
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
|
||||||
|
/// </summary>
|
||||||
|
public class AuthService(IAuthHexClient authHex)
|
||||||
{
|
{
|
||||||
private readonly PasswordHasher<Models.User> _hasher = new();
|
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
||||||
|
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
|
|
||||||
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
|
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||||
{
|
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
var user = await userRepo.GetByUsernameAsync(request.Username);
|
|
||||||
if (user is null || !user.IsActive)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
|
||||||
if (result == PasswordVerificationResult.Failed)
|
authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
|
||||||
return null;
|
|
||||||
|
|
||||||
var token = tokenService.GenerateToken(user);
|
public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
|
||||||
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
|
authHex.CallAsync("LogoutUser", new { token }, ct: ct);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
|
public class AuthServiceOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "AuthService";
|
||||||
|
|
||||||
|
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connector to the external Auth Service. This backend proxies all auth
|
||||||
|
/// traffic from the Next.js frontend through to the Auth Service — the
|
||||||
|
/// frontend never calls the Auth Service directly.
|
||||||
|
///
|
||||||
|
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuthServiceClient
|
||||||
|
{
|
||||||
|
// TODO: define methods based on Auth Service contract, e.g.:
|
||||||
|
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
|
||||||
|
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||||
|
// Task LogoutAsync(string token, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Brand;
|
namespace Distribution.Features.Brand;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/brands")]
|
[Route("api/brands")]
|
||||||
[Authorize]
|
|
||||||
public class BrandController(BrandService service) : ControllerBase
|
public class BrandController(BrandService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.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);
|
||||||
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
namespace Distribution.Features.Brand;
|
namespace Distribution.Features.Brand;
|
||||||
|
|
||||||
public record CreateBrandRequest(string Name);
|
public record CreateBrandRequest(string Name);
|
||||||
public record UpdateBrandRequest(string Name);
|
public record UpdateBrandRequest(string Name, bool IsActive = true);
|
||||||
public record BrandResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
public record BrandResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ namespace Distribution.Features.Brand;
|
|||||||
public class BrandService(IBrandRepository repo, IUnitOfWork uow)
|
public class BrandService(IBrandRepository repo, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
public async Task<List<BrandResponse>> GetAllAsync() =>
|
public async Task<List<BrandResponse>> GetAllAsync() =>
|
||||||
|
// Deleted brands stay IsActive=false but remain visible showing Inactive — delete
|
||||||
|
// never removes the row, only marks it (same rule as Items/Stores/Categories).
|
||||||
(await repo.GetAllAsync())
|
(await repo.GetAllAsync())
|
||||||
.Where(b => b.IsActive)
|
|
||||||
.Select(b => new BrandResponse(b.Id, b.Name, b.IsActive, b.CreatedAt))
|
.Select(b => new BrandResponse(b.Id, b.Name, b.IsActive, b.CreatedAt))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public async Task<BrandResponse?> GetByIdAsync(Guid id)
|
public async Task<BrandResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var brand = await repo.GetByIdAsync(id);
|
var brand = await repo.GetByIdAsync(id);
|
||||||
if (brand is null || !brand.IsActive) return null;
|
if (brand is null) return null;
|
||||||
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
|
return new BrandResponse(brand.Id, brand.Name, brand.IsActive, brand.CreatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,9 +30,10 @@ public class BrandService(IBrandRepository repo, IUnitOfWork uow)
|
|||||||
public async Task<BrandResponse?> UpdateAsync(Guid id, UpdateBrandRequest request, CancellationToken ct = default)
|
public async Task<BrandResponse?> UpdateAsync(Guid id, UpdateBrandRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var brand = await repo.GetByIdAsync(id);
|
var brand = await repo.GetByIdAsync(id);
|
||||||
if (brand is null || !brand.IsActive) return null;
|
if (brand is null) return null;
|
||||||
if (brand.ExternalErpId.HasValue) return null;
|
if (brand.ExternalErpId.HasValue) return null;
|
||||||
brand.Name = request.Name;
|
brand.Name = request.Name;
|
||||||
|
brand.IsActive = request.IsActive;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(brand); await uow.CommitAsync(ct); }
|
try { repo.Update(brand); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -40,10 +42,11 @@ public class BrandService(IBrandRepository repo, IUnitOfWork uow)
|
|||||||
|
|
||||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// Soft-delete only — never blocked by items still referencing this brand, same rule
|
||||||
|
// as Categories.
|
||||||
var brand = await repo.GetByIdAsync(id);
|
var brand = await repo.GetByIdAsync(id);
|
||||||
if (brand is null || !brand.IsActive) return false;
|
if (brand is null || !brand.IsActive) return false;
|
||||||
if (brand.ExternalErpId.HasValue) return false;
|
if (brand.ExternalErpId.HasValue) return false;
|
||||||
if (await repo.HasActiveItemsAsync(id)) return false;
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Remove(brand); await uow.CommitAsync(ct); }
|
try { repo.Remove(brand); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Category;
|
namespace Distribution.Features.Category;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class CategoryController(CategoryService service) : ControllerBase
|
public class CategoryController(CategoryService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/categories")]
|
[HttpGet("api/categories")]
|
||||||
[Authorize(Policy = "categories.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("api/categories/{id:guid}")]
|
[HttpGet("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/categories")]
|
[HttpPost("api/categories")]
|
||||||
[Authorize(Policy = "categories.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/categories/{id:guid}")]
|
[HttpPut("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("api/categories/{id:guid}")]
|
[HttpDelete("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
||||||
[Authorize(Policy = "categories.create")]
|
|
||||||
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
||||||
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/subcategories/{id:guid}")]
|
[HttpPut("api/subcategories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.update")]
|
|
||||||
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
||||||
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("api/subcategories/{id:guid}")]
|
[HttpDelete("api/subcategories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.delete")]
|
|
||||||
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace Distribution.Features.Category;
|
namespace Distribution.Features.Category;
|
||||||
|
|
||||||
public record CreateCategoryRequest(string Name);
|
public record CreateCategoryRequest(string Name);
|
||||||
public record UpdateCategoryRequest(string Name);
|
public record UpdateCategoryRequest(string Name, bool IsActive = true);
|
||||||
public record CreateSubCategoryRequest(string Name);
|
public record CreateSubCategoryRequest(string Name);
|
||||||
public record UpdateSubCategoryRequest(string Name);
|
public record UpdateSubCategoryRequest(string Name, bool IsActive = true);
|
||||||
|
|
||||||
public record SubCategoryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
public record SubCategoryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||||
public record CategorySummaryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
public record CategorySummaryResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||||
|
|||||||
@@ -4,18 +4,27 @@ namespace Distribution.Features.Category;
|
|||||||
|
|
||||||
public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepository subCategoryRepo, IUnitOfWork uow)
|
public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepository subCategoryRepo, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
public async Task<List<CategorySummaryResponse>> GetAllAsync() =>
|
public async Task<List<CategoryResponse>> GetAllAsync()
|
||||||
(await categoryRepo.GetAllAsync())
|
{
|
||||||
.Where(c => c.IsActive)
|
// Deleted categories/sub-categories stay IsActive=false but remain visible showing
|
||||||
.Select(c => new CategorySummaryResponse(c.Id, c.Name, c.IsActive, c.CreatedAt))
|
// Inactive — delete never removes the row, only marks it (same rule as Orders/Stores/Items).
|
||||||
|
var categoryList = await categoryRepo.GetAllAsync();
|
||||||
|
var result = new List<CategoryResponse>();
|
||||||
|
foreach (var category in categoryList)
|
||||||
|
{
|
||||||
|
var subs = (await subCategoryRepo.GetByCategoryIdAsync(category.Id))
|
||||||
|
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
result.Add(new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<CategoryResponse?> GetByIdAsync(Guid id)
|
public async Task<CategoryResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var category = await categoryRepo.GetByIdAsync(id);
|
var category = await categoryRepo.GetByIdAsync(id);
|
||||||
if (category is null || !category.IsActive) return null;
|
if (category is null) return null;
|
||||||
var subs = (await subCategoryRepo.GetByCategoryIdAsync(id))
|
var subs = (await subCategoryRepo.GetByCategoryIdAsync(id))
|
||||||
.Where(s => s.IsActive)
|
|
||||||
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
|
.Select(s => new SubCategoryResponse(s.Id, s.Name, s.IsActive, s.CreatedAt))
|
||||||
.ToList();
|
.ToList();
|
||||||
return new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs);
|
return new CategoryResponse(category.Id, category.Name, category.IsActive, category.CreatedAt, subs);
|
||||||
@@ -33,9 +42,10 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
|
|||||||
public async Task<CategorySummaryResponse?> UpdateAsync(Guid id, UpdateCategoryRequest request, CancellationToken ct = default)
|
public async Task<CategorySummaryResponse?> UpdateAsync(Guid id, UpdateCategoryRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var category = await categoryRepo.GetByIdAsync(id);
|
var category = await categoryRepo.GetByIdAsync(id);
|
||||||
if (category is null || !category.IsActive) return null;
|
if (category is null) return null;
|
||||||
if (category.ExternalErpId.HasValue) return null;
|
if (category.ExternalErpId.HasValue) return null;
|
||||||
category.Name = request.Name;
|
category.Name = request.Name;
|
||||||
|
category.IsActive = request.IsActive;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { categoryRepo.Update(category); await uow.CommitAsync(ct); }
|
try { categoryRepo.Update(category); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -44,11 +54,11 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
|
|||||||
|
|
||||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// Soft-delete only — never blocked by child sub-categories/items, same rule as
|
||||||
|
// Items/Stores/Orders. The row stays visible showing Inactive.
|
||||||
var category = await categoryRepo.GetByIdAsync(id);
|
var category = await categoryRepo.GetByIdAsync(id);
|
||||||
if (category is null || !category.IsActive) return false;
|
if (category is null || !category.IsActive) return false;
|
||||||
if (category.ExternalErpId.HasValue) return false;
|
if (category.ExternalErpId.HasValue) return false;
|
||||||
if (await categoryRepo.HasActiveSubCategoriesAsync(id)) return false;
|
|
||||||
if (await categoryRepo.HasActiveItemsAsync(id)) return false;
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { categoryRepo.Remove(category); await uow.CommitAsync(ct); }
|
try { categoryRepo.Remove(category); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -69,9 +79,10 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
|
|||||||
public async Task<SubCategoryResponse?> UpdateSubCategoryAsync(Guid id, UpdateSubCategoryRequest request, CancellationToken ct = default)
|
public async Task<SubCategoryResponse?> UpdateSubCategoryAsync(Guid id, UpdateSubCategoryRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var sub = await subCategoryRepo.GetByIdAsync(id);
|
var sub = await subCategoryRepo.GetByIdAsync(id);
|
||||||
if (sub is null || !sub.IsActive) return null;
|
if (sub is null) return null;
|
||||||
if (sub.ExternalErpId.HasValue) return null;
|
if (sub.ExternalErpId.HasValue) return null;
|
||||||
sub.Name = request.Name;
|
sub.Name = request.Name;
|
||||||
|
sub.IsActive = request.IsActive;
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { subCategoryRepo.Update(sub); await uow.CommitAsync(ct); }
|
try { subCategoryRepo.Update(sub); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
@@ -80,10 +91,10 @@ public class CategoryService(ICategoryRepository categoryRepo, ISubCategoryRepos
|
|||||||
|
|
||||||
public async Task<bool> DeleteSubCategoryAsync(Guid id, CancellationToken ct = default)
|
public async Task<bool> DeleteSubCategoryAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// Soft-delete only — never blocked by child items, same rule as the parent category.
|
||||||
var sub = await subCategoryRepo.GetByIdAsync(id);
|
var sub = await subCategoryRepo.GetByIdAsync(id);
|
||||||
if (sub is null || !sub.IsActive) return false;
|
if (sub is null || !sub.IsActive) return false;
|
||||||
if (sub.ExternalErpId.HasValue) return false;
|
if (sub.ExternalErpId.HasValue) return false;
|
||||||
if (await subCategoryRepo.HasActiveItemsAsync(id)) return false;
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { subCategoryRepo.Remove(sub); await uow.CommitAsync(ct); }
|
try { subCategoryRepo.Remove(sub); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/claims")]
|
||||||
|
public async Task<IActionResult> GetAll() =>
|
||||||
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
|
[HttpGet("api/claims/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/claims")]
|
||||||
|
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("api/claims/{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/claims/{id:guid}/settle")]
|
||||||
|
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? request.SettledByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "SettledByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.SettleAsync(id, request, userId.Value, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public record CreateClaimRequest(Guid AgencyUserId, string Details);
|
||||||
|
|
||||||
|
// Only editable while the claim is still Open — once settled, the settlement amount is a
|
||||||
|
// financial record and the claim details it was settled against should not silently change.
|
||||||
|
public record UpdateClaimRequest(Guid AgencyUserId, string Details);
|
||||||
|
|
||||||
|
public record SettleClaimRequest(
|
||||||
|
decimal SettlementAmount,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? SettledByUserId = null);
|
||||||
|
|
||||||
|
public record ClaimResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid AgencyUserId,
|
||||||
|
string Details,
|
||||||
|
ClaimStatus Status,
|
||||||
|
decimal? SettlementAmount,
|
||||||
|
DateTime? SettledAt,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public class ClaimRepository(AppDBContext context) : IClaimRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.Claim>> GetAllAsync() =>
|
||||||
|
await context.Claims
|
||||||
|
.Where(c => c.IsActive)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||||
|
await context.Claims
|
||||||
|
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Claim entity) =>
|
||||||
|
await context.Claims.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Claim entity) =>
|
||||||
|
context.Claims.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static ClaimResponse Map(Claim c) =>
|
||||||
|
new(c.Id, c.FK_AgencyUserId, c.Details, c.Status, c.SettlementAmount, c.SettledAt, c.IsActive, c.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<ClaimResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var c = await repo.GetByIdAsync(id);
|
||||||
|
if (c is null || !c.IsActive) return null;
|
||||||
|
return Map(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ClaimResponse>> GetAllAsync() =>
|
||||||
|
(await repo.GetAllAsync()).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||||
|
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<ClaimResponse> CreateAsync(CreateClaimRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Details))
|
||||||
|
throw new InvalidOperationException("Claim details are required.");
|
||||||
|
|
||||||
|
var claim = new Claim
|
||||||
|
{
|
||||||
|
FK_AgencyUserId = request.AgencyUserId,
|
||||||
|
Details = request.Details
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClaimResponse> UpdateAsync(Guid id, UpdateClaimRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var claim = await repo.GetByIdAsync(id);
|
||||||
|
if (claim is null || !claim.IsActive)
|
||||||
|
throw new InvalidOperationException("Claim not found.");
|
||||||
|
if (claim.Status == ClaimStatus.Settled)
|
||||||
|
throw new InvalidOperationException("Cannot edit a claim that has already been settled.");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Details))
|
||||||
|
throw new InvalidOperationException("Claim details are required.");
|
||||||
|
|
||||||
|
claim.FK_AgencyUserId = request.AgencyUserId;
|
||||||
|
claim.Details = request.Details;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(claim); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var claim = await repo.GetByIdAsync(id);
|
||||||
|
if (claim is null || !claim.IsActive)
|
||||||
|
throw new KeyNotFoundException("Claim not found.");
|
||||||
|
if (claim.Status == ClaimStatus.Settled)
|
||||||
|
throw new InvalidOperationException("Claim is already settled.");
|
||||||
|
if (request.SettlementAmount < 0)
|
||||||
|
throw new InvalidOperationException("Settlement amount cannot be negative.");
|
||||||
|
|
||||||
|
claim.Status = ClaimStatus.Settled;
|
||||||
|
claim.SettlementAmount = request.SettlementAmount;
|
||||||
|
claim.SettledByUserId = approverId;
|
||||||
|
claim.SettledAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public interface IClaimRepository
|
||||||
|
{
|
||||||
|
Task<Models.Claim?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Claim>> GetAllAsync();
|
||||||
|
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
|
||||||
|
Task AddAsync(Models.Claim entity);
|
||||||
|
void Update(Models.Claim entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api")]
|
||||||
|
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||||
|
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.GenerateAsync(rideId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||||
|
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||||
|
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||||
|
{
|
||||||
|
var result = await service.GetByRideIdAsync(rideId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("delivery-tasks/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
||||||
|
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.SetQuantitiesAsync(id, request, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
||||||
|
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmAsync(id, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
||||||
|
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.FailAsync(id, request.Note, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||||
|
|
||||||
|
public record DeliveryTaskResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid FK_RideId,
|
||||||
|
Guid FK_OrderId,
|
||||||
|
Guid StoreId,
|
||||||
|
string StoreName,
|
||||||
|
int Sequence,
|
||||||
|
DeliveryTaskStatus Status,
|
||||||
|
bool CustomerConfirmed,
|
||||||
|
DateTime? ConfirmedAt,
|
||||||
|
string? Note,
|
||||||
|
List<DeliveryTaskItemResponse> Items,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
|
||||||
|
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
|
||||||
|
|
||||||
|
public record FailTaskRequest(string Note);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
|
||||||
|
{
|
||||||
|
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.DeliveryTasks
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.Store)
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.OrderItems)
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
||||||
|
await context.DeliveryTasks
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.Store)
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.OrderItems)
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.Where(t => t.FK_RideId == rideId)
|
||||||
|
.OrderBy(t => t.Sequence)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
|
||||||
|
await context.DeliveryTasks
|
||||||
|
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
|
||||||
|
|
||||||
|
public async Task AddAsync(DeliveryTask task) =>
|
||||||
|
await context.DeliveryTasks.AddAsync(task);
|
||||||
|
|
||||||
|
public void Update(DeliveryTask task) =>
|
||||||
|
context.DeliveryTasks.Update(task);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.Ride;
|
||||||
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public class DeliveryTaskService(
|
||||||
|
IDeliveryTaskRepository repo,
|
||||||
|
IRideRepository rideRepo,
|
||||||
|
IOrderRepository orderRepo,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
|
new(t.Id,
|
||||||
|
t.FK_RideId,
|
||||||
|
t.FK_OrderId,
|
||||||
|
t.Order.FK_StoreId,
|
||||||
|
t.Order.Store?.StoreName ?? string.Empty,
|
||||||
|
t.Sequence,
|
||||||
|
t.Status,
|
||||||
|
t.CustomerConfirmed,
|
||||||
|
t.ConfirmedAt,
|
||||||
|
t.Note,
|
||||||
|
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
|
||||||
|
i.FK_ItemId,
|
||||||
|
i.Item?.Name ?? string.Empty,
|
||||||
|
i.Quantity,
|
||||||
|
i.QuantityFulfilled,
|
||||||
|
i.UnitPrice)).ToList(),
|
||||||
|
t.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
if (ride.Status != RideStatus.Active)
|
||||||
|
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
|
||||||
|
|
||||||
|
if (ride.RideType != RideType.Delivery)
|
||||||
|
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
|
||||||
|
|
||||||
|
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
|
||||||
|
|
||||||
|
var created = new List<DeliveryTask>();
|
||||||
|
int sequence = 1;
|
||||||
|
|
||||||
|
foreach (var order in orders)
|
||||||
|
{
|
||||||
|
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var task = new DeliveryTask
|
||||||
|
{
|
||||||
|
FK_RideId = rideId,
|
||||||
|
FK_OrderId = order.Id,
|
||||||
|
Sequence = sequence++,
|
||||||
|
Status = DeliveryTaskStatus.Pending
|
||||||
|
};
|
||||||
|
created.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created.Count == 0)
|
||||||
|
return new List<DeliveryTaskResponse>();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var task in created)
|
||||||
|
await repo.AddAsync(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByRideIdAsync(rideId);
|
||||||
|
return saved.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
||||||
|
{
|
||||||
|
var tasks = await repo.GetByRideIdAsync(rideId);
|
||||||
|
return tasks.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(id);
|
||||||
|
return task is null ? null : ToResponse(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
|
|
||||||
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Quantities can only be set for a Pending task");
|
||||||
|
|
||||||
|
foreach (var input in request.Items)
|
||||||
|
{
|
||||||
|
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
|
||||||
|
|
||||||
|
if (input.QuantityFulfilled < 0)
|
||||||
|
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
||||||
|
|
||||||
|
if (input.QuantityFulfilled > orderItem.Quantity)
|
||||||
|
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
||||||
|
|
||||||
|
orderItem.QuantityFulfilled = input.QuantityFulfilled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
|
|
||||||
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
||||||
|
|
||||||
|
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
|
||||||
|
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var vehicleId = ride.FK_VehicleId;
|
||||||
|
|
||||||
|
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
|
||||||
|
|
||||||
|
task.CustomerConfirmed = true;
|
||||||
|
task.ConfirmedAt = DateTime.Now;
|
||||||
|
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
||||||
|
|
||||||
|
var order = task.Order;
|
||||||
|
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
|
||||||
|
if (fullyDelivered)
|
||||||
|
order.FulfilledAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
|
||||||
|
{
|
||||||
|
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
|
||||||
|
var availableQuantity = vehicleBalance?.Quantity ?? 0;
|
||||||
|
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
|
||||||
|
|
||||||
|
if (availableQuantity < item.QuantityFulfilled)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
|
||||||
|
|
||||||
|
vehicleBalance!.Quantity -= item.QuantityFulfilled;
|
||||||
|
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(vehicleBalance);
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = item.FK_ItemId,
|
||||||
|
FromLocationId = vehicleId,
|
||||||
|
FromLocationType = LocationType.Vehicle,
|
||||||
|
ToLocationId = StockService.ExternalLocationId,
|
||||||
|
ToLocationType = LocationType.External,
|
||||||
|
Quantity = item.QuantityFulfilled,
|
||||||
|
Type = MovementType.Sale,
|
||||||
|
ReferenceId = task.FK_OrderId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(task);
|
||||||
|
orderRepo.Update(order);
|
||||||
|
|
||||||
|
if (fullyDelivered)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||||
|
?? throw new KeyNotFoundException("Sale not found");
|
||||||
|
sale.Status = SaleStatus.Completed;
|
||||||
|
sale.CompletedAt = DateTime.Now;
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
|
|
||||||
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Only a Pending task can be failed");
|
||||||
|
|
||||||
|
task.Status = DeliveryTaskStatus.Failed;
|
||||||
|
task.Note = note;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public interface IDeliveryTaskRepository
|
||||||
|
{
|
||||||
|
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
||||||
|
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
||||||
|
Task AddAsync(DeliveryTask task);
|
||||||
|
void Update(DeliveryTask task);
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using Distribution.Infra.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.DriverComplianceConfig;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/driver-compliance-config")]
|
|
||||||
[Authorize]
|
|
||||||
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet]
|
|
||||||
[Authorize(Policy = "compliance.manage")]
|
|
||||||
public async Task<IActionResult> Get()
|
|
||||||
{
|
|
||||||
var result = await service.GetAsync();
|
|
||||||
return result is null ? NoContent() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut]
|
|
||||||
[Authorize(Policy = "compliance.manage")]
|
|
||||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
var result = await service.UpsertAsync(request, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/driver-profiles")]
|
[Route("api/driver-profiles")]
|
||||||
[Authorize]
|
public class DriverProfileController(
|
||||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
DriverProfileService service,
|
||||||
|
DriverComplianceConfigService complianceService,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "driver_profiles.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);
|
||||||
@@ -17,7 +19,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.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);
|
||||||
@@ -25,15 +26,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "driver_profiles.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.update")]
|
|
||||||
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);
|
||||||
@@ -41,10 +51,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.deactivate")]
|
|
||||||
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);
|
||||||
return success ? NoContent() : NotFound();
|
return success ? NoContent() : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("compliance")]
|
||||||
|
public async Task<IActionResult> GetCompliance()
|
||||||
|
{
|
||||||
|
var result = await complianceService.GetAsync();
|
||||||
|
return result is null ? NoContent() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("compliance")]
|
||||||
|
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var result = await complianceService.UpsertAsync(request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
|
|||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
public record CreateDriverProfileRequest(
|
public record CreateDriverProfileRequest(
|
||||||
Guid FK_UserId,
|
Guid UserId,
|
||||||
string LicenseNumber,
|
string LicenseNumber,
|
||||||
DateTime LicenseExpiry,
|
DateTime LicenseExpiry,
|
||||||
DateTime MedicalClearanceExpiry);
|
DateTime MedicalClearanceExpiry);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.User;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
|
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
|
||||||
{
|
{
|
||||||
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
||||||
{
|
{
|
||||||
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
|
|||||||
|
|
||||||
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
|
var user = await userRepo.GetByIdAsync(request.UserId);
|
||||||
|
if (user is null)
|
||||||
|
throw new KeyNotFoundException($"User {request.UserId} not found");
|
||||||
|
|
||||||
|
var existing = await repo.GetByUserIdAsync(request.UserId);
|
||||||
if (existing is not null && existing.IsActive)
|
if (existing is not null && existing.IsActive)
|
||||||
throw new InvalidOperationException("An active driver profile already exists for this user");
|
throw new InvalidOperationException("An active driver profile already exists for this user");
|
||||||
|
|
||||||
var profile = new global::Distribution.Models.DriverProfile
|
var profile = new global::Distribution.Models.DriverProfile
|
||||||
{
|
{
|
||||||
FK_UserId = request.FK_UserId,
|
FK_UserId = request.UserId,
|
||||||
LicenseNumber = request.LicenseNumber,
|
LicenseNumber = request.LicenseNumber,
|
||||||
LicenseExpiry = request.LicenseExpiry,
|
LicenseExpiry = request.LicenseExpiry,
|
||||||
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/goods-receipts")]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? purchaseOrderId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/goods-receipts")]
|
||||||
|
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
||||||
|
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? request.ReceivedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(id, request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
|
||||||
|
|
||||||
|
public record CreateGrnRequest(
|
||||||
|
Guid WarehouseId,
|
||||||
|
List<GrnItemRequest> Items,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? ReceivedByUserId = null);
|
||||||
|
|
||||||
|
public record CreateFlatGrnRequest(
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
Guid WarehouseId,
|
||||||
|
List<GrnItemRequest> Items,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? ReceivedByUserId = null);
|
||||||
|
|
||||||
|
public record GrnItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderItemId,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
int AcceptedQuantity,
|
||||||
|
int RejectedQuantity);
|
||||||
|
|
||||||
|
public record GrnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
Guid ReceivedByUserId,
|
||||||
|
Guid WarehouseId,
|
||||||
|
string WarehouseName,
|
||||||
|
DateTime ReceivedAt,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<GrnItemResponse> Items);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||||
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.GoodsReceipt entity) =>
|
||||||
|
await context.GoodsReceipts.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive);
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.GoodsReceiptItems
|
||||||
|
.Include(i => i.PurchaseOrderItem)
|
||||||
|
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
|
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public class GrnService(
|
||||||
|
IGrnRepository repo,
|
||||||
|
IPoRepository poRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
|
||||||
|
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
|
||||||
|
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
|
||||||
|
|
||||||
|
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
||||||
|
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||||
|
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(purchaseOrderId);
|
||||||
|
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||||
|
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var gr = await repo.GetByIdAsync(id);
|
||||||
|
if (gr is null || !gr.IsActive) return null;
|
||||||
|
return Map(gr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
||||||
|
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
|
||||||
|
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await poRepo.GetByIdAsync(purchaseOrderId)
|
||||||
|
?? throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (!po.IsActive)
|
||||||
|
throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot receive goods against a purchase order in {po.Status} status.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||||
|
|
||||||
|
var goodsReceipt = new Models.GoodsReceipt
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = po.Id,
|
||||||
|
FK_ReceivedByUserId = receivedByUserId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
ReceivedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||||
|
|
||||||
|
if (poItem.IsCancelled)
|
||||||
|
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
|
||||||
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
||||||
|
|
||||||
|
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||||
|
|
||||||
|
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderItemId = poItem.Id,
|
||||||
|
AcceptedQuantity = line.AcceptedQuantity,
|
||||||
|
RejectedQuantity = line.RejectedQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||||
|
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
|
||||||
|
? PurchaseOrderStatus.Received
|
||||||
|
: PurchaseOrderStatus.PartiallyReceived;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(goodsReceipt);
|
||||||
|
|
||||||
|
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
||||||
|
{
|
||||||
|
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
|
||||||
|
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
||||||
|
if (balance is null)
|
||||||
|
{
|
||||||
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = poItem.FK_ItemId,
|
||||||
|
LocationId = request.WarehouseId,
|
||||||
|
LocationType = LocationType.Warehouse,
|
||||||
|
Quantity = line.AcceptedQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
balance.Quantity += line.AcceptedQuantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = poItem.FK_ItemId,
|
||||||
|
FromLocationId = StockService.ExternalLocationId,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
|
ToLocationId = request.WarehouseId,
|
||||||
|
ToLocationType = LocationType.Warehouse,
|
||||||
|
Quantity = line.AcceptedQuantity,
|
||||||
|
Type = MovementType.PurchaseReceipt,
|
||||||
|
ReferenceId = goodsReceipt.Id,
|
||||||
|
CreatedBy = receivedByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
poRepo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public interface IGrnRepository
|
||||||
|
{
|
||||||
|
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? purchaseOrderId);
|
||||||
|
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
|
Task AddAsync(Models.GoodsReceipt entity);
|
||||||
|
|
||||||
|
/// <summary>True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard).</summary>
|
||||||
|
Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId);
|
||||||
|
|
||||||
|
/// <summary>Sum of AcceptedQuantity per ItemId across all receipts for a Po.</summary>
|
||||||
|
Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId);
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Item;
|
namespace Distribution.Features.Item;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/items")]
|
[Route("api/items")]
|
||||||
[Authorize]
|
|
||||||
public class ItemController(ItemService service) : ControllerBase
|
public class ItemController(ItemService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? categoryId,
|
[FromQuery] Guid? categoryId,
|
||||||
[FromQuery] Guid? subCategoryId,
|
[FromQuery] Guid? subCategoryId,
|
||||||
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.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);
|
||||||
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/uom-conversions")]
|
[HttpPut("{id:guid}/uom-conversions")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public record UpdateItemRequest(
|
|||||||
Guid? SubCategoryId,
|
Guid? SubCategoryId,
|
||||||
Guid? BrandId,
|
Guid? BrandId,
|
||||||
Guid BaseUomId,
|
Guid BaseUomId,
|
||||||
decimal? SalePrice);
|
decimal? SalePrice,
|
||||||
|
bool IsActive = true);
|
||||||
|
|
||||||
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ public class ItemRepository(AppDBContext context) : IItemRepository
|
|||||||
|
|
||||||
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
|
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
|
||||||
{
|
{
|
||||||
|
// Deleted items stay IsActive=false but remain visible with an Inactive status —
|
||||||
|
// Delete never removes the row, only marks it (same rule as Orders/Stores).
|
||||||
var query = context.Items
|
var query = context.Items
|
||||||
.Include(i => i.Category)
|
.Include(i => i.Category)
|
||||||
.Include(i => i.SubCategory)
|
.Include(i => i.SubCategory)
|
||||||
.Include(i => i.Brand)
|
.Include(i => i.Brand)
|
||||||
.Include(i => i.BaseUom)
|
.Include(i => i.BaseUom)
|
||||||
.Where(i => i.IsActive);
|
.AsQueryable();
|
||||||
|
|
||||||
if (categoryId.HasValue) query = query.Where(i => i.FK_CategoryId == categoryId);
|
if (categoryId.HasValue) query = query.Where(i => i.FK_CategoryId == categoryId);
|
||||||
if (subCategoryId.HasValue) query = query.Where(i => i.FK_SubCategoryId == subCategoryId);
|
if (subCategoryId.HasValue) query = query.Where(i => i.FK_SubCategoryId == subCategoryId);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
public async Task<ItemDetailResponse?> GetByIdAsync(Guid id)
|
public async Task<ItemDetailResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var item = await repo.GetByIdAsync(id);
|
var item = await repo.GetByIdAsync(id);
|
||||||
if (item is null || !item.IsActive) return null;
|
if (item is null) return null;
|
||||||
return MapDetail(item);
|
return MapDetail(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
|||||||
public async Task<ItemDetailResponse?> UpdateAsync(Guid id, UpdateItemRequest request, CancellationToken ct = default)
|
public async Task<ItemDetailResponse?> UpdateAsync(Guid id, UpdateItemRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var item = await repo.GetByIdAsync(id);
|
var item = await repo.GetByIdAsync(id);
|
||||||
if (item is null || !item.IsActive) return null;
|
if (item is null) return null;
|
||||||
if (item.SyncSource == Models.SyncSource.Erp) return null;
|
if (item.SyncSource == Models.SyncSource.Erp) return null;
|
||||||
|
|
||||||
item.Name = request.Name;
|
item.Name = request.Name;
|
||||||
@@ -84,6 +84,7 @@ 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.IsActive = request.IsActive;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(item); await uow.CommitAsync(ct); }
|
try { repo.Update(item); await uow.CommitAsync(ct); }
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
|
public interface ILoadingSheetRepository
|
||||||
|
{
|
||||||
|
Task<LoadingSheet?> GetByIdAsync(Guid id);
|
||||||
|
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
|
||||||
|
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
|
||||||
|
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
|
||||||
|
Task AddAsync(LoadingSheet sheet);
|
||||||
|
void Update(LoadingSheet sheet);
|
||||||
|
void RemoveItems(IEnumerable<LoadingSheetItem> items);
|
||||||
|
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/loading-sheets")]
|
||||||
|
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? rideId,
|
||||||
|
[FromQuery] LoadingSheetStatus? status,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(rideId, status, page, pageSize);
|
||||||
|
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(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/items")]
|
||||||
|
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.SetItemsAsync(id, request, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/confirm-dispatch")]
|
||||||
|
public async Task<IActionResult> ConfirmDispatch(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 }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/receipt-items")]
|
||||||
|
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.SetReceiptItemsAsync(id, request, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/confirm-receipt")]
|
||||||
|
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/resolve")]
|
||||||
|
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ResolveAsync(id, request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/dispatch")]
|
||||||
|
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/receipt")]
|
||||||
|
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/resolve")]
|
||||||
|
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PatchResolveAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
|
public record LoadingSheetResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid FK_RideId,
|
||||||
|
Guid FK_SourceWarehouseId,
|
||||||
|
string WarehouseName,
|
||||||
|
LoadingSheetStatus Status,
|
||||||
|
DateTime? DispatchConfirmedAt,
|
||||||
|
Guid? DispatchConfirmedBy,
|
||||||
|
DateTime? ReceiptConfirmedAt,
|
||||||
|
Guid? ReceiptConfirmedBy,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<LoadingSheetItemResponse> Items);
|
||||||
|
|
||||||
|
public record LoadingSheetItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid FK_ItemId,
|
||||||
|
string ItemName,
|
||||||
|
decimal QuantityDispatched,
|
||||||
|
decimal? QuantityReceived,
|
||||||
|
decimal? QuantityResolved);
|
||||||
|
|
||||||
|
public record CreateLoadingSheetRequest(Guid FK_RideId);
|
||||||
|
|
||||||
|
public record SetItemsRequest(List<SetItemInput> Items);
|
||||||
|
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
|
||||||
|
|
||||||
|
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
|
||||||
|
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
|
||||||
|
|
||||||
|
public record ConfirmReceiptRequest(int Meterstart);
|
||||||
|
|
||||||
|
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
||||||
|
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
||||||
|
|
||||||
|
// Combined PATCH endpoints used by the frontend
|
||||||
|
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
|
||||||
|
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
|
public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetRepository
|
||||||
|
{
|
||||||
|
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.LoadingSheets
|
||||||
|
.Include(s => s.SourceWarehouse)
|
||||||
|
.Include(s => s.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
|
|
||||||
|
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
|
||||||
|
await context.LoadingSheets
|
||||||
|
.FirstOrDefaultAsync(s =>
|
||||||
|
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) =>
|
||||||
|
await context.LoadingSheets
|
||||||
|
.Include(s => s.SourceWarehouse)
|
||||||
|
.Where(s =>
|
||||||
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
|
(status == null || s.Status == status))
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
|
||||||
|
await context.LoadingSheets
|
||||||
|
.CountAsync(s =>
|
||||||
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
|
(status == null || s.Status == status));
|
||||||
|
|
||||||
|
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
||||||
|
{
|
||||||
|
var groups = await context.OrderItems
|
||||||
|
.Where(oi => oi.IsActive
|
||||||
|
&& oi.Order.IsActive
|
||||||
|
&& oi.Order.Store.FK_RouteId == routeId
|
||||||
|
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
|
||||||
|
.GroupBy(oi => oi.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
|
||||||
|
.ToListAsync();
|
||||||
|
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(LoadingSheet sheet) =>
|
||||||
|
await context.LoadingSheets.AddAsync(sheet);
|
||||||
|
|
||||||
|
public void Update(LoadingSheet sheet) =>
|
||||||
|
context.LoadingSheets.Update(sheet);
|
||||||
|
|
||||||
|
public void RemoveItems(IEnumerable<LoadingSheetItem> items) =>
|
||||||
|
context.LoadingSheetItems.RemoveRange(items);
|
||||||
|
|
||||||
|
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
|
||||||
|
await context.LoadingSheetItems.AddRangeAsync(items);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
using Distribution.Features.Ride;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
|
public class LoadingSheetService(
|
||||||
|
ILoadingSheetRepository repo,
|
||||||
|
IRideRepository rideRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(rideId, status);
|
||||||
|
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
|
||||||
|
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id);
|
||||||
|
return sheet is null ? null : ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
if (ride.Status != RideStatus.Open)
|
||||||
|
throw new InvalidOperationException("A loading sheet can only be created for an Open ride");
|
||||||
|
|
||||||
|
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
|
||||||
|
if (existing is not null)
|
||||||
|
throw new InvalidOperationException("This ride already has an active loading sheet");
|
||||||
|
|
||||||
|
var sheet = new LoadingSheet
|
||||||
|
{
|
||||||
|
FK_RideId = ride.Id,
|
||||||
|
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
|
||||||
|
Status = LoadingSheetStatus.Pending
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ride.RideType == RideType.Delivery)
|
||||||
|
{
|
||||||
|
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
|
||||||
|
foreach (var (itemId, qty) in orderItems)
|
||||||
|
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(sheet.Id);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> SetItemsAsync(Guid id, SetItemsRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.RemoveItems(sheet.Items);
|
||||||
|
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
|
||||||
|
{
|
||||||
|
FK_LoadingSheetId = sheet.Id,
|
||||||
|
FK_ItemId = i.FK_ItemId,
|
||||||
|
QuantityDispatched = i.Quantity
|
||||||
|
}));
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(sheet.Id);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.Pending)
|
||||||
|
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
|
||||||
|
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
|
||||||
|
|
||||||
|
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
|
||||||
|
sheet.DispatchConfirmedAt = DateTime.Now;
|
||||||
|
sheet.DispatchConfirmedBy = userId;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(sheet); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||||
|
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
|
||||||
|
|
||||||
|
foreach (var input in request.Items)
|
||||||
|
{
|
||||||
|
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
|
||||||
|
item.QuantityReceived = input.QuantityReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(sheet); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||||
|
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
|
||||||
|
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (hasDiscrepancy)
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = request.Meterstart;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
||||||
|
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var input in request.Items)
|
||||||
|
{
|
||||||
|
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
|
||||||
|
if (input.QuantityResolved < 0)
|
||||||
|
throw new InvalidOperationException("Resolved quantity cannot be negative");
|
||||||
|
item.QuantityResolved = input.QuantityResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
|
||||||
|
throw new InvalidOperationException("All items must have a resolved quantity");
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
sheet.Status = LoadingSheetStatus.Resolved;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = request.Meterstart;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||||
|
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var input in items)
|
||||||
|
{
|
||||||
|
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
|
||||||
|
sheetItem.QuantityReceived = input.QuantityReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (hasDiscrepancy)
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = 0;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
||||||
|
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var item in sheet.Items)
|
||||||
|
{
|
||||||
|
if (!item.QuantityReceived.HasValue)
|
||||||
|
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
|
||||||
|
item.QuantityResolved = item.QuantityReceived.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
sheet.Status = LoadingSheetStatus.Resolved;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = 0;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
||||||
|
{
|
||||||
|
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
||||||
|
{
|
||||||
|
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
|
||||||
|
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
|
||||||
|
if (warehouseBalance.Quantity < qty)
|
||||||
|
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
|
||||||
|
|
||||||
|
warehouseBalance.Quantity -= qty;
|
||||||
|
warehouseBalance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(warehouseBalance);
|
||||||
|
|
||||||
|
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
|
||||||
|
if (vehicleBalance is null)
|
||||||
|
{
|
||||||
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = itemId,
|
||||||
|
LocationId = vehicleId,
|
||||||
|
LocationType = LocationType.Vehicle,
|
||||||
|
Quantity = qty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
vehicleBalance.Quantity += qty;
|
||||||
|
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(vehicleBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = itemId,
|
||||||
|
FromLocationId = sheet.FK_SourceWarehouseId,
|
||||||
|
FromLocationType = LocationType.Warehouse,
|
||||||
|
ToLocationId = vehicleId,
|
||||||
|
ToLocationType = LocationType.Vehicle,
|
||||||
|
Quantity = qty,
|
||||||
|
Type = MovementType.Load,
|
||||||
|
ReferenceId = sheet.Id,
|
||||||
|
CreatedBy = userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
|
||||||
|
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
|
||||||
|
s.SourceWarehouse?.Name ?? string.Empty,
|
||||||
|
s.Status,
|
||||||
|
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
|
||||||
|
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
|
||||||
|
s.CreatedAt,
|
||||||
|
s.Items.Select(i => new LoadingSheetItemResponse(
|
||||||
|
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
|
||||||
|
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ public interface IOrderRepository
|
|||||||
{
|
{
|
||||||
Task<Models.Order?> GetByIdAsync(Guid id);
|
Task<Models.Order?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.Order>> GetAllAsync();
|
Task<List<Models.Order>> GetAllAsync();
|
||||||
|
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
|
||||||
Task AddAsync(Models.Order entity);
|
Task AddAsync(Models.Order entity);
|
||||||
void Update(Models.Order entity);
|
void Update(Models.Order entity);
|
||||||
void Remove(Models.Order entity);
|
void Remove(Models.Order entity);
|
||||||
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
||||||
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
||||||
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
||||||
|
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/orders")]
|
[Route("api/orders")]
|
||||||
[Authorize]
|
|
||||||
public class OrderController(OrderService service) : ControllerBase
|
public class OrderController(OrderService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.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);
|
||||||
@@ -22,31 +18,35 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("store/{storeId:guid}")]
|
[HttpGet("store/{storeId:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByStoreIdAsync(storeId);
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
return result is null ? Forbid() : Ok(result);
|
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "orders.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return result is null ? StatusCode(StatusCodes.Status403Forbidden) : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
public record CreateOrderRequest(Guid SaleId);
|
||||||
|
|
||||||
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
|
public record UpdateOrderRequest(OrderStatus Status);
|
||||||
|
|
||||||
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
|
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||||
|
|
||||||
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
|
|
||||||
|
|
||||||
public record OrderResponse(
|
public record OrderResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
Guid SaleId,
|
||||||
DateTime OrderDate,
|
DateTime OrderDate,
|
||||||
Guid StoreId,
|
Guid StoreId,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
OrderStatus Status,
|
||||||
|
DateTime? FulfilledAt,
|
||||||
List<OrderItemResponse> Items,
|
List<OrderItemResponse> Items,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
public async Task<List<Models.Order>> GetAllAsync() =>
|
public async Task<List<Models.Order>> GetAllAsync() =>
|
||||||
await context.Orders.ToListAsync();
|
await context.Orders.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
|
||||||
|
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
|
||||||
|
|
||||||
public async Task AddAsync(Models.Order entity) =>
|
public async Task AddAsync(Models.Order entity) =>
|
||||||
await context.Orders.AddAsync(entity);
|
await context.Orders.AddAsync(entity);
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
|
|
||||||
public void Remove(Models.Order entity)
|
public void Remove(Models.Order entity)
|
||||||
{
|
{
|
||||||
|
entity.Status = Models.OrderStatus.Cancelled;
|
||||||
entity.IsActive = false;
|
entity.IsActive = false;
|
||||||
context.Orders.Update(entity);
|
context.Orders.Update(entity);
|
||||||
}
|
}
|
||||||
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
|
|
||||||
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
||||||
await context.OrderItems.AddRangeAsync(items);
|
await context.OrderItems.AddRangeAsync(items);
|
||||||
|
|
||||||
|
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
|
||||||
|
await context.Orders
|
||||||
|
.Include(o => o.Store)
|
||||||
|
.Include(o => o.OrderItems)
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.Where(o => o.IsActive
|
||||||
|
&& o.Store.FK_RouteId == routeId
|
||||||
|
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
using Distribution.Features.Sale;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
@@ -12,17 +13,18 @@ public class OrderService(
|
|||||||
IOrderRepository repo,
|
IOrderRepository repo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IScopeAccessService scopeAccess,
|
IScopeAccessService scopeAccess)
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
{
|
||||||
private const int GlobalScopeRoleLevel = 50;
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
|
|
||||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||||
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
|
||||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
|
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
||||||
o.IsActive, o.CreatedAt);
|
o.IsActive, o.CreatedAt);
|
||||||
|
|
||||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||||
@@ -33,32 +35,11 @@ public class OrderService(
|
|||||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
|
|
||||||
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
|
|
||||||
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
|
|
||||||
/// can be resolved, or if the requested price is outside the allowed band.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
|
|
||||||
{
|
|
||||||
var item = await itemRepo.GetByIdAsync(itemId);
|
|
||||||
if (item is null) return null;
|
|
||||||
var regionId = store.Route?.FK_RegionId;
|
|
||||||
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
|
|
||||||
if (resolution is null) return null;
|
|
||||||
|
|
||||||
if (!requestedUnitPrice.HasValue) return resolution.Price;
|
|
||||||
|
|
||||||
var min = resolution.MinPrice ?? resolution.Price;
|
|
||||||
var max = resolution.Price;
|
|
||||||
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
|
|
||||||
|
|
||||||
return requestedUnitPrice.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<OrderResponse>> GetAllAsync()
|
public async Task<List<OrderResponse>> GetAllAsync()
|
||||||
{
|
{
|
||||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
// Deleted orders stay IsActive=false but remain visible with a Cancelled status —
|
||||||
|
// Delete never removes the row from the list, only marks it cancelled.
|
||||||
|
var orders = (await repo.GetAllAsync()).ToList();
|
||||||
if (!HasGlobalScope && currentUser.UserId is not null)
|
if (!HasGlobalScope && currentUser.UserId is not null)
|
||||||
{
|
{
|
||||||
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
|
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
|
||||||
@@ -97,63 +78,43 @@ public class OrderService(
|
|||||||
|
|
||||||
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
var sale = await saleRepo.GetByIdAsync(request.SaleId);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||||
|
|
||||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
|
||||||
if (store is null || !store.IsActive) return null;
|
?? throw new InvalidOperationException("Orders are created automatically from sales");
|
||||||
if (store.KycStatus != KycStatus.Verified)
|
|
||||||
{
|
|
||||||
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
|
||||||
if (request.TotalAmount > threshold) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var order = new Models.Order
|
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
|
||||||
{
|
return ToResponse(existingOrder, items);
|
||||||
FK_StoreId = request.StoreId,
|
|
||||||
OrderDate = request.OrderDate,
|
|
||||||
TotalAmount = request.TotalAmount
|
|
||||||
};
|
|
||||||
var orderItems = new List<Models.OrderItem>();
|
|
||||||
foreach (var i in request.Items)
|
|
||||||
{
|
|
||||||
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
|
||||||
if (unitPrice is null) return null;
|
|
||||||
|
|
||||||
orderItems.Add(new Models.OrderItem
|
|
||||||
{
|
|
||||||
FK_OrderId = order.Id,
|
|
||||||
FK_ItemId = i.ItemId,
|
|
||||||
Quantity = i.Quantity,
|
|
||||||
UnitPrice = unitPrice.Value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await repo.AddAsync(order);
|
|
||||||
await repo.AddItemsAsync(orderItems);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await uow.RollbackAsync(ct);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
return ToResponse(order, orderItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// TEMPORARY: see DeleteAsync/SaleService — store-scope enforcement depends on the broken
|
||||||
|
// auth middleware (testbug.md #28) and would reject every update. Skipped for now.
|
||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return null;
|
if (order is null || !order.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
|
||||||
order.OrderDate = request.OrderDate;
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||||
order.TotalAmount = request.TotalAmount;
|
?? throw new InvalidOperationException("Sale not found for this order");
|
||||||
|
|
||||||
|
order.Status = request.Status;
|
||||||
|
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||||
|
|
||||||
|
sale.Status = request.Status switch
|
||||||
|
{
|
||||||
|
OrderStatus.Fulfilled => SaleStatus.Completed,
|
||||||
|
OrderStatus.Cancelled => SaleStatus.Cancelled,
|
||||||
|
_ => SaleStatus.Pending
|
||||||
|
};
|
||||||
|
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Update(order);
|
repo.Update(order);
|
||||||
|
saleRepo.Update(sale);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -161,19 +122,30 @@ public class OrderService(
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = await repo.GetItemsByOrderIdAsync(id);
|
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||||
return ToResponse(order, items);
|
return ToResponse(order, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// TEMPORARY: see SaleService — store-scope enforcement depends on the broken auth
|
||||||
|
// middleware (testbug.md #28) and would reject every delete. Skipped for now.
|
||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return false;
|
if (order is null || !order.IsActive) return false;
|
||||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
|
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Remove(order);
|
repo.Remove(order);
|
||||||
|
if (sale is not null)
|
||||||
|
{
|
||||||
|
sale.Status = SaleStatus.Cancelled;
|
||||||
|
sale.CompletedAt = null;
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
}
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/payments")]
|
||||||
|
public class FlatPaymentController(PaymentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? saleId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
|
||||||
|
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(CreateFlatPaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inner = new CreatePaymentRequest(request.Amount, request.Method, request.CollectedByUserId);
|
||||||
|
var result = await service.CreateAsync(request.SaleId, inner, ct);
|
||||||
|
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public interface IPaymentRepository
|
||||||
|
{
|
||||||
|
Task<Models.Payment?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
|
||||||
|
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||||
|
Task AddAsync(Models.Payment payment);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/sales/{saleId:guid}/payments")]
|
||||||
|
public class PaymentController(PaymentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetBySale(Guid saleId)
|
||||||
|
{
|
||||||
|
var result = await service.GetBySaleIdAsync(saleId);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(saleId, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public record CreatePaymentRequest(
|
||||||
|
decimal Amount,
|
||||||
|
PaymentMethod Method,
|
||||||
|
// TEMPORARY: auth middleware doesn't resolve a logged-in user yet — see testbug.md #28.
|
||||||
|
Guid? CollectedByUserId = null);
|
||||||
|
|
||||||
|
public record CreateFlatPaymentRequest(
|
||||||
|
Guid SaleId,
|
||||||
|
decimal Amount,
|
||||||
|
PaymentMethod Method,
|
||||||
|
Guid? CollectedByUserId = null);
|
||||||
|
|
||||||
|
public record PaymentResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid SaleId,
|
||||||
|
Guid StoreId,
|
||||||
|
decimal Amount,
|
||||||
|
PaymentMethod Method,
|
||||||
|
DateTime CollectedAt,
|
||||||
|
Guid CollectedBy,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
||||||
|
|
||||||
|
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.IsActive
|
||||||
|
&& (storeId == null || p.FK_StoreId == storeId)
|
||||||
|
&& (saleId == null || p.FK_SaleId == saleId))
|
||||||
|
.OrderByDescending(p => p.CollectedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.IsActive
|
||||||
|
&& (storeId == null || p.FK_StoreId == storeId)
|
||||||
|
&& (saleId == null || p.FK_SaleId == saleId))
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||||
|
.OrderBy(p => p.CollectedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Payment payment) =>
|
||||||
|
await context.Payments.AddAsync(payment);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public class PaymentService(
|
||||||
|
IPaymentRepository repo,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||||
|
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(storeId, saleId);
|
||||||
|
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
|
||||||
|
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var payment = await repo.GetByIdAsync(id);
|
||||||
|
return payment is null ? null : ToResponse(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
|
||||||
|
var payments = await repo.GetBySaleIdAsync(saleId);
|
||||||
|
return payments.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (sale.Status == SaleStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
|
||||||
|
|
||||||
|
// 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.CollectedByUserId
|
||||||
|
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
|
||||||
|
|
||||||
|
var payment = new Models.Payment
|
||||||
|
{
|
||||||
|
FK_SaleId = saleId,
|
||||||
|
FK_StoreId = sale.FK_StoreId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
Method = request.Method,
|
||||||
|
CollectedAt = DateTime.Now,
|
||||||
|
CollectedBy = userId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(payment);
|
||||||
|
|
||||||
|
var allPayments = await repo.GetBySaleIdAsync(saleId);
|
||||||
|
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||||
|
|
||||||
|
sale.PaymentStatus = total == 0
|
||||||
|
? PaymentStatus.Pending
|
||||||
|
: total < sale.TotalAmount
|
||||||
|
? PaymentStatus.PartiallyPaid
|
||||||
|
: PaymentStatus.Paid;
|
||||||
|
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(payment);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.PriceList;
|
namespace Distribution.Features.PriceList;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/price-lists")]
|
[Route("api/price-lists")]
|
||||||
[Authorize]
|
|
||||||
public class PriceListController(PriceListService service) : ControllerBase
|
public class PriceListController(PriceListService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
|
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
|
||||||
Ok(await service.GetAllAsync(regionId));
|
Ok(await service.GetAllAsync(regionId));
|
||||||
|
|
||||||
|
[HttpGet("resolve")]
|
||||||
|
public async Task<IActionResult> Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate)
|
||||||
|
{
|
||||||
|
var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
@@ -63,8 +68,15 @@ public class PriceListController(PriceListService service) : ControllerBase
|
|||||||
|
|
||||||
[HttpDelete("{id:guid}/items/{sku}")]
|
[HttpDelete("{id:guid}/items/{sku}")]
|
||||||
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
|
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.RemoveItemAsync(id, sku, ct);
|
var result = await service.RemoveItemAsync(id, sku, ct);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,30 @@ namespace Distribution.Features.PriceList;
|
|||||||
|
|
||||||
public record CreatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
|
public record CreatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
|
||||||
|
|
||||||
public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
|
public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo, bool IsActive);
|
||||||
|
|
||||||
public record UpsertPriceListItemRequest(decimal Price, decimal? MinPrice = null);
|
public record UpsertPriceListItemRequest(
|
||||||
|
decimal Price,
|
||||||
|
decimal? MinPrice = null,
|
||||||
|
decimal? RetailPrice = null,
|
||||||
|
decimal? AgencyMarginPercent = null);
|
||||||
|
|
||||||
public record PriceListItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, decimal Price, decimal? MinPrice);
|
public record PriceListItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
decimal Price,
|
||||||
|
decimal? MinPrice,
|
||||||
|
decimal? RetailPrice,
|
||||||
|
decimal? AgencyMarginPercent);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A resolved price with its negotiable floor. MinPrice null = no negotiation, Price is fixed.
|
/// A resolved price with its negotiable floor, plus the informational retail price and agency
|
||||||
|
/// margin when the resolution came from an actual PriceListItem (both null on the Item.SalePrice
|
||||||
|
/// fallback, since neither is tracked at the item level).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record PriceResolution(decimal Price, decimal? MinPrice);
|
public record PriceResolution(decimal Price, decimal? MinPrice, decimal? RetailPrice, decimal? AgencyMarginPercent);
|
||||||
|
|
||||||
public record PriceListResponse(
|
public record PriceListResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ public class PriceListRepository(AppDBContext context) : IPriceListRepository
|
|||||||
|
|
||||||
public async Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null)
|
public async Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null)
|
||||||
{
|
{
|
||||||
var query = context.PriceLists
|
var query = context.PriceLists.Include(pl => pl.Region).AsQueryable();
|
||||||
.Include(pl => pl.Region)
|
|
||||||
.Where(pl => pl.IsActive);
|
|
||||||
|
|
||||||
if (regionId.HasValue) query = query.Where(pl => pl.FK_RegionId == regionId);
|
if (regionId.HasValue) query = query.Where(pl => pl.FK_RegionId == regionId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Store;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.PriceList;
|
namespace Distribution.Features.PriceList;
|
||||||
|
|
||||||
public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IUnitOfWork uow)
|
public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IStoreRepository storeRepo, IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static PriceListItemResponse MapItem(Models.PriceListItem i) =>
|
private static PriceListItemResponse MapItem(Models.PriceListItem i) =>
|
||||||
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice);
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice, i.RetailPrice, i.AgencyMarginPercent);
|
||||||
|
|
||||||
private static PriceListResponse MapDetail(Models.PriceList pl) =>
|
private static PriceListResponse MapDetail(Models.PriceList pl) =>
|
||||||
new(pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name,
|
new(pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name,
|
||||||
@@ -24,7 +25,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
public async Task<PriceListResponse?> GetByIdAsync(Guid id)
|
public async Task<PriceListResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var pl = await repo.GetByIdAsync(id);
|
var pl = await repo.GetByIdAsync(id);
|
||||||
if (pl is null || !pl.IsActive) return null;
|
if (pl is null) return null;
|
||||||
return MapDetail(pl);
|
return MapDetail(pl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
public async Task<PriceListResponse?> UpdateAsync(Guid id, UpdatePriceListRequest request, CancellationToken ct = default)
|
public async Task<PriceListResponse?> UpdateAsync(Guid id, UpdatePriceListRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var priceList = await repo.GetByIdAsync(id);
|
var priceList = await repo.GetByIdAsync(id);
|
||||||
if (priceList is null || !priceList.IsActive) return null;
|
if (priceList is null) return null;
|
||||||
|
|
||||||
foreach (var item in priceList.Items.Where(i => i.IsActive))
|
foreach (var item in priceList.Items.Where(i => i.IsActive))
|
||||||
{
|
{
|
||||||
@@ -61,6 +62,7 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
priceList.FK_RegionId = request.RegionId;
|
priceList.FK_RegionId = request.RegionId;
|
||||||
priceList.EffectiveFrom = request.EffectiveFrom;
|
priceList.EffectiveFrom = request.EffectiveFrom;
|
||||||
priceList.EffectiveTo = request.EffectiveTo;
|
priceList.EffectiveTo = request.EffectiveTo;
|
||||||
|
priceList.IsActive = request.IsActive;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Update(priceList); await uow.CommitAsync(ct); }
|
try { repo.Update(priceList); await uow.CommitAsync(ct); }
|
||||||
@@ -90,7 +92,9 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
public async Task<PriceListResponse?> UpsertItemAsync(Guid priceListId, string sku, UpsertPriceListItemRequest request, CancellationToken ct = default)
|
public async Task<PriceListResponse?> UpsertItemAsync(Guid priceListId, string sku, UpsertPriceListItemRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var priceList = await repo.GetByIdAsync(priceListId);
|
var priceList = await repo.GetByIdAsync(priceListId);
|
||||||
if (priceList is null || !priceList.IsActive) return null;
|
if (priceList is null) return null;
|
||||||
|
if (!priceList.IsActive)
|
||||||
|
throw new InvalidOperationException("Cannot price items on an inactive price list. Reactivate it first.");
|
||||||
|
|
||||||
var item = await itemRepo.GetBySkuAsync(sku);
|
var item = await itemRepo.GetBySkuAsync(sku);
|
||||||
if (item is null) return null;
|
if (item is null) return null;
|
||||||
@@ -99,6 +103,13 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"MinPrice ({request.MinPrice}) cannot exceed Price ({request.Price}) for item '{sku}'.");
|
$"MinPrice ({request.MinPrice}) cannot exceed Price ({request.Price}) for item '{sku}'.");
|
||||||
|
|
||||||
|
if (request.RetailPrice.HasValue && request.RetailPrice.Value < request.Price)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"RetailPrice ({request.RetailPrice}) cannot be less than Price ({request.Price}) for item '{sku}' — the store would sell at a loss.");
|
||||||
|
|
||||||
|
if (request.AgencyMarginPercent is < 0 or > 100)
|
||||||
|
throw new InvalidOperationException("AgencyMarginPercent must be between 0 and 100.");
|
||||||
|
|
||||||
if (await repo.HasOverlapAsync(item.Id, priceList.FK_RegionId, priceList.EffectiveFrom, priceList.EffectiveTo, priceListId))
|
if (await repo.HasOverlapAsync(item.Id, priceList.FK_RegionId, priceList.EffectiveFrom, priceList.EffectiveTo, priceListId))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Overlapping price list already exists for item '{sku}' in the same region for this date range.");
|
$"Overlapping price list already exists for item '{sku}' in the same region for this date range.");
|
||||||
@@ -115,13 +126,17 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
FK_PriceListId = priceListId,
|
FK_PriceListId = priceListId,
|
||||||
FK_ItemId = item.Id,
|
FK_ItemId = item.Id,
|
||||||
Price = request.Price,
|
Price = request.Price,
|
||||||
MinPrice = request.MinPrice
|
MinPrice = request.MinPrice,
|
||||||
|
RetailPrice = request.RetailPrice,
|
||||||
|
AgencyMarginPercent = request.AgencyMarginPercent
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
existing.Price = request.Price;
|
existing.Price = request.Price;
|
||||||
existing.MinPrice = request.MinPrice;
|
existing.MinPrice = request.MinPrice;
|
||||||
|
existing.RetailPrice = request.RetailPrice;
|
||||||
|
existing.AgencyMarginPercent = request.AgencyMarginPercent;
|
||||||
repo.UpdateItem(existing);
|
repo.UpdateItem(existing);
|
||||||
}
|
}
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
@@ -134,7 +149,9 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
public async Task<PriceListResponse?> RemoveItemAsync(Guid priceListId, string sku, CancellationToken ct = default)
|
public async Task<PriceListResponse?> RemoveItemAsync(Guid priceListId, string sku, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var priceList = await repo.GetByIdAsync(priceListId);
|
var priceList = await repo.GetByIdAsync(priceListId);
|
||||||
if (priceList is null || !priceList.IsActive) return null;
|
if (priceList is null) return null;
|
||||||
|
if (!priceList.IsActive)
|
||||||
|
throw new InvalidOperationException("Cannot remove items from an inactive price list. Reactivate it first.");
|
||||||
|
|
||||||
var item = await itemRepo.GetBySkuAsync(sku);
|
var item = await itemRepo.GetBySkuAsync(sku);
|
||||||
if (item is null) return null;
|
if (item is null) return null;
|
||||||
@@ -160,12 +177,36 @@ public class PriceListService(IPriceListRepository repo, IItemRepository itemRep
|
|||||||
if (regionId.HasValue)
|
if (regionId.HasValue)
|
||||||
{
|
{
|
||||||
var regionMatches = await repo.GetActiveItemsAsync(itemId, regionId, orderDate);
|
var regionMatches = await repo.GetActiveItemsAsync(itemId, regionId, orderDate);
|
||||||
if (regionMatches.Count > 0) return new PriceResolution(regionMatches[0].Price, regionMatches[0].MinPrice);
|
if (regionMatches.Count > 0)
|
||||||
|
{
|
||||||
|
var m = regionMatches[0];
|
||||||
|
return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate);
|
var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate);
|
||||||
if (globalMatches.Count > 0) return new PriceResolution(globalMatches[0].Price, globalMatches[0].MinPrice);
|
if (globalMatches.Count > 0)
|
||||||
|
{
|
||||||
|
var m = globalMatches[0];
|
||||||
|
return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent);
|
||||||
|
}
|
||||||
|
|
||||||
return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null) : null;
|
return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null, null, null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same resolution `SaleService.ResolveUnitPriceAsync` uses internally, exposed read-only so
|
||||||
|
/// the frontend can show the real price (region/date-scoped price list, else Item.SalePrice)
|
||||||
|
/// before a sale is ever submitted, instead of guessing from the flat catalogue price.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PriceResolution?> ResolveByStoreAsync(Guid itemId, Guid storeId, DateTime saleDate)
|
||||||
|
{
|
||||||
|
var item = await itemRepo.GetByIdAsync(itemId);
|
||||||
|
if (item is null) return null;
|
||||||
|
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
|
||||||
|
if (store is null) return null;
|
||||||
|
|
||||||
|
return await ResolvePriceAsync(itemId, store.Route?.FK_RegionId, item.SalePrice, saleDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public interface IPoRepository
|
||||||
|
{
|
||||||
|
Task<Models.PurchaseOrder?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status);
|
||||||
|
Task AddAsync(Models.PurchaseOrder entity);
|
||||||
|
void Update(Models.PurchaseOrder entity);
|
||||||
|
|
||||||
|
Task AddItemAsync(PurchaseOrderItem entity);
|
||||||
|
|
||||||
|
/// <summary>True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier.</summary>
|
||||||
|
Task<bool> HasActiveForSupplierAsync(Guid supplierId);
|
||||||
|
|
||||||
|
/// <summary>All active PurchaseOrders for a supplier, with items, used by evaluateSupplier.</summary>
|
||||||
|
Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/purchase-orders")]
|
||||||
|
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
|
||||||
|
Ok(await service.GetAllAsync(supplierId, status));
|
||||||
|
|
||||||
|
[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(CreatePoRequest 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 (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/close")]
|
||||||
|
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CloseAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CancelAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CancelLineAsync(id, itemId, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/match-invoice")]
|
||||||
|
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
|
||||||
|
{
|
||||||
|
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/resolve-variance")]
|
||||||
|
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ResolveInvoiceVarianceAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
|
||||||
|
|
||||||
|
public record CreatePoRequest(
|
||||||
|
Guid SupplierId,
|
||||||
|
List<PoItemRequest> Items,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? CreatedByUserId = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
|
||||||
|
/// null = add a new line to the Po. Only valid while the Po is Issued.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost);
|
||||||
|
|
||||||
|
public record UpdatePoRequest(List<UpdatePoLineRequest> Items);
|
||||||
|
|
||||||
|
public record CancelPoRequest(string Reason);
|
||||||
|
|
||||||
|
public record CancelPoLineRequest(string? Reason);
|
||||||
|
|
||||||
|
public record PoItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
int Quantity,
|
||||||
|
decimal UnitCost,
|
||||||
|
int ReceivedQuantity,
|
||||||
|
int CancelledQuantity,
|
||||||
|
bool IsCancelled);
|
||||||
|
|
||||||
|
public record PoResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid SupplierId,
|
||||||
|
string SupplierName,
|
||||||
|
Guid CreatedByUserId,
|
||||||
|
PurchaseOrderStatus Status,
|
||||||
|
string? CancelReason,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<PoItemResponse> Items);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal Po-vs-Grn quantity/cost reconciliation. Full 3-way match (Po + Grn + Invoice) activates
|
||||||
|
/// once Finance's Invoice entity (step 19, not yet built) exists — until then this compares only
|
||||||
|
/// PurchaseOrderItem vs GoodsReceiptItem.
|
||||||
|
/// </summary>
|
||||||
|
public record InvoiceMatchLineResult(Guid PurchaseOrderItemId, Guid ItemId, int OrderedQuantity, int AcceptedQuantity, decimal UnitCost, bool QuantityMatches);
|
||||||
|
|
||||||
|
public record InvoiceMatchResult(Guid PurchaseOrderId, Guid? GoodsReceiptId, Guid? InvoiceId, bool IsMatched, List<InvoiceMatchLineResult> Lines, string Note);
|
||||||
|
|
||||||
|
public record ResolveInvoiceVarianceRequest(string Variance, string Resolution, decimal? AdjustedUnitCost, Guid? PurchaseOrderItemId);
|
||||||
|
|
||||||
|
public record InvoiceVarianceResolutionResponse(Guid PurchaseOrderId, string Variance, string Resolution, DateTime ResolvedAt);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public class PoRepository(AppDBContext context) : IPoRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.PurchaseOrder?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.PurchaseOrders
|
||||||
|
.Include(po => po.Supplier)
|
||||||
|
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(po => po.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status)
|
||||||
|
{
|
||||||
|
var query = context.PurchaseOrders
|
||||||
|
.Include(po => po.Supplier)
|
||||||
|
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(po => po.IsActive)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (supplierId.HasValue) query = query.Where(po => po.FK_SupplierId == supplierId.Value);
|
||||||
|
if (status.HasValue) query = query.Where(po => po.Status == status.Value);
|
||||||
|
|
||||||
|
return await query.OrderByDescending(po => po.CreatedAt).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.PurchaseOrder entity) =>
|
||||||
|
await context.PurchaseOrders.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.PurchaseOrder entity) =>
|
||||||
|
context.PurchaseOrders.Update(entity);
|
||||||
|
|
||||||
|
public async Task AddItemAsync(PurchaseOrderItem entity) =>
|
||||||
|
await context.PurchaseOrderItems.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<bool> HasActiveForSupplierAsync(Guid supplierId) =>
|
||||||
|
await context.PurchaseOrders.AnyAsync(po =>
|
||||||
|
po.FK_SupplierId == supplierId &&
|
||||||
|
po.IsActive &&
|
||||||
|
po.Status != PurchaseOrderStatus.Cancelled);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId) =>
|
||||||
|
await context.PurchaseOrders
|
||||||
|
.Include(po => po.Items)
|
||||||
|
.Where(po => po.FK_SupplierId == supplierId && po.IsActive)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public class PoService(
|
||||||
|
IPoRepository repo,
|
||||||
|
IGrnRepository goodsReceiptRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
|
||||||
|
|
||||||
|
private static PoResponse Map(Models.PurchaseOrder po) =>
|
||||||
|
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
|
||||||
|
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
|
||||||
|
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
|
||||||
|
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<PoResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A purchase order must have at least one line item.");
|
||||||
|
|
||||||
|
var po = new Models.PurchaseOrder
|
||||||
|
{
|
||||||
|
FK_SupplierId = request.SupplierId,
|
||||||
|
FK_CreatedByUserId = createdByUserId,
|
||||||
|
Status = PurchaseOrderStatus.Issued
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
po.Items.Add(new PurchaseOrderItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitCost = line.UnitCost
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(po.Id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
|
||||||
|
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (po.Status != PurchaseOrderStatus.Issued)
|
||||||
|
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.PurchaseOrderItemId.HasValue)
|
||||||
|
{
|
||||||
|
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||||
|
|
||||||
|
if (existing.ReceivedQuantity > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
|
||||||
|
|
||||||
|
existing.FK_ItemId = line.ItemId;
|
||||||
|
existing.Quantity = line.Quantity;
|
||||||
|
existing.UnitCost = line.UnitCost;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await repo.AddItemAsync(new PurchaseOrderItem
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = po.Id,
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitCost = line.UnitCost
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
|
||||||
|
|
||||||
|
po.Status = PurchaseOrderStatus.Closed;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
|
||||||
|
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
|
||||||
|
|
||||||
|
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
|
||||||
|
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
|
||||||
|
|
||||||
|
po.Status = PurchaseOrderStatus.Cancelled;
|
||||||
|
po.CancelReason = request.Reason;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity < Quantity
|
||||||
|
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
|
||||||
|
/// every non-cancelled line is fully received.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||||
|
|
||||||
|
if (line.IsCancelled)
|
||||||
|
throw new InvalidOperationException("This line is already cancelled.");
|
||||||
|
|
||||||
|
if (line.ReceivedQuantity >= line.Quantity)
|
||||||
|
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
|
||||||
|
|
||||||
|
line.IsCancelled = true;
|
||||||
|
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
|
||||||
|
|
||||||
|
var activeLines = po.Items.Where(i => i.IsActive).ToList();
|
||||||
|
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
|
||||||
|
&& activeLines.Any(i => !i.IsCancelled))
|
||||||
|
{
|
||||||
|
po.Status = PurchaseOrderStatus.Received;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
|
||||||
|
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(poId);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
var receipts = grnId.HasValue
|
||||||
|
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
|
||||||
|
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
|
||||||
|
|
||||||
|
var acceptedByItem = receipts
|
||||||
|
.SelectMany(g => g.Items.Where(i => i.IsActive))
|
||||||
|
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
|
||||||
|
|
||||||
|
var lines = po.Items.Where(i => i.IsActive).Select(i =>
|
||||||
|
{
|
||||||
|
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
|
||||||
|
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var isMatched = lines.All(l => l.QuantityMatches);
|
||||||
|
|
||||||
|
return new InvoiceMatchResult(
|
||||||
|
poId, grnId, invoiceId, isMatched, lines,
|
||||||
|
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
|
||||||
|
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
|
||||||
|
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(poId);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (request.AdjustedUnitCost.HasValue)
|
||||||
|
{
|
||||||
|
if (!request.PurchaseOrderItemId.HasValue)
|
||||||
|
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
|
||||||
|
|
||||||
|
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||||
|
|
||||||
|
line.UnitCost = request.AdjustedUnitCost.Value;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public interface IPurchaseReturnRepository
|
||||||
|
{
|
||||||
|
Task<Models.PurchaseReturn?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
|
Task AddAsync(Models.PurchaseReturn entity);
|
||||||
|
|
||||||
|
/// <summary>Sum of Quantity per ItemId across all prior returns for a Po.</summary>
|
||||||
|
Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/purchase-orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/purchase-orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest 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(id, request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
|
||||||
|
|
||||||
|
public record CreatePurchaseReturnRequest(
|
||||||
|
string Reason,
|
||||||
|
List<PurchaseReturnItemRequest> Items,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? CreatedByUserId = null);
|
||||||
|
|
||||||
|
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
|
||||||
|
|
||||||
|
public record PurchaseReturnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
string Reason,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<PurchaseReturnItemResponse> Items);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.PurchaseReturn?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.PurchaseReturns
|
||||||
|
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(pr => pr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.PurchaseReturns
|
||||||
|
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive)
|
||||||
|
.OrderByDescending(pr => pr.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.PurchaseReturn entity) =>
|
||||||
|
await context.PurchaseReturns.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.PurchaseReturnItems
|
||||||
|
.Include(i => i.PurchaseReturn)
|
||||||
|
.Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
|
.GroupBy(i => i.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public class PurchaseReturnService(
|
||||||
|
IPurchaseReturnRepository repo,
|
||||||
|
IPoRepository poRepo,
|
||||||
|
IGrnRepository goodsReceiptRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity);
|
||||||
|
|
||||||
|
private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) =>
|
||||||
|
new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt,
|
||||||
|
pr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<PurchaseReturnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var pr = await repo.GetByIdAsync(id);
|
||||||
|
if (pr is null || !pr.IsActive) return null;
|
||||||
|
return Map(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PurchaseReturnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot
|
||||||
|
/// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance
|
||||||
|
/// and writes a StockMovement in the same unit of work.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PurchaseReturnResponse> CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||||
|
if (po is null || !po.IsActive)
|
||||||
|
throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId))
|
||||||
|
throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A purchase return must have at least one line item.");
|
||||||
|
|
||||||
|
var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId);
|
||||||
|
var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId);
|
||||||
|
|
||||||
|
// The receipt(s) for this Po share a single receiving warehouse per the current model
|
||||||
|
// (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse
|
||||||
|
// as the stock-decrement location for the return.
|
||||||
|
var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId);
|
||||||
|
var warehouseId = receipts.First().FK_WarehouseId;
|
||||||
|
|
||||||
|
var purchaseReturn = new Models.PurchaseReturn
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = purchaseOrderId,
|
||||||
|
Reason = request.Reason
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||||
|
|
||||||
|
receivedTotals.TryGetValue(line.ItemId, out var received);
|
||||||
|
returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned);
|
||||||
|
var available = received - alreadyReturned;
|
||||||
|
|
||||||
|
if (line.Quantity > available)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available}).");
|
||||||
|
|
||||||
|
purchaseReturn.Items.Add(new Models.PurchaseReturnItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(purchaseReturn);
|
||||||
|
|
||||||
|
foreach (var line in purchaseReturn.Items)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
|
||||||
|
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
|
||||||
|
|
||||||
|
if (balance.Quantity < line.Quantity)
|
||||||
|
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
|
||||||
|
|
||||||
|
balance.Quantity -= line.Quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
FromLocationId = warehouseId,
|
||||||
|
FromLocationType = LocationType.Warehouse,
|
||||||
|
ToLocationId = StockService.ExternalLocationId,
|
||||||
|
ToLocationType = LocationType.External,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
Type = MovementType.PurchaseReturn,
|
||||||
|
ReasonCode = request.Reason,
|
||||||
|
ReferenceId = purchaseReturn.Id,
|
||||||
|
CreatedBy = createdByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(purchaseReturn.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,40 @@ public interface IRegionRepository
|
|||||||
Task AddAsync(Models.Region entity);
|
Task AddAsync(Models.Region entity);
|
||||||
void Update(Models.Region entity);
|
void Update(Models.Region entity);
|
||||||
void Remove(Models.Region entity);
|
void Remove(Models.Region entity);
|
||||||
|
|
||||||
|
Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period);
|
||||||
|
Task AddTargetAsync(Models.RegionalTarget target);
|
||||||
|
void UpdateTarget(Models.RegionalTarget target);
|
||||||
|
Task<List<Models.RegionalTarget>> GetAllTargetsAsync();
|
||||||
|
Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id);
|
||||||
|
void RemoveTarget(Models.RegionalTarget target);
|
||||||
|
|
||||||
|
Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period);
|
||||||
|
Task AddBudgetAsync(Models.RegionalBudget budget);
|
||||||
|
void UpdateBudget(Models.RegionalBudget budget);
|
||||||
|
Task<List<Models.RegionalBudget>> GetAllBudgetsAsync();
|
||||||
|
Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id);
|
||||||
|
void RemoveBudget(Models.RegionalBudget budget);
|
||||||
|
|
||||||
|
Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId);
|
||||||
|
Task AddAgencyMapAsync(Models.RegionAgencyMap map);
|
||||||
|
void UpdateAgencyMap(Models.RegionAgencyMap map);
|
||||||
|
Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId);
|
||||||
|
Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync();
|
||||||
|
Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id);
|
||||||
|
void RemoveAgencyMap(Models.RegionAgencyMap map);
|
||||||
|
|
||||||
|
Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId);
|
||||||
|
Task AddTaxRuleAsync(Models.RegionalTaxRule rule);
|
||||||
|
void UpdateTaxRule(Models.RegionalTaxRule rule);
|
||||||
|
Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync();
|
||||||
|
Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id);
|
||||||
|
void RemoveTaxRule(Models.RegionalTaxRule rule);
|
||||||
|
|
||||||
|
Task<int?> GetUserRoleLevelAsync(Guid userId);
|
||||||
|
|
||||||
|
Task<List<Models.Route>> GetRoutesAsync(Guid regionId);
|
||||||
|
Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds);
|
||||||
|
|
||||||
|
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Region;
|
namespace Distribution.Features.Region;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/regions")]
|
[Route("api/regions")]
|
||||||
[Authorize]
|
|
||||||
public class RegionController(RegionService service) : ControllerBase
|
public class RegionController(RegionService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "regions.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.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);
|
||||||
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "regions.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,10 +32,43 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
return deleted ? NoContent() : NotFound();
|
return deleted ? NoContent() : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//targets/budgets/agencies/tax-rule.
|
||||||
|
[HttpPut("{id:guid}/manage")]
|
||||||
|
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.ManageAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/hierarchy")]
|
||||||
|
public async Task<IActionResult> GetHierarchy(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetHierarchyAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/dashboard")]
|
||||||
|
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||||
|
{
|
||||||
|
var result = await service.GetDashboardAsync(id, period);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
|
||||||
|
[HttpGet("manage-entries")]
|
||||||
|
public async Task<IActionResult> GetManageEntries() =>
|
||||||
|
Ok(await service.GetManageEntriesAsync());
|
||||||
|
|
||||||
|
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
|
||||||
|
return deleted ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,59 @@ public record CreateRegionRequest(string Name);
|
|||||||
public record UpdateRegionRequest(string Name);
|
public record UpdateRegionRequest(string Name);
|
||||||
|
|
||||||
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record RegionalTargetResponse(Guid RegionId, string Period, decimal TargetValue);
|
||||||
|
|
||||||
|
public record RegionalBudgetResponse(Guid RegionId, string Period, decimal BudgetAmount);
|
||||||
|
|
||||||
|
public record RegionAgencyMapResponse(Guid RegionId, Guid AgencyUserId);
|
||||||
|
|
||||||
|
public record RegionalTaxRuleResponse(Guid RegionId, string RuleConfig);
|
||||||
|
|
||||||
|
// Consolidated "beyond CRUD" management endpoint — replaces the separate
|
||||||
|
// SetTarget/SetBudget/MapAgency/SetTaxRule endpoints. Every field is optional; only the
|
||||||
|
// sub-actions whose fields are supplied get applied. Target/Budget both need Period.
|
||||||
|
public record ManageRegionRequest(
|
||||||
|
string? Period,
|
||||||
|
decimal? TargetValue,
|
||||||
|
decimal? BudgetAmount,
|
||||||
|
Guid? AgencyUserId,
|
||||||
|
string? RuleConfig);
|
||||||
|
|
||||||
|
public record ManageRegionResponse(
|
||||||
|
Guid RegionId,
|
||||||
|
RegionalTargetResponse? Target,
|
||||||
|
RegionalBudgetResponse? Budget,
|
||||||
|
RegionAgencyMapResponse? AgencyMap,
|
||||||
|
RegionalTaxRuleResponse? TaxRule);
|
||||||
|
|
||||||
|
public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List<Guid> StoreIds);
|
||||||
|
|
||||||
|
public record RegionHierarchyResponse(Guid RegionId, string RegionName, List<RegionHierarchyRouteItem> Routes);
|
||||||
|
|
||||||
|
public record RegionDashboardResponse(
|
||||||
|
Guid RegionId,
|
||||||
|
string Period,
|
||||||
|
decimal? TargetValue,
|
||||||
|
decimal? BudgetAmount,
|
||||||
|
int OrderCount,
|
||||||
|
decimal ActualSales,
|
||||||
|
DateTime? LastOrderDate,
|
||||||
|
string? TaxRuleConfig,
|
||||||
|
List<Guid> AgencyUserIds);
|
||||||
|
|
||||||
|
// Flat, CRUD-list-friendly view over the four "manage" sub-resources (RegionalTarget,
|
||||||
|
// RegionalBudget, RegionalTaxRule, RegionAgencyMap), so the frontend can render one table like
|
||||||
|
// any other resource list (e.g. Rides) instead of one row per region with derived columns.
|
||||||
|
public enum RegionManageEntryType { Target, Budget, TaxRule, AgencyMap }
|
||||||
|
|
||||||
|
public record RegionManageEntryResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid RegionId,
|
||||||
|
string RegionName,
|
||||||
|
RegionManageEntryType EntryType,
|
||||||
|
string? Period,
|
||||||
|
decimal? Amount,
|
||||||
|
string? RuleConfig,
|
||||||
|
Guid? AgencyUserId,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|||||||
@@ -22,4 +22,118 @@ public class RegionRepository(AppDBContext context) : IRegionRepository
|
|||||||
entity.IsActive = false;
|
entity.IsActive = false;
|
||||||
context.Regions.Update(entity);
|
context.Regions.Update(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: no IsActive filter on any RegionalTarget lookup below — this entity is hard-deleted
|
||||||
|
// (see RemoveTarget), so IsActive is always true for rows that still exist. Querying without
|
||||||
|
// the filter also means a pre-existing orphaned row (e.g. soft-deleted before this fix
|
||||||
|
// shipped) is found and reused/cleaned up here instead of silently blocking a re-add.
|
||||||
|
public async Task<Models.RegionalTarget?> GetTargetAsync(Guid regionId, string period) =>
|
||||||
|
await context.RegionalTargets.FirstOrDefaultAsync(t =>
|
||||||
|
t.FK_RegionId == regionId && t.Period == period);
|
||||||
|
|
||||||
|
public async Task AddTargetAsync(Models.RegionalTarget target) =>
|
||||||
|
await context.RegionalTargets.AddAsync(target);
|
||||||
|
|
||||||
|
public void UpdateTarget(Models.RegionalTarget target) =>
|
||||||
|
context.RegionalTargets.Update(target);
|
||||||
|
|
||||||
|
public async Task<List<Models.RegionalTarget>> GetAllTargetsAsync() =>
|
||||||
|
await context.RegionalTargets.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Models.RegionalTarget?> GetTargetByIdAsync(Guid id) =>
|
||||||
|
await context.RegionalTargets.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
|
// Hard delete, not the usual IsActive soft-delete: RegionalTarget has a unique index on
|
||||||
|
// (FK_RegionId, Period) at the DB level, which doesn't know about IsActive — soft-deleting
|
||||||
|
// and then re-adding a target for the same region+period would violate that constraint.
|
||||||
|
public void RemoveTarget(Models.RegionalTarget target) =>
|
||||||
|
context.RegionalTargets.Remove(target);
|
||||||
|
|
||||||
|
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalBudget.
|
||||||
|
public async Task<Models.RegionalBudget?> GetBudgetAsync(Guid regionId, string period) =>
|
||||||
|
await context.RegionalBudgets.FirstOrDefaultAsync(b =>
|
||||||
|
b.FK_RegionId == regionId && b.Period == period);
|
||||||
|
|
||||||
|
public async Task AddBudgetAsync(Models.RegionalBudget budget) =>
|
||||||
|
await context.RegionalBudgets.AddAsync(budget);
|
||||||
|
|
||||||
|
public void UpdateBudget(Models.RegionalBudget budget) =>
|
||||||
|
context.RegionalBudgets.Update(budget);
|
||||||
|
|
||||||
|
public async Task<List<Models.RegionalBudget>> GetAllBudgetsAsync() =>
|
||||||
|
await context.RegionalBudgets.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Models.RegionalBudget?> GetBudgetByIdAsync(Guid id) =>
|
||||||
|
await context.RegionalBudgets.FirstOrDefaultAsync(b => b.Id == id);
|
||||||
|
|
||||||
|
// Hard delete — see RemoveTarget's comment; same unique-index-vs-soft-delete conflict
|
||||||
|
// applies to RegionalBudget's (FK_RegionId, Period) unique index.
|
||||||
|
public void RemoveBudget(Models.RegionalBudget budget) =>
|
||||||
|
context.RegionalBudgets.Remove(budget);
|
||||||
|
|
||||||
|
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionAgencyMap.
|
||||||
|
public async Task<Models.RegionAgencyMap?> GetAgencyMapAsync(Guid regionId, Guid agencyUserId) =>
|
||||||
|
await context.RegionAgencyMaps.FirstOrDefaultAsync(m =>
|
||||||
|
m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId);
|
||||||
|
|
||||||
|
public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) =>
|
||||||
|
await context.RegionAgencyMaps.AddAsync(map);
|
||||||
|
|
||||||
|
public void UpdateAgencyMap(Models.RegionAgencyMap map) =>
|
||||||
|
context.RegionAgencyMaps.Update(map);
|
||||||
|
|
||||||
|
public async Task<List<Models.RegionAgencyMap>> GetAgencyMapsForRegionAsync(Guid regionId) =>
|
||||||
|
await context.RegionAgencyMaps.Where(m => m.FK_RegionId == regionId).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.RegionAgencyMap>> GetAllAgencyMapsAsync() =>
|
||||||
|
await context.RegionAgencyMaps.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Models.RegionAgencyMap?> GetAgencyMapByIdAsync(Guid id) =>
|
||||||
|
await context.RegionAgencyMaps.FirstOrDefaultAsync(m => m.Id == id);
|
||||||
|
|
||||||
|
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionAgencyMap's
|
||||||
|
// (FK_RegionId, FK_AgencyUserId) unique index.
|
||||||
|
public void RemoveAgencyMap(Models.RegionAgencyMap map) =>
|
||||||
|
context.RegionAgencyMaps.Remove(map);
|
||||||
|
|
||||||
|
// No IsActive filter — see GetTargetAsync's comment; same reasoning for RegionalTaxRule.
|
||||||
|
public async Task<Models.RegionalTaxRule?> GetTaxRuleAsync(Guid regionId) =>
|
||||||
|
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId);
|
||||||
|
|
||||||
|
public async Task AddTaxRuleAsync(Models.RegionalTaxRule rule) =>
|
||||||
|
await context.RegionalTaxRules.AddAsync(rule);
|
||||||
|
|
||||||
|
public void UpdateTaxRule(Models.RegionalTaxRule rule) =>
|
||||||
|
context.RegionalTaxRules.Update(rule);
|
||||||
|
|
||||||
|
public async Task<List<Models.RegionalTaxRule>> GetAllTaxRulesAsync() =>
|
||||||
|
await context.RegionalTaxRules.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Models.RegionalTaxRule?> GetTaxRuleByIdAsync(Guid id) =>
|
||||||
|
await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
|
||||||
|
// Hard delete — see RemoveTarget's comment; same conflict applies to RegionalTaxRule's
|
||||||
|
// unique index on FK_RegionId alone (one tax rule per region).
|
||||||
|
public void RemoveTaxRule(Models.RegionalTaxRule rule) =>
|
||||||
|
context.RegionalTaxRules.Remove(rule);
|
||||||
|
|
||||||
|
public async Task<int?> GetUserRoleLevelAsync(Guid userId) =>
|
||||||
|
await context.Users
|
||||||
|
.Where(u => u.Id == userId && u.IsActive)
|
||||||
|
.Select(u => (int?)u.Role.Level)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Route>> GetRoutesAsync(Guid regionId) =>
|
||||||
|
await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Store>> GetStoresByRouteIdsAsync(List<Guid> routeIds) =>
|
||||||
|
await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive) =>
|
||||||
|
await (from o in context.Orders
|
||||||
|
join s in context.Stores on o.FK_StoreId equals s.Id
|
||||||
|
join r in context.Routes on s.FK_RouteId equals r.Id
|
||||||
|
where r.FK_RegionId == regionId && o.IsActive && s.IsActive && r.IsActive
|
||||||
|
&& o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive
|
||||||
|
select o).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.Region;
|
namespace Distribution.Features.Region;
|
||||||
|
|
||||||
public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
public class RegionService(IRegionRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||||
{
|
{
|
||||||
|
private const int RegionalAgencyRoleLevel = 60;
|
||||||
|
|
||||||
|
// TODO: re-enable the RegionalManager+scope / RegionalAgency role gate on ManageAsync once
|
||||||
|
// the role model for this endpoint is finalized. Left open (allow-all) for now on purpose.
|
||||||
|
private Task<bool> CanManageRegionAsync(Guid regionId) => Task.FromResult(true);
|
||||||
|
|
||||||
public async Task<List<RegionResponse>> GetAllAsync() =>
|
public async Task<List<RegionResponse>> GetAllAsync() =>
|
||||||
(await repo.GetAllAsync())
|
(await repo.GetAllAsync())
|
||||||
.Where(r => r.IsActive)
|
.Where(r => r.IsActive)
|
||||||
@@ -70,4 +77,221 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consolidated replacement for the old separate SetTarget/SetBudget/MapAgency/SetTaxRule
|
||||||
|
// endpoints. Each sub-action only runs if its fields are present on the request, so callers
|
||||||
|
// can update just one thing (as the UI does) or several at once in a single round trip.
|
||||||
|
public async Task<ManageRegionResponse?> ManageAsync(Guid regionId, ManageRegionRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
if (!await CanManageRegionAsync(regionId)) return null;
|
||||||
|
|
||||||
|
RegionalTargetResponse? targetResponse = null;
|
||||||
|
RegionalBudgetResponse? budgetResponse = null;
|
||||||
|
RegionAgencyMapResponse? agencyMapResponse = null;
|
||||||
|
RegionalTaxRuleResponse? taxRuleResponse = null;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (request.TargetValue is not null && !string.IsNullOrWhiteSpace(request.Period))
|
||||||
|
{
|
||||||
|
var target = await repo.GetTargetAsync(regionId, request.Period);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
target = new Models.RegionalTarget { FK_RegionId = regionId, Period = request.Period, TargetValue = request.TargetValue.Value };
|
||||||
|
await repo.AddTargetAsync(target);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
target.TargetValue = request.TargetValue.Value;
|
||||||
|
target.IsActive = true;
|
||||||
|
repo.UpdateTarget(target);
|
||||||
|
}
|
||||||
|
targetResponse = new RegionalTargetResponse(regionId, request.Period, target.TargetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.BudgetAmount is not null && !string.IsNullOrWhiteSpace(request.Period))
|
||||||
|
{
|
||||||
|
var budget = await repo.GetBudgetAsync(regionId, request.Period);
|
||||||
|
if (budget is null)
|
||||||
|
{
|
||||||
|
budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = request.Period, BudgetAmount = request.BudgetAmount.Value };
|
||||||
|
await repo.AddBudgetAsync(budget);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
budget.BudgetAmount = request.BudgetAmount.Value;
|
||||||
|
budget.IsActive = true;
|
||||||
|
repo.UpdateBudget(budget);
|
||||||
|
}
|
||||||
|
budgetResponse = new RegionalBudgetResponse(regionId, request.Period, budget.BudgetAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AgencyUserId is not null)
|
||||||
|
{
|
||||||
|
var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId.Value);
|
||||||
|
if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel)
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId.Value);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId.Value };
|
||||||
|
await repo.AddAgencyMapAsync(map);
|
||||||
|
}
|
||||||
|
else if (!existing.IsActive)
|
||||||
|
{
|
||||||
|
existing.IsActive = true;
|
||||||
|
repo.UpdateAgencyMap(existing);
|
||||||
|
}
|
||||||
|
agencyMapResponse = new RegionAgencyMapResponse(regionId, request.AgencyUserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.RuleConfig is not null)
|
||||||
|
{
|
||||||
|
var rule = await repo.GetTaxRuleAsync(regionId);
|
||||||
|
if (rule is null)
|
||||||
|
{
|
||||||
|
rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig };
|
||||||
|
await repo.AddTaxRuleAsync(rule);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rule.RuleConfig = request.RuleConfig;
|
||||||
|
rule.IsActive = true;
|
||||||
|
repo.UpdateTaxRule(rule);
|
||||||
|
}
|
||||||
|
taxRuleResponse = new RegionalTaxRuleResponse(regionId, rule.RuleConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ManageRegionResponse(regionId, targetResponse, budgetResponse, agencyMapResponse, taxRuleResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionHierarchyResponse?> GetHierarchyAsync(Guid regionId)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
|
||||||
|
var routes = await repo.GetRoutesAsync(regionId);
|
||||||
|
var stores = await repo.GetStoresByRouteIdsAsync(routes.Select(r => r.Id).ToList());
|
||||||
|
var routeItems = routes
|
||||||
|
.Select(r => new RegionHierarchyRouteItem(
|
||||||
|
r.Id,
|
||||||
|
r.RouteName,
|
||||||
|
stores.Where(s => s.FK_RouteId == r.Id).Select(s => s.Id).ToList()))
|
||||||
|
.ToList();
|
||||||
|
return new RegionHierarchyResponse(region.Id, region.Name, routeItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegionDashboardResponse?> GetDashboardAsync(Guid regionId, string period)
|
||||||
|
{
|
||||||
|
var region = await repo.GetByIdAsync(regionId);
|
||||||
|
if (region is null || !region.IsActive) return null;
|
||||||
|
|
||||||
|
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
|
||||||
|
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
|
||||||
|
var normalizedPeriod = periodStart.ToString("yyyy-MM");
|
||||||
|
var periodEndExclusive = periodStart.AddMonths(1);
|
||||||
|
|
||||||
|
var target = await repo.GetTargetAsync(regionId, normalizedPeriod);
|
||||||
|
var budget = await repo.GetBudgetAsync(regionId, normalizedPeriod);
|
||||||
|
var orders = await repo.GetOrdersForDashboardAsync(regionId, periodStart, periodEndExclusive);
|
||||||
|
var taxRule = await repo.GetTaxRuleAsync(regionId);
|
||||||
|
var agencyMaps = await repo.GetAgencyMapsForRegionAsync(regionId);
|
||||||
|
|
||||||
|
return new RegionDashboardResponse(
|
||||||
|
regionId,
|
||||||
|
normalizedPeriod,
|
||||||
|
target?.TargetValue,
|
||||||
|
budget?.BudgetAmount,
|
||||||
|
orders.Count,
|
||||||
|
orders.Sum(o => o.TotalAmount),
|
||||||
|
orders.Count == 0 ? null : orders.Max(o => o.OrderDate),
|
||||||
|
taxRule?.RuleConfig,
|
||||||
|
agencyMaps.Select(m => m.FK_AgencyUserId).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RegionManageEntryResponse>> GetManageEntriesAsync()
|
||||||
|
{
|
||||||
|
var regionNames = (await repo.GetAllAsync()).ToDictionary(r => r.Id, r => r.Name);
|
||||||
|
string RegionName(Guid id) => regionNames.TryGetValue(id, out var name) ? name : "Unknown";
|
||||||
|
|
||||||
|
var entries = new List<RegionManageEntryResponse>();
|
||||||
|
|
||||||
|
entries.AddRange((await repo.GetAllTargetsAsync()).Select(t =>
|
||||||
|
new RegionManageEntryResponse(t.Id, t.FK_RegionId, RegionName(t.FK_RegionId), RegionManageEntryType.Target, t.Period, t.TargetValue, null, null, t.CreatedAt)));
|
||||||
|
|
||||||
|
entries.AddRange((await repo.GetAllBudgetsAsync()).Select(b =>
|
||||||
|
new RegionManageEntryResponse(b.Id, b.FK_RegionId, RegionName(b.FK_RegionId), RegionManageEntryType.Budget, b.Period, b.BudgetAmount, null, null, b.CreatedAt)));
|
||||||
|
|
||||||
|
entries.AddRange((await repo.GetAllTaxRulesAsync()).Select(r =>
|
||||||
|
new RegionManageEntryResponse(r.Id, r.FK_RegionId, RegionName(r.FK_RegionId), RegionManageEntryType.TaxRule, null, null, r.RuleConfig, null, r.CreatedAt)));
|
||||||
|
|
||||||
|
entries.AddRange((await repo.GetAllAgencyMapsAsync()).Select(m =>
|
||||||
|
new RegionManageEntryResponse(m.Id, m.FK_RegionId, RegionName(m.FK_RegionId), RegionManageEntryType.AgencyMap, null, null, null, m.FK_AgencyUserId, m.CreatedAt)));
|
||||||
|
|
||||||
|
return entries.OrderByDescending(e => e.CreatedAt).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteManageEntryAsync(RegionManageEntryType entryType, Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool found;
|
||||||
|
switch (entryType)
|
||||||
|
{
|
||||||
|
case RegionManageEntryType.Target:
|
||||||
|
var target = await repo.GetTargetByIdAsync(id);
|
||||||
|
found = target is not null;
|
||||||
|
if (target is not null) repo.RemoveTarget(target);
|
||||||
|
break;
|
||||||
|
case RegionManageEntryType.Budget:
|
||||||
|
var budget = await repo.GetBudgetByIdAsync(id);
|
||||||
|
found = budget is not null;
|
||||||
|
if (budget is not null) repo.RemoveBudget(budget);
|
||||||
|
break;
|
||||||
|
case RegionManageEntryType.TaxRule:
|
||||||
|
var rule = await repo.GetTaxRuleByIdAsync(id);
|
||||||
|
found = rule is not null;
|
||||||
|
if (rule is not null) repo.RemoveTaxRule(rule);
|
||||||
|
break;
|
||||||
|
case RegionManageEntryType.AgencyMap:
|
||||||
|
var map = await repo.GetAgencyMapByIdAsync(id);
|
||||||
|
found = map is not null;
|
||||||
|
if (map is not null) repo.RemoveAgencyMap(map);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
found = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public interface IReturnRepository
|
||||||
|
{
|
||||||
|
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.SaleReturn>> GetAllAsync();
|
||||||
|
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
|
||||||
|
Task AddAsync(Models.SaleReturn entity);
|
||||||
|
void Update(Models.SaleReturn entity);
|
||||||
|
Task AddCreditNoteAsync(Models.CreditNote entity);
|
||||||
|
|
||||||
|
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
|
||||||
|
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/returns")]
|
||||||
|
public async Task<IActionResult> GetAll() =>
|
||||||
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
|
[HttpGet("api/returns/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("api/returns/{id:guid}/reasons")]
|
||||||
|
public async Task<IActionResult> UpdateReasons(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateReasonsAsync(id, request, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> GetByOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest 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(id, request, userId.Value, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
|
||||||
|
|
||||||
|
public record CreateSaleReturnRequest(
|
||||||
|
ReturnMode Mode,
|
||||||
|
List<ReturnItemRequest> Items,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? CreatedByUserId = null);
|
||||||
|
|
||||||
|
// The return's financial/stock effect (credit, order total, vehicle stock credit) is applied
|
||||||
|
// immediately on create with no separate approval step, so quantities/mode can never be edited
|
||||||
|
// after the fact without desyncing stock and order totals. Only each line's reason code is editable.
|
||||||
|
public record UpdateReturnReasonItem(Guid ItemId, ReturnReasonCode ReasonCode);
|
||||||
|
public record UpdateSaleReturnReasonsRequest(List<UpdateReturnReasonItem> Items);
|
||||||
|
|
||||||
|
public record SaleReturnItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
decimal Quantity,
|
||||||
|
ReturnReasonCode ReasonCode,
|
||||||
|
decimal UnitPrice);
|
||||||
|
|
||||||
|
public record SaleReturnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid OrderId,
|
||||||
|
ReturnMode Mode,
|
||||||
|
decimal TotalCreditAmount,
|
||||||
|
decimal OrderTotalAmountAfter,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<SaleReturnItemResponse> Items);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public class ReturnRepository(AppDBContext context) : IReturnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.SaleReturns
|
||||||
|
.Include(r => r.Order)
|
||||||
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.SaleReturn>> GetAllAsync() =>
|
||||||
|
await context.SaleReturns
|
||||||
|
.Include(r => r.Order)
|
||||||
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(r => r.IsActive)
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
|
||||||
|
await context.SaleReturns
|
||||||
|
.Include(r => r.Order)
|
||||||
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(r => r.FK_OrderId == orderId && r.IsActive)
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.SaleReturn entity) =>
|
||||||
|
await context.SaleReturns.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.SaleReturn entity) =>
|
||||||
|
context.SaleReturns.Update(entity);
|
||||||
|
|
||||||
|
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
|
||||||
|
await context.CreditNotes.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
|
||||||
|
await context.OrderItems
|
||||||
|
.Include(oi => oi.Order)
|
||||||
|
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.Ride;
|
||||||
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public class ReturnService(
|
||||||
|
IReturnRepository repo,
|
||||||
|
IOrderRepository orderRepo,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
|
IRideRepository rideRepo,
|
||||||
|
IItemRepository itemRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
|
||||||
|
|
||||||
|
private static SaleReturnResponse Map(SaleReturn r) =>
|
||||||
|
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
|
||||||
|
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var r = await repo.GetByIdAsync(id);
|
||||||
|
if (r is null || !r.IsActive) return null;
|
||||||
|
return Map(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SaleReturnResponse>> GetAllAsync() =>
|
||||||
|
(await repo.GetAllAsync()).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quantities/mode are immutable once created (the stock/order effect already happened) —
|
||||||
|
/// only each line's reason code can be corrected after the fact.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SaleReturnResponse> UpdateReasonsAsync(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var r = await repo.GetByIdAsync(id);
|
||||||
|
if (r is null || !r.IsActive)
|
||||||
|
throw new InvalidOperationException("Return not found.");
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
var item = r.Items.FirstOrDefault(i => i.FK_ItemId == line.ItemId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Item {line.ItemId} not found on this return.");
|
||||||
|
item.ReasonCode = line.ReasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(r); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
|
||||||
|
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a return recognized during the same store visit as an open order and settles it
|
||||||
|
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
|
||||||
|
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
|
||||||
|
/// A return is only allowed for an item the store has previously ordered.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var order = await orderRepo.GetByIdAsync(orderId);
|
||||||
|
if (order is null || !order.IsActive)
|
||||||
|
throw new InvalidOperationException("Order not found.");
|
||||||
|
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
|
||||||
|
throw new InvalidOperationException("Cannot record a return against a closed order.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A return must have at least one line item.");
|
||||||
|
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||||
|
?? throw new InvalidOperationException("Sale not found for this order.");
|
||||||
|
if (sale.FK_RideId is null)
|
||||||
|
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
|
||||||
|
?? throw new InvalidOperationException("Ride not found.");
|
||||||
|
var vehicleId = ride.FK_VehicleId;
|
||||||
|
|
||||||
|
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
||||||
|
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
|
||||||
|
|
||||||
|
var saleReturn = new SaleReturn
|
||||||
|
{
|
||||||
|
FK_OrderId = orderId,
|
||||||
|
Mode = request.Mode,
|
||||||
|
CreatedByUserId = createdByUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||||
|
|
||||||
|
var item = await itemRepo.GetByIdAsync(line.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
||||||
|
|
||||||
|
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
|
||||||
|
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
|
||||||
|
|
||||||
|
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
|
||||||
|
? matchingOrderItem.UnitPrice
|
||||||
|
: item.SalePrice ?? 0;
|
||||||
|
|
||||||
|
saleReturn.Items.Add(new SaleReturnItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
ReasonCode = line.ReasonCode,
|
||||||
|
UnitPrice = unitPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var line in saleReturn.Items)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
|
||||||
|
if (balance is null)
|
||||||
|
{
|
||||||
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
LocationId = vehicleId,
|
||||||
|
LocationType = LocationType.Vehicle,
|
||||||
|
Quantity = line.Quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
balance.Quantity += line.Quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
FromLocationId = StockService.ExternalLocationId,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
|
ToLocationId = vehicleId,
|
||||||
|
ToLocationType = LocationType.Vehicle,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
Type = MovementType.CustomerReturn,
|
||||||
|
ReasonCode = line.ReasonCode.ToString(),
|
||||||
|
ReferenceId = saleReturn.Id,
|
||||||
|
CreatedBy = createdByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.AddAsync(saleReturn);
|
||||||
|
|
||||||
|
if (request.Mode == ReturnMode.CreditDeduction)
|
||||||
|
{
|
||||||
|
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
|
||||||
|
saleReturn.TotalCreditAmount = total;
|
||||||
|
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
|
||||||
|
orderRepo.Update(order);
|
||||||
|
|
||||||
|
await repo.AddCreditNoteAsync(new CreditNote
|
||||||
|
{
|
||||||
|
FK_SaleReturnId = saleReturn.Id,
|
||||||
|
Amount = total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else // ItemExchange
|
||||||
|
{
|
||||||
|
foreach (var line in saleReturn.Items)
|
||||||
|
{
|
||||||
|
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
|
||||||
|
continue; // no matching line on this order — nothing to offset
|
||||||
|
|
||||||
|
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
|
||||||
|
orderItem.Quantity -= (int)offsetQty;
|
||||||
|
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
|
||||||
|
}
|
||||||
|
orderRepo.Update(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(saleReturn.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/rides")]
|
[Route("api/rides")]
|
||||||
[Authorize]
|
|
||||||
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] RideStatus? status,
|
[FromQuery] RideStatus? status,
|
||||||
[FromQuery] RideType? type,
|
[FromQuery] RideType? type,
|
||||||
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "rides.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);
|
||||||
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "rides.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -41,12 +36,11 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/driver")]
|
[HttpPut("{id:guid}/driver")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.AssignDriverAsync(id, request.FK_DriverProfileId, ct);
|
var result = await service.AssignDriverAsync(id, request.DriverProfileId, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/open")]
|
[HttpPost("{id:guid}/open")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -66,8 +59,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/activate")]
|
||||||
[HttpPost("{id:guid}/activate")]
|
[HttpPost("{id:guid}/activate")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -79,8 +72,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/close")]
|
||||||
[HttpPost("{id:guid}/close")]
|
[HttpPost("{id:guid}/close")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -93,7 +86,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/cancel-request")]
|
[HttpPost("{id:guid}/cancel-request")]
|
||||||
[Authorize(Policy = "rides.cancel.request")]
|
|
||||||
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -107,7 +99,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("cancel-requests")]
|
[HttpGet("cancel-requests")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetCancelRequests(
|
public async Task<IActionResult> GetCancelRequests(
|
||||||
[FromQuery] CancelRequestStatus? status,
|
[FromQuery] CancelRequestStatus? status,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
@@ -118,7 +109,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/approve")]
|
[HttpPost("cancel-requests/{id:guid}/approve")]
|
||||||
[Authorize(Policy = "rides.cancel.approve")]
|
|
||||||
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -132,7 +122,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/reject")]
|
[HttpPost("cancel-requests/{id:guid}/reject")]
|
||||||
[Authorize(Policy = "rides.cancel.approve")]
|
|
||||||
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -146,7 +135,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/cancel-request")]
|
[HttpGet("{id:guid}/cancel-request")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetCancelRequest(Guid id)
|
public async Task<IActionResult> GetCancelRequest(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetCancelRequestAsync(id);
|
var result = await service.GetCancelRequestAsync(id);
|
||||||
@@ -154,7 +142,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("breakdown-assist/{rideId:guid}")]
|
[HttpGet("breakdown-assist/{rideId:guid}")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ public record RideResponse(
|
|||||||
|
|
||||||
public record CreateRideRequest(
|
public record CreateRideRequest(
|
||||||
RideType RideType,
|
RideType RideType,
|
||||||
Guid FK_VehicleId,
|
Guid VehicleId,
|
||||||
Guid FK_DriverProfileId,
|
Guid DriverProfileId,
|
||||||
Guid FK_RouteId,
|
Guid RouteId,
|
||||||
Guid? FK_SourceWarehouseId);
|
Guid? SourceWarehouseId);
|
||||||
|
|
||||||
public record AssignRideDriverRequest(Guid FK_DriverProfileId);
|
public record AssignRideDriverRequest(Guid DriverProfileId);
|
||||||
|
|
||||||
public record ActivateRideRequest(int Meterstart);
|
public record ActivateRideRequest(int Meterstart);
|
||||||
|
|
||||||
|
|||||||
@@ -36,19 +36,19 @@ public class RideService(
|
|||||||
|
|
||||||
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
|
public async Task<RideResponse> CreateAsync(CreateRideRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId)
|
var vehicle = await vehicleRepo.GetByIdAsync(request.VehicleId)
|
||||||
?? throw new InvalidOperationException("Vehicle not found");
|
?? throw new InvalidOperationException("Vehicle not found");
|
||||||
|
|
||||||
var sourceWarehouseId = vehicle.FK_DefaultWarehouseId ?? request.FK_SourceWarehouseId;
|
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.SourceWarehouseId;
|
||||||
if (!sourceWarehouseId.HasValue)
|
if (!sourceWarehouseId.HasValue)
|
||||||
throw new InvalidOperationException("Source warehouse is required for this vehicle");
|
throw new InvalidOperationException("Source warehouse is required for this vehicle");
|
||||||
|
|
||||||
var ride = new DeliveryRun
|
var ride = new DeliveryRun
|
||||||
{
|
{
|
||||||
RideType = request.RideType,
|
RideType = request.RideType,
|
||||||
FK_VehicleId = request.FK_VehicleId,
|
FK_VehicleId = request.VehicleId,
|
||||||
FK_DriverProfileId = request.FK_DriverProfileId,
|
FK_DriverProfileId = request.DriverProfileId,
|
||||||
FK_RouteId = request.FK_RouteId,
|
FK_RouteId = request.RouteId,
|
||||||
FK_SourceWarehouseId = sourceWarehouseId.Value,
|
FK_SourceWarehouseId = sourceWarehouseId.Value,
|
||||||
Status = RideStatus.Draft
|
Status = RideStatus.Draft
|
||||||
};
|
};
|
||||||
@@ -141,6 +141,9 @@ public class RideService(
|
|||||||
if (ride.Status != RideStatus.Active)
|
if (ride.Status != RideStatus.Active)
|
||||||
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
|
throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status");
|
||||||
|
|
||||||
|
if (request.Meterend < ride.Meterstart)
|
||||||
|
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
|
||||||
|
|
||||||
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
|
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
|
||||||
|
|
||||||
ride.Status = RideStatus.Closed;
|
ride.Status = RideStatus.Closed;
|
||||||
@@ -295,7 +298,8 @@ public class RideService(
|
|||||||
b.LocationId,
|
b.LocationId,
|
||||||
b.LocationType,
|
b.LocationType,
|
||||||
b.Quantity,
|
b.Quantity,
|
||||||
b.UpdatedAt)).ToList()));
|
b.UpdatedAt,
|
||||||
|
ride.Vehicle?.NumberPlate ?? string.Empty)).ToList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
|
var openOrders = await rideRepo.GetActiveOrdersByRouteIdAsync(strandedRide.FK_RouteId);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user