Compare commits

..

35 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna 308be2f5ce refactor invoice and po life cycle 2026-09-16 10:39:42 +05:30
DeepnaPooja 85a16396aa Add the testbug.md file 2026-09-02 13:46:34 +05:30
DeepnaPooja 12ff9e8fef Merge pull request 'Feat/fe integration' (#19) from feat/FE_Integration into dev
Reviewed-on: #19
2026-09-01 10:27:37 +00:00
DeepnaPooja 7cf272e311 Consolidate region management APIs 2026-09-01 15:46:35 +05:30
DeepnaPooja 82a32de966 add seed data 2026-09-01 15:46:35 +05:30
Kalana B. Thilakarathna 68b34b3fc3 Refactor store CRUD to pages, add map-based location picker
Refactored store creation and editing from modal sheets to dedicated pages (`/stores/new`, `/stores/[id]/edit`), and added a new store detail view page (`/stores/[id]`). Introduced a `LocationPicker` component using `react-leaflet` for map-based location selection, replacing manual latitude/longitude inputs. Updated navigation in the store list to use these pages. Improved UI and user feedback for store forms and details. Updated backend API and form logic to match the new flow. Added `leaflet`, `react-leaflet`, and types as dependencies. Temporarily relaxed store scope checks in `StoreService.cs`. Updated table actions and types for the new workflow.
2026-09-01 10:53:55 +05:30
DeepnaPooja 83167b5eb3 Merge pull request 'Feat/route sales mngt' (#17) from feat/route_sales_mngt into dev
Reviewed-on: #17
2026-08-31 07:38:42 +00:00
DeepnaPooja e67675a12a update docs 2026-08-31 13:00:49 +05:30
DeepnaPooja 2ecda623ac add route service tests 2026-08-31 13:00:42 +05:30
DeepnaPooja da9caa855a add sales visits ui 2026-08-31 13:00:33 +05:30
DeepnaPooja 5c4e1b590a add route visit management 2026-08-31 13:00:13 +05:30
DeepnaPooja 75b5035354 Merge pull request 'Feat/region mngt' (#16) from feat/region_mngt into dev
Reviewed-on: #16
2026-08-31 05:47:39 +00:00
DeepnaPooja c0432a448f Document route-to-region assignment and applied migration
Notes the new PUT /api/routes/{id}/region endpoint as a stand-in for
the still-blocked assignAreaToRegion, and removes the stale "migration
pending" caveat now that AddRegionManagement has been applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:11:25 +05:30
DeepnaPooja bfbfe352ee Add dedicated endpoint to assign a route to a region
PUT /api/routes/{id}/region assigns/reassigns an existing route to a
region directly, validating the target region exists and is active.
Stands in for the blocked assignAreaToRegion until an Area entity
exists between Region and Route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:11:15 +05:30
DeepnaPooja 320da0e1c9 Add and apply EF Core migration for region management tables
Creates RegionAgencyMaps, RegionalBudgets, RegionalTargets, and
RegionalTaxRules with their unique indexes and foreign keys, and
applies the migration to the shared MySQL database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 10:38:59 +05:30
DeepnaPooja f0d7f939ef Update mainPlan.md status table for Region Management
The summary table still said "Not Started" for step 10 after the
detailed section below it was updated to reflect the implemented
target/budget/agency-map/tax-rule/hierarchy/dashboard endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:53:59 +05:30
DeepnaPooja 8b84c8aeb3 Document the implemented region management endpoints
Updates mainPlan.md's Region Management table, 10_region_management.md's
status/open-questions/implementation-notes sections, and
BACKEND_API_REFERENCE.md's Regions section to reflect the newly added
target/budget/agency-map/tax-rule/hierarchy/dashboard endpoints, and
flags that the EF Core migration for the new tables is still pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:51:46 +05:30
DeepnaPooja 9febae6d63 Add region management dialog for targets, budgets, tax rules & agencies
RegionManageSheet lets a manager set a region's target/budget for a
period, save a tax rule config, map a RegionalAgency user to the
region, and view its route/store hierarchy and dashboard stats. Wired
into the regions table as a new row action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:46:05 +05:30
DeepnaPooja df27d58089 Add frontend types and API client methods for region management
Adds RegionalTargetResponse, RegionalBudgetResponse,
RegionAgencyMapResponse, RegionalTaxRuleResponse,
RegionHierarchyResponse, and RegionDashboardResponse types, plus
setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard methods
on the regions API client, mirroring the new backend endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:55 +05:30
DeepnaPooja 4e12e9f8f1 Update RegionServiceTests for the new RegionService constructor
RegionService now depends on ICurrentUserService and IScopeAccessService
for the target/budget/tax-rule/agency scope checks; update the existing
CRUD test setup to mock them accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:43 +05:30
DeepnaPooja 39eab2e3a2 Add region target/budget/tax-rule/agency-map/hierarchy/dashboard APIs
Extends RegionService, RegionRepository, and RegionController with the
endpoints from mainPlan.md sec 2 beyond plain CRUD: setRegionalTarget,
setRegionalBudget, mapAgencyToRegion, setRegionalTaxRule,
getRegionHierarchy, and getRegionDashboard. Target/budget/tax-rule
mutation is gated to users with region scope at RegionalManager level
or RegionalAgency level across all regions; mapAgencyToRegion links an
existing User holding the RegionalAgency role rather than introducing
a separate agency entity, matching the convention already used by
Claim.FK_AgencyUserId elsewhere in the codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:45:35 +05:30
DeepnaPooja 889ca0795d Add data model for region targets, budgets, agency mapping, tax rules
Introduces RegionalTarget, RegionalBudget, RegionAgencyMap, and
RegionalTaxRule entities plus their DbSets and EF constraints, laying
the groundwork for the "beyond CRUD" region management features.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:44:12 +05:30
Kalana B. Thilakarathna a7801a2bc2 Merge branch 'production' into dev 2026-08-29 21:03:56 +05:30
Kalana B. Thilakarathna f4db83654c Implement GRN and Purchase Order sheets with form handling and data fetching 2026-08-29 21:03:31 +05:30
Kalana B. Thilakarathna d414ec8e38 Merge branch 'dev' into production 2026-08-26 20:20:00 +05:30
Kalana B. Thilakarathna dc7e7a70fc Add FlatPaymentController with RESTful payment endpoints
Introduced FlatPaymentController under Distribution.Features.Payment, providing GET and POST endpoints at /api/payments. Supports listing, retrieving, and creating payments with filtering and error handling. Business logic is delegated to PaymentService.
2026-08-26 20:18:58 +05:30
Kalana B. Thilakarathna 20b82a2734 Merge branch 'dev' into production 2026-08-25 23:48:06 +05:30
Kalana B. Thilakarathna 83d3272784 frontend fully integreated with the backend 2026-08-25 23:47:33 +05:30
DeepnaPooja d6cd9ccd08 Merge pull request 'Dev' (#15) from dev into production
Reviewed-on: #15
2026-08-25 17:24:08 +00:00
DeepnaPooja 1bee5f91c6 Merge branches 'feat/authentication' and 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-08-25 22:46:27 +05:30
kalana_Thilakarathna 3268a50c3e Merge pull request 'Production' (#13) from production into dev
Reviewed-on: #13
2026-08-25 10:56:00 +00:00
Kalana B. Thilakarathna f1c036d1d8 frontend initiatewith shadcn uis 2026-08-25 16:20:50 +05:30
Kalana B. Thilakarathna 92fef1dfa8 Merge branch 'dev' into production 2026-08-25 16:12:06 +05:30
Kalana B. Thilakarathna bb33928661 auth clean up 2026-08-25 16:11:37 +05:30
DeepnaPooja b279a121ad Merge pull request 'Dev' (#12) from dev into production
Reviewed-on: #12
2026-08-25 10:15:19 +00:00
350 changed files with 55469 additions and 444 deletions
@@ -1,4 +1,5 @@
using Distribution.Features.Region;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Moq;
@@ -10,11 +11,14 @@ public class RegionServiceTests
{
private readonly Mock<IRegionRepository> _repo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<ICurrentUserService> _currentUser = new();
private readonly Mock<IScopeAccessService> _scopeAccess = new();
private readonly RegionService _sut;
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") =>
@@ -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);
}
}
+32
View File
@@ -56,6 +56,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
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)
{
@@ -373,6 +378,33 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.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.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
}
}
+117
View File
@@ -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
}
);
}
}
+131
View File
@@ -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,134 @@
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 = null, FromLocationType = null, ToLocationId = MasterDataSeed.ColomboWarehouseId, ToLocationType = LocationType.Warehouse, Quantity = 200m, Type = MovementType.PurchaseReceipt, ReasonCode = null, ReferenceId = ProcurementSeed.GoodsReceipt1Id, CreatedAt = new DateTime(2026, 8, 15), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement2Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.ColomboWarehouseId, FromLocationType = LocationType.Warehouse, ToLocationId = MasterDataSeed.VanVehicleId, ToLocationType = LocationType.Vehicle, Quantity = 50m, Type = MovementType.Load, ReasonCode = null, ReferenceId = OperationsDataSeed.LoadingSheet1Id, CreatedAt = new DateTime(2026, 8, 25, 7, 30, 0), CreatedBy = MasterDataSeed.Supervisor1UserId },
new StockMovement { Id = StockMovement3Id, FK_ItemId = MasterDataSeed.Coke500ItemId, FromLocationId = MasterDataSeed.VanVehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = null, ToLocationType = null, Quantity = 30m, Type = MovementType.Sale, ReasonCode = null, ReferenceId = OperationsDataSeed.Sale1Id, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), CreatedBy = MasterDataSeed.SalesRep1UserId }
);
modelBuilder.Entity<StoreKycDocument>().HasData(
new StoreKycDocument { Id = StoreKycDocument1Id, FK_StoreId = MasterDataSeed.FortStoreId, DocumentType = "Business Registration", FileReference = "seed/kyc/fort-mini-mart-brc.pdf", UploadedAt = new DateTime(2026, 8, 10), CreatedAt = new DateTime(2026, 8, 10), IsActive = true },
new StoreKycDocument { Id = StoreKycDocument2Id, FK_StoreId = MasterDataSeed.WattalaStoreId, DocumentType = "NIC", FileReference = "seed/kyc/wattala-owner-nic.pdf", UploadedAt = new DateTime(2026, 8, 12), CreatedAt = new DateTime(2026, 8, 12), IsActive = true }
);
modelBuilder.Entity<DriverComplianceConfig>().HasData(
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
);
modelBuilder.Entity<RideCancelRequest>().HasData(
new RideCancelRequest
{
Id = RideCancelRequest1Id,
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
TypeCode = CancelRequestType.VehicleBreakdown,
Reason = "Reported minor engine issue, resolved after inspection",
RequestedBy = MasterDataSeed.Driver2UserId,
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
Status = CancelRequestStatus.Rejected,
ApprovedBy = MasterDataSeed.Supervisor1UserId,
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
}
);
modelBuilder.Entity<RouteVisitLog>().HasData(
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
);
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 }
);
}
}
+88
View File
@@ -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 }
);
}
}
@@ -0,0 +1,20 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -0,0 +1,16 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken ct);
}
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Brand;
[ApiController]
[Route("api/brands")]
[Authorize]
public class BrandController(BrandService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -1,19 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Category;
[ApiController]
[Authorize]
public class CategoryController(CategoryService service) : ControllerBase
{
[HttpGet("api/categories")]
[Authorize(Policy = "categories.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/categories/{id:guid}")]
[Authorize(Policy = "categories.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPost("api/categories")]
[Authorize(Policy = "categories.create")]
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPut("api/categories/{id:guid}")]
[Authorize(Policy = "categories.update")]
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpDelete("api/categories/{id:guid}")]
[Authorize(Policy = "categories.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
[Authorize(Policy = "categories.create")]
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpPut("api/subcategories/{id:guid}")]
[Authorize(Policy = "categories.update")]
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateSubCategoryAsync(id, request, ct);
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
}
[HttpDelete("api/subcategories/{id:guid}")]
[Authorize(Policy = "categories.delete")]
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteSubCategoryAsync(id, ct);
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
[ApiController]
[Authorize]
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims/{id:guid}")]
@@ -27,7 +25,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize(Policy = "claims.settle")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -1,10 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Authorize]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
@@ -21,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
}
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
@@ -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;
namespace Distribution.Features.DriverProfile;
[ApiController]
[Route("api/driver-profiles")]
[Authorize]
public class DriverProfileController(DriverProfileService service) : ControllerBase
public class DriverProfileController(
DriverProfileService service,
DriverComplianceConfigService complianceService,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "driver_profiles.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
@@ -17,7 +19,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "driver_profiles.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,15 +26,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
}
[HttpPost]
[Authorize(Policy = "driver_profiles.create")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
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}")]
[Authorize(Policy = "driver_profiles.update")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -41,10 +51,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "driver_profiles.deactivate")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
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);
public record CreateDriverProfileRequest(
Guid FK_UserId,
Guid UserId,
string LicenseNumber,
DateTime LicenseExpiry,
DateTime MedicalClearanceExpiry);
@@ -1,9 +1,10 @@
using Distribution.Features.Stock;
using Distribution.Features.User;
using Distribution.Infra.UoW;
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)
{
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
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)
throw new InvalidOperationException("An active driver profile already exists for this user");
var profile = new global::Distribution.Models.DriverProfile
{
FK_UserId = request.FK_UserId,
FK_UserId = request.UserId,
LicenseNumber = request.LicenseNumber,
LicenseExpiry = request.LicenseExpiry,
MedicalClearanceExpiry = request.MedicalClearanceExpiry
+24 -3
View File
@@ -1,13 +1,21 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
[ApiController]
[Authorize]
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)
{
@@ -15,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
return result is null ? NotFound() : Ok(result);
}
[HttpPost("api/goods-receipts")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
+2
View File
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
.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)
+7
View File
@@ -19,6 +19,13 @@ public class GrnService(
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);
@@ -3,6 +3,8 @@ 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);
+1 -9
View File
@@ -1,15 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Item;
[ApiController]
[Route("api/items")]
[Authorize]
public class ItemController(ItemService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? categoryId,
[FromQuery] Guid? subCategoryId,
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
{
try
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
}
[HttpPut("{id:guid}/uom-conversions")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
{
var result = await service.ReplaceConversionsAsync(id, request, ct);
@@ -1,17 +1,14 @@
using Distribution.Models;
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
[Authorize]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
@@ -23,7 +20,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -31,7 +27,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
try
@@ -44,7 +39,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/items")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -57,7 +51,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm-dispatch")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -71,7 +64,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/receipt-items")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
@@ -84,7 +76,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm-receipt")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -98,7 +89,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/resolve")]
[Authorize(Policy = "loading.resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -110,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
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 }); }
}
}
@@ -35,3 +35,7 @@ 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);
@@ -225,6 +225,98 @@ public class LoadingSheetService(
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))
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Order;
[ApiController]
[Route("api/orders")]
[Authorize]
public class OrderController(OrderService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
@@ -30,7 +25,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
@@ -42,7 +36,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
try
@@ -54,7 +47,6 @@ public class OrderController(OrderService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "orders.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -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);
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 }); }
}
}
@@ -4,6 +4,9 @@ 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);
}
@@ -1,10 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Authorize]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
@@ -4,6 +4,8 @@ namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
@@ -6,6 +6,26 @@ 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)
@@ -1,4 +1,5 @@
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -14,6 +15,19 @@ public class PaymentService(
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);
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
[Authorize]
public class PriceListController(PriceListService service) : ControllerBase
{
[HttpGet]
@@ -1,13 +1,11 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
[ApiController]
[Route("api/purchase-orders")]
[Authorize]
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
[ApiController]
[Authorize]
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
@@ -7,4 +7,40 @@ public interface IRegionRepository
Task AddAsync(Models.Region entity);
void Update(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;
[ApiController]
[Route("api/regions")]
[Authorize]
public class RegionController(RegionService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "regions.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "regions.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "regions.create")]
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "regions.update")]
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,10 +32,43 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "regions.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
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 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;
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();
}
+225 -1
View File
@@ -1,9 +1,16 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
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() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
@@ -70,4 +77,221 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow)
}
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;
}
}
}
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
[ApiController]
[Authorize]
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns/{id:guid}")]
+3 -16
View File
@@ -1,17 +1,14 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
[Authorize]
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetAll(
[FromQuery] RideStatus? status,
[FromQuery] RideType? type,
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost]
[Authorize(Policy = "rides.create")]
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPut("{id:guid}/driver")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
{
try
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("{id:guid}/open")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
{
try
@@ -66,8 +59,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/activate")]
[HttpPost("{id:guid}/activate")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
{
try
@@ -79,8 +72,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("{id:guid}/close")]
[HttpPost("{id:guid}/close")]
[Authorize(Policy = "rides.manage")]
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
{
try
@@ -93,7 +86,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("{id:guid}/cancel-request")]
[Authorize(Policy = "rides.cancel.request")]
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -107,7 +99,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("cancel-requests")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetCancelRequests(
[FromQuery] CancelRequestStatus? status,
[FromQuery] int page = 1,
@@ -118,7 +109,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("cancel-requests/{id:guid}/approve")]
[Authorize(Policy = "rides.cancel.approve")]
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -132,7 +122,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpPost("cancel-requests/{id:guid}/reject")]
[Authorize(Policy = "rides.cancel.approve")]
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -146,7 +135,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("{id:guid}/cancel-request")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetCancelRequest(Guid id)
{
var result = await service.GetCancelRequestAsync(id);
@@ -154,7 +142,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
}
[HttpGet("breakdown-assist/{rideId:guid}")]
[Authorize(Policy = "rides.view")]
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
{
try
+1 -3
View File
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Role;
[ApiController]
[Route("api/roles")]
[Authorize]
public class RoleController(RoleService service) : ControllerBase
{
[HttpGet]
@@ -7,4 +7,14 @@ public interface IRouteRepository
Task AddAsync(Models.Route entity);
void Update(Models.Route entity);
void Remove(Models.Route entity);
Task<Models.User?> GetActiveUserAsync(Guid userId);
Task<List<Models.Store>> GetStoresByIdsAsync(List<Guid> storeIds);
Task<Models.Store?> GetStoreByIdAsync(Guid storeId);
void UpdateStore(Models.Store store);
Task<Models.RouteVisitLog?> GetOpenVisitAsync(Guid routeId, Guid storeId);
Task AddVisitLogAsync(Models.RouteVisitLog log);
void UpdateVisitLog(Models.RouteVisitLog log);
Task<List<Models.RouteVisitLog>> GetVisitsByStoreIdAsync(Guid storeId);
}
+40 -8
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Route;
[ApiController]
[Route("api/routes")]
[Authorize]
public class RouteController(RouteService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "routes.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "routes.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "routes.create")]
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,18 +25,55 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "routes.update")]
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/region")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "routes.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPost("{id:guid}/stores")]
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
{
var result = await service.AssignStoresToRouteAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/owner")]
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
{
var result = await service.AssignOwnerAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/optimize")]
public IActionResult OptimizeSequence(Guid id) =>
StatusCode(StatusCodes.Status501NotImplemented);
[HttpPost("{id:guid}/visits/check-in")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
var result = await service.RecordCheckInAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/visits/check-out")]
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
{
var result = await service.RecordCheckOutAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
+13 -1
View File
@@ -4,4 +4,16 @@ public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPo
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, bool IsActive, DateTime CreatedAt);
public record RouteResponse(Guid Id, Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length, Guid? OwnerUserId, bool IsActive, DateTime CreatedAt);
public record AssignRouteToRegionRequest(Guid RegionId);
public record AssignStoresToRouteRequest(List<Guid> StoreIds);
public record AssignRouteOwnerRequest(Guid SalesRepId);
public record RecordCheckInRequest(Guid StoreId, decimal Latitude, decimal Longitude, DateTime? Timestamp);
public record RecordCheckOutRequest(Guid StoreId, DateTime? Timestamp);
public record RouteVisitResponse(Guid Id, Guid RouteId, Guid StoreId, Guid UserId, DateTime CheckInAt, bool IsDeviationFlagged, DateTime? CheckOutAt);
@@ -22,4 +22,31 @@ public class RouteRepository(AppDBContext context) : IRouteRepository
entity.IsActive = false;
context.Routes.Update(entity);
}
public async Task<Models.User?> GetActiveUserAsync(Guid userId) =>
await context.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive);
public async Task<List<Models.Store>> GetStoresByIdsAsync(List<Guid> storeIds) =>
await context.Stores.Where(s => storeIds.Contains(s.Id)).ToListAsync();
public async Task<Models.Store?> GetStoreByIdAsync(Guid storeId) =>
await context.Stores.FindAsync(storeId);
public void UpdateStore(Models.Store store) =>
context.Stores.Update(store);
public async Task<Models.RouteVisitLog?> GetOpenVisitAsync(Guid routeId, Guid storeId) =>
await context.RouteVisitLogs
.Where(v => v.FK_RouteId == routeId && v.FK_StoreId == storeId && v.CheckOutAt == null)
.OrderByDescending(v => v.CheckInAt)
.FirstOrDefaultAsync();
public async Task AddVisitLogAsync(Models.RouteVisitLog log) =>
await context.RouteVisitLogs.AddAsync(log);
public void UpdateVisitLog(Models.RouteVisitLog log) =>
context.RouteVisitLogs.Update(log);
public async Task<List<Models.RouteVisitLog>> GetVisitsByStoreIdAsync(Guid storeId) =>
await context.RouteVisitLogs.Where(v => v.FK_StoreId == storeId).ToListAsync();
}
+173 -5
View File
@@ -1,20 +1,50 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Route;
public class RouteService(IRouteRepository repo, IUnitOfWork uow)
public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int RouteManagerRoleLevel = 20;
private const double DeviationThresholdMeters = 500;
private async Task<bool> CanManageRouteAsync(Guid routeId)
{
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
}
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
{
const double earthRadiusMeters = 6371000;
var dLat = ToRadians((double)(lat2 - lat1));
var dLon = ToRadians((double)(lon2 - lon1));
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(ToRadians((double)lat1)) * Math.Cos(ToRadians((double)lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return earthRadiusMeters * c;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180;
private static RouteResponse ToResponse(Models.Route route) =>
new(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
private static RouteVisitResponse ToVisitResponse(Models.RouteVisitLog log) =>
new(log.Id, log.FK_RouteId, log.FK_StoreId, log.FK_UserId, log.CheckInAt, log.IsDeviationFlagged, log.CheckOutAt);
public async Task<List<RouteResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.IsActive, r.CreatedAt))
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
.ToList();
public async Task<RouteResponse?> GetByIdAsync(Guid id)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
}
public async Task<RouteResponse> CreateAsync(CreateRouteRequest request, CancellationToken ct = default)
@@ -38,7 +68,7 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
return ToResponse(route);
}
public async Task<RouteResponse?> UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default)
@@ -61,7 +91,30 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt);
return ToResponse(route);
}
public async Task<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
var region = await regionRepo.GetByIdAsync(request.RegionId);
if (region is null || !region.IsActive) return null;
route.FK_RegionId = request.RegionId;
await uow.BeginAsync(ct);
try
{
repo.Update(route);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -81,4 +134,119 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
}
return true;
}
public async Task<RouteResponse?> AssignStoresToRouteAsync(Guid id, AssignStoresToRouteRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await CanManageRouteAsync(id)) return null;
var stores = await repo.GetStoresByIdsAsync(request.StoreIds);
if (stores.Count != request.StoreIds.Distinct().Count()) return null;
await uow.BeginAsync(ct);
try
{
foreach (var store in stores)
{
store.FK_RouteId = id;
repo.UpdateStore(store);
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<RouteResponse?> AssignOwnerAsync(Guid id, AssignRouteOwnerRequest request, CancellationToken ct = default)
{
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await CanManageRouteAsync(id)) return null;
var salesRep = await repo.GetActiveUserAsync(request.SalesRepId);
if (salesRep is null) return null;
route.FK_OwnerUserId = request.SalesRepId;
await uow.BeginAsync(ct);
try
{
repo.Update(route);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(route);
}
public async Task<RouteVisitResponse?> RecordCheckInAsync(Guid id, RecordCheckInRequest request, CancellationToken ct = default)
{
if (currentUser.UserId is null) return null;
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
var store = await repo.GetStoreByIdAsync(request.StoreId);
if (store is null || !store.IsActive || store.FK_RouteId != id) return null;
var distance = DistanceMeters(request.Latitude, request.Longitude, store.Latitude, store.Longitude);
var log = new Models.RouteVisitLog
{
FK_RouteId = id,
FK_StoreId = request.StoreId,
FK_UserId = currentUser.UserId.Value,
CheckInAt = request.Timestamp ?? DateTime.Now,
CheckInLatitude = request.Latitude,
CheckInLongitude = request.Longitude,
IsDeviationFlagged = distance > DeviationThresholdMeters
};
await uow.BeginAsync(ct);
try
{
await repo.AddVisitLogAsync(log);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToVisitResponse(log);
}
public async Task<RouteVisitResponse?> RecordCheckOutAsync(Guid id, RecordCheckOutRequest request, CancellationToken ct = default)
{
if (currentUser.UserId is null) return null;
var route = await repo.GetByIdAsync(id);
if (route is null || !route.IsActive) return null;
if (!await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, id)) return null;
var log = await repo.GetOpenVisitAsync(id, request.StoreId);
if (log is null) return null;
log.CheckOutAt = request.Timestamp ?? DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.UpdateVisitLog(log);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToVisitResponse(log);
}
}
@@ -5,7 +5,8 @@ namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync();
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
+17 -9
View File
@@ -1,20 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
[Authorize]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.GetAllAsync(rideId, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +21,6 @@ public class SaleController(SaleService service) : ControllerBase
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
@@ -30,10 +28,20 @@ public class SaleController(SaleService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPatch("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+15 -2
View File
@@ -9,8 +9,21 @@ public class SaleRepository(AppDBContext context) : ISaleRepository
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync() =>
await context.Sales.ToListAsync();
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
await context.Sales
.Where(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
await context.Sales
.CountAsync(s => s.IsActive &&
(rideId == null || s.FK_RideId == rideId) &&
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
+27 -7
View File
@@ -71,14 +71,14 @@ public class SaleService(
sale.IsActive,
sale.CreatedAt);
public async Task<List<SaleResponse>> GetAllAsync()
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
{
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
IEnumerable<Guid>? storeIds = null;
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
}
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
var total = await repo.GetCountAsync(rideId, storeIds);
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
var result = new List<SaleResponse>();
foreach (var sale in sales)
@@ -88,7 +88,7 @@ public class SaleService(
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
@@ -119,6 +119,26 @@ public class SaleService(
return result;
}
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Sale is already cancelled");
sale.Status = SaleStatus.Cancelled;
await uow.BeginAsync(ct);
try { repo.Update(sale); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
[ApiController]
[Route("api/schemes")]
[Authorize]
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
@@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase
[ApiController]
[Route("api/orders")]
[Authorize]
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
@@ -1,16 +1,14 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Stock;
[ApiController]
[Route("api/stock")]
[Authorize]
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
{
// ── Query ──────────────────────────────────────────────────────────────
// ── Query ──────────────────────────────────────────────────────────────
[HttpGet("balances")]
public async Task<IActionResult> GetBalances(
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────
// ── Write ──────────────────────────────────────────────────────────────
[HttpPost("transfer")]
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
@@ -1,15 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store.Kyc;
[ApiController]
[Route("api/stores/{storeId:guid}/kyc-documents")]
[Authorize]
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetByStoreId(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
@@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr
}
[HttpPost]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
{
var result = await service.UploadAsync(storeId, request, ct);
+8 -16
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store;
[ApiController]
[Route("api/stores")]
[Authorize]
public class StoreController(StoreService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "stores.create")]
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "stores.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
@@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
@@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
@@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/kyc/verify")]
[Authorize(Policy = "stores.kyc.verify")]
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, request, ct);
@@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/segment")]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
@@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/visit-history")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
@@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
@@ -102,10 +88,16 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
[HttpPatch("{id:guid}/kyc-status")]
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
return result is null ? NotFound() : Ok(result);
}
}
+2
View File
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchKycStatusRequest(KycStatus KycStatus);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
+11 -6
View File
@@ -5,7 +5,7 @@ using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
@@ -14,8 +14,9 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
//if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
//return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
return true;
}
private static StoreResponse ToResponse(Models.Store s) =>
@@ -37,7 +38,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
//if (!await HasStoreScopeAsync(id)) return null;
return ToResponse(store);
}
@@ -209,8 +210,12 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
// Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists.
return [];
var visits = await routeRepo.GetVisitsByStoreIdAsync(id);
return visits
.OrderByDescending(v => v.CheckInAt)
.Select(v => new StoreVisitHistoryItem(v.Id, v.CheckInAt, v.FK_UserId, v.CheckOutAt is null ? "In Progress" : "Completed"))
.ToList();
}
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Supplier;
[ApiController]
[Route("api/suppliers")]
[Authorize]
public class SupplierController(SupplierService service) : ControllerBase
{
[HttpGet]
@@ -1,11 +1,9 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
[ApiController]
[Authorize]
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims/{id:guid}")]
@@ -32,7 +30,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
[Authorize(Policy = "suppliers.claims.approve")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -46,7 +43,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
[Authorize(Policy = "suppliers.claims.approve")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
+1 -3
View File
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sync;
[ApiController]
[Route("api/sync")]
[Authorize(Policy = "items.create")]
public class SyncController(SyncService service) : ControllerBase
{
[HttpPost("uoms")]
+1 -8
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Uom;
[ApiController]
[Route("api/uoms")]
[Authorize]
public class UomController(UomService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "items.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class UomController(UomService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "items.create")]
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class UomController(UomService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "items.update")]
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class UomController(UomService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "items.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
+1 -10
View File
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.User;
[ApiController]
[Route("api/users")]
[Authorize]
public class UserController(UserService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "users.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "users.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "users.create")]
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "users.update")]
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPut("{id:guid}/role")]
[Authorize(Policy = "users.assign_role")]
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
{
var result = await service.AssignRoleAsync(id, request, ct);
@@ -46,7 +39,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpPut("{id:guid}/scope")]
[Authorize(Policy = "users.assign_scope")]
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
{
var updated = await service.AssignScopeAsync(id, request, ct);
@@ -54,7 +46,6 @@ public class UserController(UserService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "users.deactivate")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var deactivated = await service.DeactivateAsync(id, ct);
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Vehicle;
[ApiController]
[Route("api/vehicles")]
[Authorize]
public class VehicleController(VehicleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "vehicles.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "vehicles.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class VehicleController(VehicleService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "vehicles.create")]
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class VehicleController(VehicleService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "vehicles.update")]
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class VehicleController(VehicleService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "vehicles.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -1,20 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Warehouse;
[ApiController]
[Route("api/warehouses")]
[Authorize]
public class WarehouseController(WarehouseService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "warehouses.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "warehouses.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -22,7 +18,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
}
[HttpPost]
[Authorize(Policy = "warehouses.create")]
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,7 +25,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "warehouses.update")]
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -38,7 +32,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "warehouses.delete")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Distribution.Infra.Authorization;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticatedAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
context.Result = new UnauthorizedResult();
}
}
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Infra.Authorization;
[ApiController]
[Route("api/authorization")]
[Authorize]
public class AuthorizationController(
IPermissionService permissionService,
IScopeAccessService scopeAccessService,
@@ -1,24 +1,23 @@
using Microsoft.AspNetCore.Authorization;
//namespace Distribution.Infra.Authorization;
namespace Distribution.Infra.Authorization;
//public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
//{
// private readonly IPermissionService _permissionService;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly IPermissionService _permissionService;
// public PermissionAuthorizationHandler(IPermissionService permissionService)
// {
// _permissionService = permissionService;
// }
public PermissionAuthorizationHandler(IPermissionService permissionService)
{
_permissionService = permissionService;
}
// protected override async Task HandleRequirementAsync(
// AuthorizationHandlerContext context, PermissionRequirement requirement)
// {
// var roleIdClaim = context.User.FindFirst("role_id")?.Value;
// if (!Guid.TryParse(roleIdClaim, out var roleId))
// return;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var roleIdClaim = context.User.FindFirst("role_id")?.Value;
if (!Guid.TryParse(roleIdClaim, out var roleId))
return;
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
context.Succeed(requirement);
}
}
// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
// context.Succeed(requirement);
// }
//}
@@ -1,32 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
//using Microsoft.Extensions.Options;
namespace Distribution.Infra.Authorization;
//namespace Distribution.Infra.Authorization;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
//public class PermissionPolicyProvider : IAuthorizationPolicyProvider
//{
// private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
// {
// _fallback = new DefaultAuthorizationPolicyProvider(options);
// }
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
// public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
// public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.Contains('.'))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
// {
// if (policyName.Contains('.'))
// {
// var policy = new AuthorizationPolicyBuilder()
// .RequireAuthenticatedUser()
// .AddRequirements(new PermissionRequirement(policyName))
// .Build();
// return Task.FromResult<AuthorizationPolicy?>(policy);
// }
return _fallback.GetPolicyAsync(policyName);
}
}
// return _fallback.GetPolicyAsync(policyName);
// }
//}
@@ -1,13 +1,12 @@
using Microsoft.AspNetCore.Authorization;
//namespace Distribution.Infra.Authorization;
namespace Distribution.Infra.Authorization;
//public class PermissionRequirement : IAuthorizationRequirement
//{
// public string PermissionCode { get; }
public class PermissionRequirement : IAuthorizationRequirement
{
public string PermissionCode { get; }
public PermissionRequirement(string permissionCode)
{
PermissionCode = permissionCode;
}
}
// public PermissionRequirement(string permissionCode)
// {
// PermissionCode = permissionCode;
// }
//}
+44
View File
@@ -0,0 +1,44 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Middleware;
public class AuthMiddleware(RequestDelegate next, IConfiguration config)
{
public async Task InvokeAsync(HttpContext context)
{
var token = context.Request.Headers.Authorization
.ToString()
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase)
.Trim();
if (!string.IsNullOrEmpty(token))
{
var key = config["Jwt:Key"];
if (!string.IsNullOrEmpty(key))
{
try
{
var principal = new JwtSecurityTokenHandler().ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
}, out _);
context.User = principal;
}
catch { }
}
}
await next(context);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRegionManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RegionAgencyMaps",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionAgencyMaps", x => x.Id);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RegionAgencyMaps_Users_FK_AgencyUserId",
column: x => x.FK_AgencyUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalBudgets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BudgetAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalBudgets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalBudgets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTargets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Period = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetValue = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTargets", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTargets_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RegionalTaxRules",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegionalTaxRules", x => x.Id);
table.ForeignKey(
name: "FK_RegionalTaxRules_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_AgencyUserId",
table: "RegionAgencyMaps",
column: "FK_AgencyUserId");
migrationBuilder.CreateIndex(
name: "IX_RegionAgencyMaps_FK_RegionId_FK_AgencyUserId",
table: "RegionAgencyMaps",
columns: new[] { "FK_RegionId", "FK_AgencyUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalBudgets_FK_RegionId_Period",
table: "RegionalBudgets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTargets_FK_RegionId_Period",
table: "RegionalTargets",
columns: new[] { "FK_RegionId", "Period" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegionalTaxRules_FK_RegionId",
table: "RegionalTaxRules",
column: "FK_RegionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RegionAgencyMaps");
migrationBuilder.DropTable(
name: "RegionalBudgets");
migrationBuilder.DropTable(
name: "RegionalTargets");
migrationBuilder.DropTable(
name: "RegionalTaxRules");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddRouteVisitManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "FK_OwnerUserId",
table: "Routes",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.CreateTable(
name: "RouteVisitLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RouteId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CheckInAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CheckInLatitude = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CheckInLongitude = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IsDeviationFlagged = table.Column<bool>(type: "tinyint(1)", nullable: false),
CheckOutAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RouteVisitLogs", x => x.Id);
table.ForeignKey(
name: "FK_RouteVisitLogs_Routes_FK_RouteId",
column: x => x.FK_RouteId,
principalTable: "Routes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RouteVisitLogs_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RouteVisitLogs_Users_FK_UserId",
column: x => x.FK_UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Routes_FK_OwnerUserId",
table: "Routes",
column: "FK_OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_RouteVisitLogs_FK_RouteId",
table: "RouteVisitLogs",
column: "FK_RouteId");
migrationBuilder.CreateIndex(
name: "IX_RouteVisitLogs_FK_StoreId",
table: "RouteVisitLogs",
column: "FK_StoreId");
migrationBuilder.CreateIndex(
name: "IX_RouteVisitLogs_FK_UserId",
table: "RouteVisitLogs",
column: "FK_UserId");
migrationBuilder.AddForeignKey(
name: "FK_Routes_Users_FK_OwnerUserId",
table: "Routes",
column: "FK_OwnerUserId",
principalTable: "Users",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Routes_Users_FK_OwnerUserId",
table: "Routes");
migrationBuilder.DropTable(
name: "RouteVisitLogs");
migrationBuilder.DropIndex(
name: "IX_Routes_FK_OwnerUserId",
table: "Routes");
migrationBuilder.DropColumn(
name: "FK_OwnerUserId",
table: "Routes");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedMasterAndOperationsData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Brands",
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[,]
{
{ new Guid("21000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Coca-Cola" },
{ new Guid("21000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Lays" }
});
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "Id", "AllowCustomerReturn", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000001"), false, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Beverages" },
{ new Guid("20000000-0000-0000-0000-000000000002"), false, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Snacks" }
});
migrationBuilder.InsertData(
table: "Regions",
columns: new[] { "Id", "CreatedAt", "IsActive", "Name" },
values: new object[,]
{
{ new Guid("24000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Western Province" },
{ new Guid("24000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Central Province" }
});
migrationBuilder.InsertData(
table: "Uoms",
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[] { new Guid("22000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Piece" });
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
values: new object[,]
{
{ new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000002"), "Sunil Fernando", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "sunil.fernando" },
{ new Guid("29000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000005"), "Priya Rajapaksa", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "priya.rajapaksa" }
});
migrationBuilder.InsertData(
table: "Warehouses",
columns: new[] { "Id", "CreatedAt", "IsActive", "Location", "Name", "Type", "VehicleId" },
values: new object[,]
{
{ new Guid("27000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Peliyagoda, Colombo", "Colombo Distribution Center", 0, null },
{ new Guid("27000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Katugastota, Kandy", "Kandy Fulfillment Center", 1, null }
});
migrationBuilder.InsertData(
table: "Items",
columns: new[] { "Id", "CostPrice", "CreatedAt", "Description", "ExternalErpItemId", "FK_BaseUomId", "FK_BrandId", "FK_CategoryId", "FK_SubCategoryId", "IsActive", "LastSyncedAt", "Name", "SalePrice", "Sku", "SyncSource" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Coca-Cola 500ml bottle", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000001"), new Guid("20000000-0000-0000-0000-000000000001"), null, true, null, "Coca-Cola 500ml", 60m, "BEV-COKE-500", 0 },
{ new Guid("23000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Coca-Cola 1.5L bottle", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000001"), new Guid("20000000-0000-0000-0000-000000000001"), null, true, null, "Coca-Cola 1.5L", 120m, "BEV-COKE-1500", 0 },
{ new Guid("23000000-0000-0000-0000-000000000003"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Lays Classic Salted 52g pack", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000002"), new Guid("20000000-0000-0000-0000-000000000002"), null, true, null, "Lays Classic 52g", 50m, "SNK-LAYS-52", 0 },
{ new Guid("23000000-0000-0000-0000-000000000004"), null, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Lays Family Pack 150g", null, new Guid("22000000-0000-0000-0000-000000000001"), new Guid("21000000-0000-0000-0000-000000000002"), new Guid("20000000-0000-0000-0000-000000000002"), null, true, null, "Lays Family Pack 150g", 150m, "SNK-LAYS-FAM", 0 }
});
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
values: new object[,]
{
{ new Guid("29000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Nimal Perera", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "nimal.perera" },
{ new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Kamal Silva", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "kamal.silva" },
{ new Guid("29000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Ruwan Jayasuriya", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "ruwan.jayasuriya" },
{ new Guid("29000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000001"), "Priyantha Kumara", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "priyantha.kumara" }
});
migrationBuilder.InsertData(
table: "Vehicles",
columns: new[] { "Id", "CreatedAt", "DefaultWarehouseId", "IsActive", "NumberPlate", "RegNumber", "Type" },
values: new object[,]
{
{ new Guid("28000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("27000000-0000-0000-0000-000000000001"), true, "CAB-1234", "WP-CAB-1234", 2 },
{ new Guid("28000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("27000000-0000-0000-0000-000000000002"), true, "KY-5678", "CP-KY-5678", 1 }
});
migrationBuilder.InsertData(
table: "DriverProfiles",
columns: new[] { "Id", "CreatedAt", "FK_UserId", "IsActive", "LicenseExpiry", "LicenseNumber", "MedicalClearanceExpiry" },
values: new object[,]
{
{ new Guid("30000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), true, new DateTime(2027, 6, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), "B1234567", new DateTime(2027, 1, 31, 0, 0, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("30000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), true, new DateTime(2027, 9, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), "B7654321", new DateTime(2027, 3, 31, 0, 0, 0, 0, DateTimeKind.Unspecified) }
});
migrationBuilder.InsertData(
table: "Routes",
columns: new[] { "Id", "CreatedAt", "EndPoint", "FK_OwnerUserId", "FK_RegionId", "IsActive", "Length", "RouteName", "StartPoint" },
values: new object[,]
{
{ new Guid("25000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Dehiwala", new Guid("29000000-0000-0000-0000-000000000002"), new Guid("24000000-0000-0000-0000-000000000001"), true, 18.5m, "Colombo City Route", "Colombo Fort" },
{ new Guid("25000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Negombo", new Guid("29000000-0000-0000-0000-000000000002"), new Guid("24000000-0000-0000-0000-000000000001"), true, 32.0m, "Negombo Coastal Route", "Wattala" },
{ new Guid("25000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Peradeniya", new Guid("29000000-0000-0000-0000-000000000003"), new Guid("24000000-0000-0000-0000-000000000002"), true, 12.0m, "Kandy Town Route", "Kandy Lake" },
{ new Guid("25000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), "Matale", new Guid("29000000-0000-0000-0000-000000000003"), new Guid("24000000-0000-0000-0000-000000000002"), true, 26.0m, "Matale Route", "Kandy" }
});
migrationBuilder.InsertData(
table: "DeliveryRuns",
columns: new[] { "Id", "ActivatedAt", "ClosedAt", "CreatedAt", "FK_DriverProfileId", "FK_RouteId", "FK_SourceWarehouseId", "FK_VehicleId", "IsActive", "Meterend", "Meterstart", "RideType", "StartedAt", "Status" },
values: new object[,]
{
{ new Guid("31000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 8, 15, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 14, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000001"), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), true, 10550, 10500, 1, new DateTime(2026, 8, 25, 8, 0, 0, 0, DateTimeKind.Unspecified), 5 },
{ new Guid("31000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 9, 20, 0, 0, DateTimeKind.Unspecified), null, new DateTime(2026, 8, 30, 8, 45, 0, 0, DateTimeKind.Unspecified), new Guid("30000000-0000-0000-0000-000000000002"), new Guid("25000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000002"), new Guid("28000000-0000-0000-0000-000000000002"), true, 0, 20300, 0, new DateTime(2026, 8, 30, 9, 0, 0, 0, DateTimeKind.Unspecified), 2 }
});
migrationBuilder.InsertData(
table: "Stores",
columns: new[] { "Id", "CreatedAt", "CreditAmount", "CreditLimit", "FK_RouteId", "IsActive", "KycStatus", "Latitude", "Longitude", "OneTimeAmount", "OutstandingBalance", "OwnerName", "Segment", "StoreAddress", "StoreName" },
values: new object[,]
{
{ new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 50000m, new Guid("25000000-0000-0000-0000-000000000001"), true, 1, 6.9344m, 79.8428m, 0m, 0m, "Saman Kumara", "Gold", "12 York Street, Colombo Fort", "Fort Mini Mart" },
{ new Guid("26000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 30000m, new Guid("25000000-0000-0000-0000-000000000001"), true, 1, 6.8508m, 79.8654m, 0m, 5000m, "Nadeesha Silva", "Silver", "45 Galle Road, Dehiwala", "Dehiwala Grocery" },
{ new Guid("26000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 40000m, new Guid("25000000-0000-0000-0000-000000000002"), true, 0, 6.9890m, 79.8912m, 0m, 1200m, "Ruwan Gunasekara", "Gold", "88 Negombo Road, Wattala", "Wattala Supermarket" },
{ new Guid("26000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 25000m, new Guid("25000000-0000-0000-0000-000000000002"), true, 0, 7.2094m, 79.8380m, 0m, 0m, "Chamari Peiris", null, "5 Beach Road, Negombo", "Negombo Beach Store" },
{ new Guid("26000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 35000m, new Guid("25000000-0000-0000-0000-000000000003"), true, 1, 7.2906m, 80.6337m, 0m, 0m, "Dinesh Wickramasinghe", "Key Account", "3 Lake Road, Kandy", "Kandy Lake View Store" },
{ new Guid("26000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0m, 20000m, new Guid("25000000-0000-0000-0000-000000000004"), true, 0, 7.4675m, 80.6234m, 0m, 0m, "Ishara Bandara", null, "20 Main Street, Matale", "Matale Central Store" }
});
migrationBuilder.InsertData(
table: "LoadingSheets",
columns: new[] { "Id", "CreatedAt", "DispatchConfirmedAt", "DispatchConfirmedBy", "FK_RideId", "FK_SourceWarehouseId", "IsActive", "ReceiptConfirmedAt", "ReceiptConfirmedBy", "Status" },
values: new object[] { new Guid("35000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 7, 45, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 25, 14, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000004"), 2 });
migrationBuilder.InsertData(
table: "Sales",
columns: new[] { "Id", "CompletedAt", "CreatedAt", "CreatedByUserId", "FK_RideId", "FK_StoreId", "FulfillImmediately", "IsActive", "PaymentStatus", "SaleDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("32000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), false, true, 2, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 1, 3000m },
{ new Guid("32000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("31000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), false, true, 0, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "LoadingSheetItems",
columns: new[] { "Id", "FK_ItemId", "FK_LoadingSheetId", "QuantityDispatched", "QuantityReceived", "QuantityResolved" },
values: new object[,]
{
{ new Guid("36000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("35000000-0000-0000-0000-000000000001"), 50m, 50m, null },
{ new Guid("36000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("35000000-0000-0000-0000-000000000001"), 30m, 30m, null }
});
migrationBuilder.InsertData(
table: "Orders",
columns: new[] { "Id", "CreatedAt", "FK_SaleId", "FK_StoreId", "FulfilledAt", "IsActive", "OrderDate", "Status", "TotalAmount" },
values: new object[,]
{
{ new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 13, 0, 0, 0, DateTimeKind.Unspecified), true, new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 2, 3000m },
{ new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000002"), new Guid("26000000-0000-0000-0000-000000000005"), null, true, new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), 0, 1200m }
});
migrationBuilder.InsertData(
table: "DeliveryTasks",
columns: new[] { "Id", "ConfirmedAt", "CreatedAt", "CustomerConfirmed", "FK_OrderId", "FK_RideId", "IsActive", "Note", "Sequence", "Status" },
values: new object[] { new Guid("37000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 30, 10, 5, 0, 0, DateTimeKind.Unspecified), false, new Guid("33000000-0000-0000-0000-000000000002"), new Guid("31000000-0000-0000-0000-000000000002"), true, null, 1, 0 });
migrationBuilder.InsertData(
table: "OrderItems",
columns: new[] { "FK_ItemId", "FK_OrderId", "CreatedAt", "Id", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[,]
{
{ new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000001"), true, 30, 30, 60m },
{ new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000002"), true, 24, 24, 50m },
{ new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 30, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34000000-0000-0000-0000-000000000003"), true, 10, 0, 120m }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "DeliveryTasks",
keyColumn: "Id",
keyValue: new Guid("37000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "LoadingSheetItems",
keyColumn: "Id",
keyValue: new Guid("36000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "OrderItems",
keyColumns: new[] { "FK_ItemId", "FK_OrderId" },
keyValues: new object[] { new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000006"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000006"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "LoadingSheets",
keyColumn: "Id",
keyValue: new Guid("35000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Orders",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Brands",
keyColumn: "Id",
keyValue: new Guid("21000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Brands",
keyColumn: "Id",
keyValue: new Guid("21000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Sales",
keyColumn: "Id",
keyValue: new Guid("32000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Sales",
keyColumn: "Id",
keyValue: new Guid("32000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Uoms",
keyColumn: "Id",
keyValue: new Guid("22000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DeliveryRuns",
keyColumn: "Id",
keyValue: new Guid("31000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Stores",
keyColumn: "Id",
keyValue: new Guid("26000000-0000-0000-0000-000000000005"));
migrationBuilder.DeleteData(
table: "DriverProfiles",
keyColumn: "Id",
keyValue: new Guid("30000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DriverProfiles",
keyColumn: "Id",
keyValue: new Guid("30000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Routes",
keyColumn: "Id",
keyValue: new Guid("25000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Vehicles",
keyColumn: "Id",
keyValue: new Guid("28000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Vehicles",
keyColumn: "Id",
keyValue: new Guid("28000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Regions",
keyColumn: "Id",
keyValue: new Guid("24000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Regions",
keyColumn: "Id",
keyValue: new Guid("24000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000005"));
migrationBuilder.DeleteData(
table: "Warehouses",
keyColumn: "Id",
keyValue: new Guid("27000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Warehouses",
keyColumn: "Id",
keyValue: new Guid("27000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("29000000-0000-0000-0000-000000000001"));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,498 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedRemainingTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "DriverComplianceConfigs",
columns: new[] { "Id", "EnforceLicenseExpiry", "EnforceMedicalClearance", "UpdatedAt", "UpdatedBy" },
values: new object[] { new Guid("64000000-0000-0000-0000-000000000001"), true, true, new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001") });
migrationBuilder.InsertData(
table: "Payments",
columns: new[] { "Id", "Amount", "CollectedAt", "CollectedBy", "CreatedAt", "FK_SaleId", "FK_StoreId", "IsActive", "Method" },
values: new object[] { new Guid("47000000-0000-0000-0000-000000000001"), 3000m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("32000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), true, 0 });
migrationBuilder.InsertData(
table: "PriceLists",
columns: new[] { "Id", "CreatedAt", "EffectiveFrom", "EffectiveTo", "FK_RegionId", "IsActive", "Name" },
values: new object[] { new Guid("39000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "Western Province Price List" });
migrationBuilder.InsertData(
table: "RegionalBudgets",
columns: new[] { "Id", "BudgetAmount", "CreatedAt", "FK_RegionId", "IsActive", "Period" },
values: new object[] { new Guid("58000000-0000-0000-0000-000000000001"), 200000m, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "2026-08" });
migrationBuilder.InsertData(
table: "RegionalTargets",
columns: new[] { "Id", "CreatedAt", "FK_RegionId", "IsActive", "Period", "TargetValue" },
values: new object[] { new Guid("59000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "2026-08", 500000m });
migrationBuilder.InsertData(
table: "RegionalTaxRules",
columns: new[] { "Id", "CreatedAt", "FK_RegionId", "IsActive", "RuleConfig" },
values: new object[] { new Guid("60000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("24000000-0000-0000-0000-000000000001"), true, "{\"rate\": 0.15}" });
migrationBuilder.InsertData(
table: "RideCancelRequests",
columns: new[] { "Id", "ApprovedAt", "ApprovedBy", "FK_RideId", "Reason", "RequestedAt", "RequestedBy", "Status", "TypeCode" },
values: new object[] { new Guid("65000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 30, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000002"), "Reported minor engine issue, resolved after inspection", new DateTime(2026, 8, 30, 9, 10, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), 2, 0 });
migrationBuilder.InsertData(
table: "RouteVisitLogs",
columns: new[] { "Id", "CheckInAt", "CheckInLatitude", "CheckInLongitude", "CheckOutAt", "CreatedAt", "FK_RouteId", "FK_StoreId", "FK_UserId", "IsActive", "IsDeviationFlagged" },
values: new object[,]
{
{ new Guid("66000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), 6.9344m, 79.8428m, new DateTime(2026, 8, 25, 9, 25, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000002"), true, false },
{ new Guid("66000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 10, 0, 0, 0, DateTimeKind.Unspecified), 6.9000m, 79.8654m, null, new DateTime(2026, 8, 25, 10, 0, 0, 0, DateTimeKind.Unspecified), new Guid("25000000-0000-0000-0000-000000000001"), new Guid("26000000-0000-0000-0000-000000000002"), new Guid("29000000-0000-0000-0000-000000000002"), true, true }
});
migrationBuilder.InsertData(
table: "SaleReturns",
columns: new[] { "Id", "CreatedAt", "CreatedByUserId", "FK_OrderId", "IsActive", "Mode", "TotalCreditAmount" },
values: new object[] { new Guid("51000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000001"), true, 0, 300m });
migrationBuilder.InsertData(
table: "SaleRoutes",
columns: new[] { "Id", "CreatedAt", "IsActive" },
values: new object[,]
{
{ new Guid("69000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true },
{ new Guid("69000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), true }
});
migrationBuilder.InsertData(
table: "Schemes",
columns: new[] { "Id", "AgentLimitExempt", "BaselineExpectedSales", "CreatedAt", "DateRangeFrom", "DateRangeTo", "EligibleSegment", "FK_FreeItemId", "FK_RegionId", "FK_TriggerItemId", "FreeQty", "IsActive", "Name", "RegionId", "RuleConfig", "RuleType", "TriggerQty" },
values: new object[,]
{
{ new Guid("48000000-0000-0000-0000-000000000001"), false, 500000m, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("23000000-0000-0000-0000-000000000001"), new Guid("24000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), 1m, true, "Buy 10 Coke Get 1 Free", null, null, 0, 10 },
{ new Guid("48000000-0000-0000-0000-000000000002"), false, null, new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2026, 12, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("23000000-0000-0000-0000-000000000004"), new Guid("24000000-0000-0000-0000-000000000002"), null, 1m, true, "Snack Combo Bundle", null, null, 3, null }
});
migrationBuilder.InsertData(
table: "StockBalances",
columns: new[] { "Id", "FK_ItemId", "LocationId", "LocationType", "Quantity", "UpdatedAt" },
values: new object[,]
{
{ new Guid("61000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 150m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("61000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 90m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("61000000-0000-0000-0000-000000000003"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 20m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) }
});
migrationBuilder.InsertData(
table: "StockMovements",
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ItemId", "FromLocationId", "FromLocationType", "Quantity", "ReasonCode", "ReferenceId", "ToLocationId", "ToLocationType", "Type" },
values: new object[,]
{
{ new Guid("62000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), null, null, 200m, null, new Guid("43000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 6 },
{ new Guid("62000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 25, 7, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), 0, 50m, null, new Guid("35000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 5 },
{ new Guid("62000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000002"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("28000000-0000-0000-0000-000000000001"), 1, 30m, null, new Guid("32000000-0000-0000-0000-000000000001"), null, null, 0 }
});
migrationBuilder.InsertData(
table: "StoreKycDocuments",
columns: new[] { "Id", "CreatedAt", "DocumentType", "FK_StoreId", "FileReference", "IsActive", "UploadedAt" },
values: new object[,]
{
{ new Guid("63000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), "Business Registration", new Guid("26000000-0000-0000-0000-000000000001"), "seed/kyc/fort-mini-mart-brc.pdf", true, new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified) },
{ new Guid("63000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), "NIC", new Guid("26000000-0000-0000-0000-000000000003"), "seed/kyc/wattala-owner-nic.pdf", true, new DateTime(2026, 8, 12, 0, 0, 0, 0, DateTimeKind.Unspecified) }
});
migrationBuilder.InsertData(
table: "Suppliers",
columns: new[] { "Id", "ContactInfo", "CreatedAt", "IsActive", "Name", "PerformanceScore" },
values: new object[,]
{
{ new Guid("38000000-0000-0000-0000-000000000001"), "+94 11 234 5678", new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Ceylon Beverages Ltd", 4.2m },
{ new Guid("38000000-0000-0000-0000-000000000002"), "+94 11 876 5432", new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), true, "Snack Foods (Pvt) Ltd", null }
});
migrationBuilder.InsertData(
table: "Uoms",
columns: new[] { "Id", "CreatedAt", "ExternalErpId", "IsActive", "Name" },
values: new object[] { new Guid("67000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, true, "Case" });
migrationBuilder.InsertData(
table: "UserRegionScopes",
columns: new[] { "FK_RegionId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
values: new object[] { new Guid("24000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000001"), true });
migrationBuilder.InsertData(
table: "UserRouteScopes",
columns: new[] { "FK_RouteId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
values: new object[] { new Guid("25000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000002"), true });
migrationBuilder.InsertData(
table: "UserStoreScopes",
columns: new[] { "FK_StoreId", "FK_UserId", "CreatedAt", "Id", "IsActive" },
values: new object[] { new Guid("26000000-0000-0000-0000-000000000005"), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("68000000-0000-0000-0000-000000000003"), true });
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "CreatedAt", "FK_ReportsToUserId", "FK_RoleId", "FullName", "IsActive", "PasswordHash", "UpdatedAt", "Username" },
values: new object[] { new Guid("70000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), null, new Guid("10000000-0000-0000-0000-000000000006"), "Channa De Silva", true, "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==", null, "channa.desilva" });
migrationBuilder.InsertData(
table: "Claims",
columns: new[] { "Id", "CreatedAt", "Details", "FK_AgencyUserId", "IsActive", "SettledAt", "SettledByUserId", "SettlementAmount", "Status" },
values: new object[] { new Guid("53000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 27, 0, 0, 0, 0, DateTimeKind.Unspecified), "Distributor margin claim for Q3 promotional shortfall", new Guid("70000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 28, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000006"), 15000m, 1 });
migrationBuilder.InsertData(
table: "CreditNotes",
columns: new[] { "Id", "Amount", "CreatedAt", "FK_SaleReturnId", "IsActive", "IssuedAt" },
values: new object[] { new Guid("54000000-0000-0000-0000-000000000001"), 300m, new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("51000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified) });
migrationBuilder.InsertData(
table: "PriceListItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_PriceListId", "IsActive", "MinPrice", "Price" },
values: new object[,]
{
{ new Guid("40000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("39000000-0000-0000-0000-000000000001"), true, 55m, 60m },
{ new Guid("40000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("39000000-0000-0000-0000-000000000001"), true, 45m, 50m }
});
migrationBuilder.InsertData(
table: "PurchaseOrders",
columns: new[] { "Id", "CancelReason", "CreatedAt", "FK_CreatedByUserId", "FK_SupplierId", "IsActive", "Status" },
values: new object[,]
{
{ new Guid("41000000-0000-0000-0000-000000000001"), null, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("38000000-0000-0000-0000-000000000001"), true, 2 },
{ new Guid("41000000-0000-0000-0000-000000000002"), null, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("38000000-0000-0000-0000-000000000002"), true, 2 }
});
migrationBuilder.InsertData(
table: "RegionAgencyMaps",
columns: new[] { "Id", "CreatedAt", "FK_AgencyUserId", "FK_RegionId", "IsActive" },
values: new object[] { new Guid("57000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("70000000-0000-0000-0000-000000000001"), new Guid("24000000-0000-0000-0000-000000000001"), true });
migrationBuilder.InsertData(
table: "SaleReturnItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_SaleReturnId", "IsActive", "Quantity", "ReasonCode", "UnitPrice" },
values: new object[] { new Guid("52000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 26, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("51000000-0000-0000-0000-000000000001"), true, 5m, 1, 60m });
migrationBuilder.InsertData(
table: "SchemeBundleComponents",
columns: new[] { "Id", "FK_ItemId", "FK_SchemeId", "Qty" },
values: new object[] { new Guid("49000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("48000000-0000-0000-0000-000000000002"), 5 });
migrationBuilder.InsertData(
table: "SchemeRedemptions",
columns: new[] { "Id", "DiscountAmount", "FK_FreeItemId", "FK_OrderId", "FK_SchemeId", "FieldInvoiceId", "FreeItemCostValue", "FreeQty", "RedeemedAt" },
values: new object[] { new Guid("50000000-0000-0000-0000-000000000001"), 0m, new Guid("23000000-0000-0000-0000-000000000001"), new Guid("33000000-0000-0000-0000-000000000001"), new Guid("48000000-0000-0000-0000-000000000001"), null, 40m, 1m, new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified) });
migrationBuilder.InsertData(
table: "UomConversions",
columns: new[] { "Id", "FK_FromUomId", "FK_ItemId", "FK_ToUomId", "Factor" },
values: new object[] { new Guid("67000000-0000-0000-0000-000000000002"), new Guid("67000000-0000-0000-0000-000000000001"), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("22000000-0000-0000-0000-000000000001"), 24m });
migrationBuilder.InsertData(
table: "GoodsReceipts",
columns: new[] { "Id", "CreatedAt", "FK_PurchaseOrderId", "FK_ReceivedByUserId", "FK_WarehouseId", "IsActive", "ReceivedAt" },
values: new object[] { new Guid("43000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("41000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("27000000-0000-0000-0000-000000000001"), true, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified) });
migrationBuilder.InsertData(
table: "PurchaseOrderItems",
columns: new[] { "Id", "CancelledQuantity", "CreatedAt", "FK_ItemId", "FK_PurchaseOrderId", "IsActive", "IsCancelled", "Quantity", "ReceivedQuantity", "UnitCost" },
values: new object[,]
{
{ new Guid("42000000-0000-0000-0000-000000000001"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000001"), new Guid("41000000-0000-0000-0000-000000000001"), true, false, 200, 200, 40m },
{ new Guid("42000000-0000-0000-0000-000000000002"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("41000000-0000-0000-0000-000000000001"), true, false, 100, 98, 80m },
{ new Guid("42000000-0000-0000-0000-000000000003"), 0, new DateTime(2026, 8, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("41000000-0000-0000-0000-000000000002"), true, false, 150, 150, 35m }
});
migrationBuilder.InsertData(
table: "PurchaseReturns",
columns: new[] { "Id", "CreatedAt", "FK_PurchaseOrderId", "IsActive", "Reason" },
values: new object[] { new Guid("45000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("41000000-0000-0000-0000-000000000002"), true, "Damaged in transit" });
migrationBuilder.InsertData(
table: "GoodsReceiptItems",
columns: new[] { "Id", "AcceptedQuantity", "CreatedAt", "FK_GoodsReceiptId", "FK_PurchaseOrderItemId", "IsActive", "RejectedQuantity" },
values: new object[,]
{
{ new Guid("44000000-0000-0000-0000-000000000001"), 200, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43000000-0000-0000-0000-000000000001"), new Guid("42000000-0000-0000-0000-000000000001"), true, 0 },
{ new Guid("44000000-0000-0000-0000-000000000002"), 98, new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43000000-0000-0000-0000-000000000001"), new Guid("42000000-0000-0000-0000-000000000002"), true, 2 }
});
migrationBuilder.InsertData(
table: "PurchaseReturnItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_PurchaseReturnId", "IsActive", "Quantity" },
values: new object[] { new Guid("46000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000003"), new Guid("45000000-0000-0000-0000-000000000001"), true, 10 });
migrationBuilder.InsertData(
table: "SupplierClaims",
columns: new[] { "Id", "CreatedAt", "Details", "EvidenceUrl", "FK_GoodsReceiptId", "FK_PurchaseOrderId", "IsActive", "RaisedByUserId", "ResolutionAmount", "ResolutionNotes", "ResolutionType", "ResolvedAt", "ResolvedByUserId", "Status" },
values: new object[] { new Guid("55000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), "2 units of Coke 1.5L received damaged", null, new Guid("43000000-0000-0000-0000-000000000001"), new Guid("41000000-0000-0000-0000-000000000001"), true, new Guid("29000000-0000-0000-0000-000000000001"), 160m, "Credited against next invoice", 1, new DateTime(2026, 8, 16, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000006"), 1 });
migrationBuilder.InsertData(
table: "SupplierClaimItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_SupplierClaimId", "IsActive", "IssueType", "Quantity" },
values: new object[] { new Guid("56000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("55000000-0000-0000-0000-000000000001"), true, 0, 2m });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Claims",
keyColumn: "Id",
keyValue: new Guid("53000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "CreditNotes",
keyColumn: "Id",
keyValue: new Guid("54000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "DriverComplianceConfigs",
keyColumn: "Id",
keyValue: new Guid("64000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "GoodsReceiptItems",
keyColumn: "Id",
keyValue: new Guid("44000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PriceListItems",
keyColumn: "Id",
keyValue: new Guid("40000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PriceListItems",
keyColumn: "Id",
keyValue: new Guid("40000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "PurchaseOrderItems",
keyColumn: "Id",
keyValue: new Guid("42000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "PurchaseReturnItems",
keyColumn: "Id",
keyValue: new Guid("46000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionAgencyMaps",
keyColumn: "Id",
keyValue: new Guid("57000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionalBudgets",
keyColumn: "Id",
keyValue: new Guid("58000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionalTargets",
keyColumn: "Id",
keyValue: new Guid("59000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RegionalTaxRules",
keyColumn: "Id",
keyValue: new Guid("60000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RideCancelRequests",
keyColumn: "Id",
keyValue: new Guid("65000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RouteVisitLogs",
keyColumn: "Id",
keyValue: new Guid("66000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "RouteVisitLogs",
keyColumn: "Id",
keyValue: new Guid("66000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SaleReturnItems",
keyColumn: "Id",
keyValue: new Guid("52000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SaleRoutes",
keyColumn: "Id",
keyValue: new Guid("69000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SaleRoutes",
keyColumn: "Id",
keyValue: new Guid("69000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SchemeBundleComponents",
keyColumn: "Id",
keyValue: new Guid("49000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SchemeRedemptions",
keyColumn: "Id",
keyValue: new Guid("50000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StockBalances",
keyColumn: "Id",
keyValue: new Guid("61000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StockBalances",
keyColumn: "Id",
keyValue: new Guid("61000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "StockBalances",
keyColumn: "Id",
keyValue: new Guid("61000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "StockMovements",
keyColumn: "Id",
keyValue: new Guid("62000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StockMovements",
keyColumn: "Id",
keyValue: new Guid("62000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "StockMovements",
keyColumn: "Id",
keyValue: new Guid("62000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "StoreKycDocuments",
keyColumn: "Id",
keyValue: new Guid("63000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "StoreKycDocuments",
keyColumn: "Id",
keyValue: new Guid("63000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SupplierClaimItems",
keyColumn: "Id",
keyValue: new Guid("56000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "UomConversions",
keyColumn: "Id",
keyValue: new Guid("67000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "UserRegionScopes",
keyColumns: new[] { "FK_RegionId", "FK_UserId" },
keyValues: new object[] { new Guid("24000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "UserRouteScopes",
keyColumns: new[] { "FK_RouteId", "FK_UserId" },
keyValues: new object[] { new Guid("25000000-0000-0000-0000-000000000001"), new Guid("29000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "UserStoreScopes",
keyColumns: new[] { "FK_StoreId", "FK_UserId" },
keyValues: new object[] { new Guid("26000000-0000-0000-0000-000000000005"), new Guid("29000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "PriceLists",
keyColumn: "Id",
keyValue: new Guid("39000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PurchaseOrderItems",
keyColumn: "Id",
keyValue: new Guid("42000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PurchaseOrderItems",
keyColumn: "Id",
keyValue: new Guid("42000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "PurchaseReturns",
keyColumn: "Id",
keyValue: new Guid("45000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "SaleReturns",
keyColumn: "Id",
keyValue: new Guid("51000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Schemes",
keyColumn: "Id",
keyValue: new Guid("48000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Schemes",
keyColumn: "Id",
keyValue: new Guid("48000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "SupplierClaims",
keyColumn: "Id",
keyValue: new Guid("55000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Uoms",
keyColumn: "Id",
keyValue: new Guid("67000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("70000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "GoodsReceipts",
keyColumn: "Id",
keyValue: new Guid("43000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "PurchaseOrders",
keyColumn: "Id",
keyValue: new Guid("41000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "PurchaseOrders",
keyColumn: "Id",
keyValue: new Guid("41000000-0000-0000-0000-000000000001"));
migrationBuilder.DeleteData(
table: "Suppliers",
keyColumn: "Id",
keyValue: new Guid("38000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Suppliers",
keyColumn: "Id",
keyValue: new Guid("38000000-0000-0000-0000-000000000001"));
}
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionAgencyMap : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
/// <summary>User holding the RegionalAgency role that is mapped to this region.</summary>
public Guid FK_AgencyUserId { get; set; }
[ForeignKey("FK_AgencyUserId")]
public User AgencyUser { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalBudget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal BudgetAmount { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTarget : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string Period { get; set; }
public decimal TargetValue { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RegionalTaxRule : Template
{
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public string RuleConfig { get; set; }
}
}
+4
View File
@@ -12,5 +12,9 @@ namespace Distribution.Models
public Guid FK_RegionId { get; set; }
[ForeignKey("FK_RegionId")]
public Region Region { get; set; }
public Guid? FK_OwnerUserId { get; set; }
[ForeignKey("FK_OwnerUserId")]
public User? OwnerUser { get; set; }
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RouteVisitLog : Template
{
public Guid FK_RouteId { get; set; }
[ForeignKey("FK_RouteId")]
public Route Route { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid FK_UserId { get; set; }
[ForeignKey("FK_UserId")]
public User User { get; set; }
public DateTime CheckInAt { get; set; }
public decimal CheckInLatitude { get; set; }
public decimal CheckInLongitude { get; set; }
public bool IsDeviationFlagged { get; set; } = false;
public DateTime? CheckOutAt { get; set; }
}
}
+23 -65
View File
@@ -1,6 +1,7 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Middleware;
using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.DeliveryRide;
@@ -34,10 +35,7 @@ using Distribution.Features.Stock;
using Distribution.Features.Warehouse;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
@@ -126,69 +124,29 @@ builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
// ── Auth Service connector ───────────────────────────────────────────────────
builder.Services.Configure<AuthServiceOptions>(
builder.Configuration.GetSection(AuthServiceOptions.SectionName));
var jwtSection = builder.Configuration.GetSection("Jwt");
var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured.");
var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml));
var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"];
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtSection["Issuer"],
ValidateAudience = true,
ValidAudience = jwtSection["Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwtSigningKey,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
builder.Services.AddHttpClient<AuthServiceClient>(client =>
{
if (!string.IsNullOrWhiteSpace(authServiceBaseUrl))
client.BaseAddress = new Uri(authServiceBaseUrl);
});
// AuthHex issues the token and owns its claim shape; this app's authorization (permission
// policies, role-level checks) runs against the *local* Role/Permission tables, so every
// validated token is mapped back to a local User/Role once here, not duplicated per
// endpoint. A token for a user with no matching local User record is rejected outright —
// login via AuthHex does not implicitly provision a local user.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var subClaim = context.Principal?.FindFirst("sub")?.Value
?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
builder.Services.AddScoped<IAuthServiceClient, AuthServiceClient>();
if (!Guid.TryParse(subClaim, out var userId))
{
context.Fail("Token is missing a valid user id claim.");
return;
}
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()));
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
{
context.Fail("User not found or inactive.");
return;
}
var identity = (ClaimsIdentity)context.Principal!.Identity!;
identity.AddClaim(new Claim("username", user.Username));
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
}
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddSwaggerGen();
@@ -202,9 +160,9 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
//app.UseHttpsRedirection();
app.UseCors();
app.UseMiddleware<AuthMiddleware>();
app.MapControllers();
app.Run();
+8 -1
View File
@@ -1,6 +1,7 @@
{
"Urls": "http://localhost:5089",
"ConnectionStrings": {
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution_db;User=kalana;Password=kalana@hexdive"
},
"Logging": {
"LogLevel": {
@@ -9,6 +10,9 @@
}
},
"AllowedHosts": "*",
"Cors": {
"AllowedOrigins": [ "http://localhost:3000" ]
},
"Jwt": {
"PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
"Audience": "AuthHexClient",
@@ -21,5 +25,8 @@
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
},
"AuthService": {
"BaseUrl": ""
}
}
+4
View File
@@ -29,6 +29,10 @@ function reference (`assignAreaToRegion`, `getRegionHierarchy`) and any region-s
via the API even though `Store.FK_RouteId` exists on the model. Blocks `assignOutletsToRoute` (§3)
and route/region-scoped authorization for Stores and Orders.
> **Resolved** — `StoreDTOs.cs`'s `CreateStoreRequest`/`UpdateStoreRequest` now carry `RouteId`,
> and a dedicated bulk-assign endpoint (`POST /api/routes/{id}/stores`) was added in
> [11_route_sales_management.md](./11_route_sales_management.md).
### 3. Role Controller — Missing Authorization
`RoleController`'s GET endpoints carry only `[Authorize]`, with no `[Authorize(Policy = "...")]`
+17 -4
View File
@@ -64,16 +64,29 @@ When `FulfillImmediately = false`, no stock movement — order stays Pending for
**Feature folder:** `Features/Payment/`
### API Endpoints
### Sales API Endpoints
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
| GET | `/api/sales` | Any | List sales — supports `?rideId=`, `?page=`, `?pageSize=` query params |
| POST | `/api/sales/{saleId:guid}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{saleId:guid}/payments` | Any | List all payments for a sale |
**`GET /api/sales` filter params:**
| Param | Type | Description |
|---|---|---|
| `rideId` | `Guid?` | Filter to sales created on a specific ride |
| `page` | `int` | Page number (default 1) |
| `pageSize` | `int` | Items per page (default 20) |
Returns `PagedResponse<SaleResponse>`: `{ page, pageSize, total, items }`.
Scope rules still apply: non-global users only see sales for stores in their scope.
### Payment Creation Logic
On `POST /api/sales/{id}/payments`:
On `POST /api/sales/{saleId:guid}/payments`:
1. Validate sale exists and is not `Cancelled`.
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
+4 -3
View File
@@ -48,7 +48,7 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr
| `logAuditEvent(userId, action, entityType, entityId)` (§18) | Internal — called by every mutating service, not directly exposed |
| `checkLicenseCompliance(entityId, licenseType)` (§18) | `GET /api/compliance/{entityType}/{entityId}/license-check` |
| `generateComplianceReport(period)` (§18) | `GET /api/compliance/report?period=...` |
| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Triggered from route check-in/out data (§3 of the function reference; not yet in any numbered step — see [11_route_sales_management.md](./11_route_sales_management.md)) |
| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Not implemented. The underlying data now exists — `POST /api/routes/{id}/visits/check-in` ([11_route_sales_management.md](./11_route_sales_management.md)) computes and stores `IsDeviationFlagged` on each `RouteVisitLog` row (Haversine distance vs. the store's registered lat/long) — but nothing reads that flag to raise an alert/anomaly flag yet. |
| — | `GET /api/audit-log?entityType=...&entityId=...` |
| — | `GET /api/anomaly-flags?status=Open` |
@@ -57,8 +57,9 @@ adds the automated pre-check (`ReturnWithoutSale`) that runs before a human appr
## Automated Checks & Flags
- Returns without a matching prior sale (`ReturnWithoutSale`).
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — depends on GPS check-in/out
data from [11_route_sales_management.md](./11_route_sales_management.md).
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — the GPS check-in/out data this
depends on now exists ([11_route_sales_management.md](./11_route_sales_management.md)'s
`RouteVisitLog.IsDeviationFlagged`), but no anomaly flag or alert reads it yet.
- Agents with consistently high variance rates across closed rides (`HighVarianceRate`) — reads
`Variance Record` history from [07_ride_reconciliation.md](./07_ride_reconciliation.md).
- Cash collected below invoiced amount (`CashShortfall`) — reads `Payment Record` from
+59 -20
View File
@@ -1,6 +1,6 @@
# 10 — Region Management (Beyond CRUD)
**Status**: Not Started
**Status**: Implemented (except `assignAreaToRegion`, blocked on the `Area` entity)
**Depends On**: 00 — Existing Gap Fixes
---
@@ -24,48 +24,87 @@ CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
### Regional Target / Budget
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
Implemented as two separate entities, `RegionalTarget` and `RegionalBudget`
(`Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`), each with `FK_RegionId`, `Period`
(string, `yyyy-MM`), and `TargetValue`/`BudgetAmount` respectively. A unique index on
`(FK_RegionId, Period)` means each region has at most one target and one budget row per period;
setting the same period again updates it in place rather than inserting a duplicate.
### Agency-Region Map
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
`Role` (see Open Questions).
Implemented as `RegionAgencyMap` (`Models/RegionAgencyMap.cs`): `FK_RegionId`, `FK_AgencyUserId`,
unique on the pair. "Regional Agency" stayed a `Role`, not a distinct entity (see Open Questions,
resolved) — `FK_AgencyUserId` points at an existing `User` who holds the `RegionalAgency` role,
the same convention already used by `Claim.FK_AgencyUserId` (§18).
### Regional Tax Rule
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
Implemented as `RegionalTaxRule` (`Models/RegionalTaxRule.cs`): `FK_RegionId` (unique — one rule
per region), `RuleConfig` (raw JSON string). Nothing in pricing/tax calculation reads this yet;
it's stored for a future pricing-rule integration.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
| Function reference | Endpoint | Status |
|---|---|---|
| `assignAreaToRegion(areaId, regionId)` | — | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). Stand-in: `PUT /api/routes/{id}/region` assigns/reassigns an existing route directly to a region (see [11_route_sales_management.md](./11_route_sales_management.md)). |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `mapAgencyToRegion(agencyId, regionId)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/manage` (consolidated, see below) | Implemented |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` | Implemented (region → routes → store ids; no `Area` level) |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` | Implemented (real-time aggregation; see Open Questions, resolved) |
All request/response DTOs live in `Features/Region/RegionDTOs.cs`; routing and access checks are
in `RegionController.cs` and `RegionService.cs`.
The four `set*`/`mapAgencyToRegion` actions above were later consolidated into a single
`PUT /api/regions/{id}/manage` endpoint (`RegionService.ManageAsync`) — every field on the request
is optional, and only the sub-action(s) whose fields are present get applied, so one call can
update just one thing (as the frontend's `region-manage-sheet.tsx` does, one button per field) or
several at once. See [BACKEND_API_REFERENCE.md §6.5](../../sharedDocs/BACKEND_API_REFERENCE.md#65-regions)
for the exact request/response shape.
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
- ⚠️ **Temporarily disabled**: `RegionService.CanManageRegionAsync` (the gate on `ManageAsync`) was
stubbed to always return `true` — role-based restriction is deliberately deferred pending a
decision on the role model for this endpoint (tracked as a `TODO` in `RegionService.cs`). The
design below is what it enforced before being stubbed out, and what it should return to:
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across
regions. Role level ≥ 60 (`RegionalAgency`) allowed unconditionally, role level ≥ 50
(`RegionalManager`) requires a matching `UserRegionScope` row for that region, anything lower
rejected.
- `agencyUserId` (when supplied to `manage`) still rejects the target user if their role level is
below the `RegionalAgency` threshold (60) — you can only map a user who actually holds (or
outranks) that role. This check was not part of the disabled gate and remains active.
---
## Open Questions
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
third party with its own record), or is the role sufficient?
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
- ~~Is "Regional Agency" a business entity distinct from the `RegionalAgency` role...?~~ Resolved:
kept as a `Role` on `User`; no new agency entity, consistent with `Claim.FK_AgencyUserId`.
- ~~Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?~~ Resolved:
real-time aggregation from `Order`/`Store`/`Route` for now; revisit if/when region-wide dashboard
queries become a performance concern at scale.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- Backend: `Models/RegionalTarget.cs`, `Models/RegionalBudget.cs`, `Models/RegionAgencyMap.cs`,
`Models/RegionalTaxRule.cs`, `Data/AppDBContext.cs` (DbSets + unique indexes),
`Features/Region/{IRegionRepository,RegionRepository,RegionService,RegionController,RegionDTOs}.cs`.
- Frontend: `distribution_fe/types/api.ts` (response types), `distribution_fe/lib/api/master.ts`
(`regions.setTarget/setBudget/mapAgency/setTaxRule/hierarchy/dashboard`), and a new
`RegionManageSheet` dialog (`distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx`)
wired into the regions table's row actions.
- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the
shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules`
all exist.
+34 -3
View File
@@ -1,6 +1,6 @@
# 11 — Route & Sales Visit Management
**Status**: Not Started
**Status**: Partially Implemented
**Depends On**: 00 — Existing Gap Fixes
---
@@ -62,10 +62,41 @@ access grant which may cover multiple users (e.g. a Supervisor overseeing severa
## Open Questions
- Is route sequence optimization (`optimizeRouteSequence`) a manual reorder endpoint, or does it
need actual distance/priority-based computation (external mapping API)?
need actual distance/priority-based computation (external mapping API)? — **Deferred.** The
endpoint is stubbed (`501 Not Implemented`) until this is decided.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
Implemented: `assignOutletsToRoute`, `assignRouteToSalesRep`/`reassignRoute` (same endpoint),
`recordRouteCheckIn`/`recordRouteCheckOut`, and `getStoreVisitHistory` (§4, now reads from
`RouteVisitLog`). See [BACKEND_API_REFERENCE.md §6.6](../../sharedDocs/BACKEND_API_REFERENCE.md#66-routes)
for full request/response shapes.
Deliberately deferred, not oversights:
- **`optimizeRouteSequence`** — stubbed as `501 Not Implemented`; no distance/priority computation
exists (see Open Questions above).
- **`getRouteAdherence`** / **`getRoutePerformance`** — not built; the underlying metrics
(adherence %, strike rate, missed-visit count) were never defined, so there was nothing concrete
to implement against.
- **`sendRouteDeviationAlert`** — check-in computes and stores a basic `IsDeviationFlagged` boolean
(Haversine distance > 500m from the store's registered `Latitude`/`Longitude`) but does not call
any alert/notification pipeline, since [09_fraud_controls.md](./09_fraud_controls.md) itself is
not implemented yet.
Data model differs slightly from the original sketch above: `RouteId`/`OwnerUserId` became a
nullable `FK_OwnerUserId` directly on `Route` (no separate ownership table needed for a
single-owner field), and `Route Visit Log`'s `CheckInGps` became two decimal columns
(`CheckInLatitude`/`CheckInLongitude`) to match the existing `Store.Latitude`/`Longitude` shape
and make the distance calculation straightforward.
Authorization for the new endpoints follows the `RegionService.CanManageRegionAsync` pattern
(scope check via `IScopeAccessService`, no dedicated `routes.*` permission code): bulk store
assignment and owner assignment require `IScopeAccessService.HasRouteScopeAsync` plus role level
≥ 20 (Supervisor+); check-in/check-out require route scope only, no role floor, since any rep with
access to the route should be able to log their own visits.
Frontend: implemented in `distribution_fe` — an admin "Manage" dialog on the Routes page (owner +
bulk store assignment), a read-only visit-history dialog on the Stores page, and a new rep-facing
`/sales-visits` page for GPS check-in/check-out (see `distribution_fe/app/(dashboard)/sales-visits/`).
+5 -5
View File
@@ -100,14 +100,14 @@ One migration needed:
### New
| Method | Route | Permission | Notes |
|---|---|---|---|
| POST | /api/stores/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
| GET | /api/stores/{id}/kyc-documents | stores.view | Lists documents for a store |
| POST | /api/stores/{storeId:guid}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
| GET | /api/stores/{storeId:guid}/kyc-documents | stores.view | Lists documents for a store |
| PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` |
| PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above |
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
| GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit |
| PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` |
| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands |
| GET | /api/stores/{id}/visit-history | stores.view | Wired to Step 11 — returns a plain array (not paginated) read from `RouteVisitLog` |
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme |
| GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables |
@@ -115,7 +115,7 @@ One migration needed:
## Request / Response Shapes
### POST `/api/stores/{id}/kyc-documents` (multipart/form-data)
### POST `/api/stores/{storeId:guid}/kyc-documents` (multipart/form-data)
Fields: `documentType` (string), `file` (binary).
Response:
@@ -247,7 +247,7 @@ ever belong to a Store and have no independent lifecycle.
### KYC upload logic (pseudo)
```
POST /api/stores/{id}/kyc-documents — multipart (documentType, file):
POST /api/stores/{storeId:guid}/kyc-documents — multipart (documentType, file):
1. Load Store by id, reject if missing/inactive or caller lacks store scope
2. Ensure wwwroot/uploads/kyc/{storeId}/ exists
3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}"
+1 -1
View File
@@ -347,7 +347,7 @@ GET /api/schemes/eligible?storeId=...&orderId=...
**Apply it:**
```
POST /api/orders/{orderId}/apply-scheme
POST /api/orders/{id}/apply-scheme
{ "schemeId": "..." }
```
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
+1 -1
View File
@@ -46,7 +46,7 @@ home until this step. There is no invoice, payment, or GL entity for warehouse-f
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` |
| `recordPayment(invoiceId, amount, method)` | Current implemented path is `POST /api/sales/{saleId:guid}/payments` for field-sale collections; `POST /api/invoices/{id}/payments` remains the target endpoint for this future invoice-based finance step |
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |

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