From 889ca0795dfff2e933ef97c510f1e0575e5d8eb6 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:44:12 +0530 Subject: [PATCH 01/14] 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 --- Distribution/Data/AppDBContext.cs | 26 ++++++++++++++++++++++++++ Distribution/Models/RegionAgencyMap.cs | 16 ++++++++++++++++ Distribution/Models/RegionalBudget.cs | 14 ++++++++++++++ Distribution/Models/RegionalTarget.cs | 14 ++++++++++++++ Distribution/Models/RegionalTaxRule.cs | 13 +++++++++++++ 5 files changed, 83 insertions(+) create mode 100644 Distribution/Models/RegionAgencyMap.cs create mode 100644 Distribution/Models/RegionalBudget.cs create mode 100644 Distribution/Models/RegionalTarget.cs create mode 100644 Distribution/Models/RegionalTaxRule.cs diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index 94dd9de..79a4e39 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -56,6 +56,10 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet Claims { get; set; } public DbSet SupplierClaims { get; set; } public DbSet SupplierClaimItems { get; set; } + public DbSet RegionalTargets { get; set; } + public DbSet RegionalBudgets { get; set; } + public DbSet RegionAgencyMaps { get; set; } + public DbSet RegionalTaxRules { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -373,6 +377,28 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(t => t.FK_OrderId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(t => new { t.FK_RegionId, t.Period }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(b => new { b.FK_RegionId, b.Period }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(m => new { m.FK_RegionId, m.FK_AgencyUserId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(m => m.AgencyUser) + .WithMany() + .HasForeignKey(m => m.FK_AgencyUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(r => r.FK_RegionId) + .IsUnique(); + Seed.RoleAndPermissionSeed.Apply(modelBuilder); } } diff --git a/Distribution/Models/RegionAgencyMap.cs b/Distribution/Models/RegionAgencyMap.cs new file mode 100644 index 0000000..a91df42 --- /dev/null +++ b/Distribution/Models/RegionAgencyMap.cs @@ -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; } + + /// User holding the RegionalAgency role that is mapped to this region. + public Guid FK_AgencyUserId { get; set; } + [ForeignKey("FK_AgencyUserId")] + public User AgencyUser { get; set; } + } +} diff --git a/Distribution/Models/RegionalBudget.cs b/Distribution/Models/RegionalBudget.cs new file mode 100644 index 0000000..3922b20 --- /dev/null +++ b/Distribution/Models/RegionalBudget.cs @@ -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; } + } +} diff --git a/Distribution/Models/RegionalTarget.cs b/Distribution/Models/RegionalTarget.cs new file mode 100644 index 0000000..e5e6f82 --- /dev/null +++ b/Distribution/Models/RegionalTarget.cs @@ -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; } + } +} diff --git a/Distribution/Models/RegionalTaxRule.cs b/Distribution/Models/RegionalTaxRule.cs new file mode 100644 index 0000000..84d2ab2 --- /dev/null +++ b/Distribution/Models/RegionalTaxRule.cs @@ -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; } + } +} From 39eab2e3a26121e5fe89fa2435d4becc1be66811 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:45:35 +0530 Subject: [PATCH 02/14] 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 --- .../Features/Region/IRegionRepository.cs | 22 +++ .../Features/Region/RegionController.cs | 42 +++++ Distribution/Features/Region/RegionDTOs.cs | 29 +++ .../Features/Region/RegionRepository.cs | 56 ++++++ Distribution/Features/Region/RegionService.cs | 170 +++++++++++++++++- 5 files changed, 318 insertions(+), 1 deletion(-) diff --git a/Distribution/Features/Region/IRegionRepository.cs b/Distribution/Features/Region/IRegionRepository.cs index 8eccc6f..43e0382 100644 --- a/Distribution/Features/Region/IRegionRepository.cs +++ b/Distribution/Features/Region/IRegionRepository.cs @@ -7,4 +7,26 @@ public interface IRegionRepository Task AddAsync(Models.Region entity); void Update(Models.Region entity); void Remove(Models.Region entity); + + Task GetTargetAsync(Guid regionId, string period); + Task AddTargetAsync(Models.RegionalTarget target); + void UpdateTarget(Models.RegionalTarget target); + + Task GetBudgetAsync(Guid regionId, string period); + Task AddBudgetAsync(Models.RegionalBudget budget); + void UpdateBudget(Models.RegionalBudget budget); + + Task GetAgencyMapAsync(Guid regionId, Guid agencyUserId); + Task AddAgencyMapAsync(Models.RegionAgencyMap map); + + Task GetTaxRuleAsync(Guid regionId); + Task AddTaxRuleAsync(Models.RegionalTaxRule rule); + void UpdateTaxRule(Models.RegionalTaxRule rule); + + Task GetUserRoleLevelAsync(Guid userId); + + Task> GetRoutesAsync(Guid regionId); + Task> GetStoresByRouteIdsAsync(List routeIds); + + Task> GetOrdersForDashboardAsync(Guid regionId, DateTime periodStart, DateTime periodEndExclusive); } diff --git a/Distribution/Features/Region/RegionController.cs b/Distribution/Features/Region/RegionController.cs index 61d33b8..7fe260a 100644 --- a/Distribution/Features/Region/RegionController.cs +++ b/Distribution/Features/Region/RegionController.cs @@ -37,4 +37,46 @@ public class RegionController(RegionService service) : ControllerBase var deleted = await service.DeleteAsync(id, ct); return deleted ? NoContent() : NotFound(); } + + [HttpPut("{id:guid}/targets/{period}")] + public async Task SetTarget(Guid id, string period, SetRegionalTargetRequest request, CancellationToken ct) + { + var result = await service.SetTargetAsync(id, period, request, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("{id:guid}/budgets/{period}")] + public async Task SetBudget(Guid id, string period, SetRegionalBudgetRequest request, CancellationToken ct) + { + var result = await service.SetBudgetAsync(id, period, request, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("{id:guid}/agencies")] + public async Task MapAgency(Guid id, MapAgencyToRegionRequest request, CancellationToken ct) + { + var result = await service.MapAgencyAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("{id:guid}/tax-rule")] + public async Task SetTaxRule(Guid id, SetRegionalTaxRuleRequest request, CancellationToken ct) + { + var result = await service.SetTaxRuleAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/hierarchy")] + public async Task GetHierarchy(Guid id) + { + var result = await service.GetHierarchyAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/dashboard")] + public async Task GetDashboard(Guid id, [FromQuery] string period) + { + var result = await service.GetDashboardAsync(id, period); + return result is null ? NotFound() : Ok(result); + } } diff --git a/Distribution/Features/Region/RegionDTOs.cs b/Distribution/Features/Region/RegionDTOs.cs index 828e5a3..08a1f5c 100644 --- a/Distribution/Features/Region/RegionDTOs.cs +++ b/Distribution/Features/Region/RegionDTOs.cs @@ -5,3 +5,32 @@ public record CreateRegionRequest(string Name); public record UpdateRegionRequest(string Name); public record RegionResponse(Guid Id, string Name, bool IsActive, DateTime CreatedAt); + +public record SetRegionalTargetRequest(decimal TargetValue); + +public record SetRegionalBudgetRequest(decimal BudgetAmount); + +public record MapAgencyToRegionRequest(Guid AgencyUserId); + +public record SetRegionalTaxRuleRequest(string RuleConfig); + +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); + +public record RegionHierarchyRouteItem(Guid RouteId, string RouteName, List StoreIds); + +public record RegionHierarchyResponse(Guid RegionId, string RegionName, List Routes); + +public record RegionDashboardResponse( + Guid RegionId, + string Period, + decimal? TargetValue, + decimal? BudgetAmount, + int OrderCount, + decimal ActualSales, + DateTime? LastOrderDate); diff --git a/Distribution/Features/Region/RegionRepository.cs b/Distribution/Features/Region/RegionRepository.cs index f6f6efb..4e357e0 100644 --- a/Distribution/Features/Region/RegionRepository.cs +++ b/Distribution/Features/Region/RegionRepository.cs @@ -22,4 +22,60 @@ public class RegionRepository(AppDBContext context) : IRegionRepository entity.IsActive = false; context.Regions.Update(entity); } + + public async Task GetTargetAsync(Guid regionId, string period) => + await context.RegionalTargets.FirstOrDefaultAsync(t => + t.FK_RegionId == regionId && t.Period == period && t.IsActive); + + 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 GetBudgetAsync(Guid regionId, string period) => + await context.RegionalBudgets.FirstOrDefaultAsync(b => + b.FK_RegionId == regionId && b.Period == period && b.IsActive); + + 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 GetAgencyMapAsync(Guid regionId, Guid agencyUserId) => + await context.RegionAgencyMaps.FirstOrDefaultAsync(m => + m.FK_RegionId == regionId && m.FK_AgencyUserId == agencyUserId && m.IsActive); + + public async Task AddAgencyMapAsync(Models.RegionAgencyMap map) => + await context.RegionAgencyMaps.AddAsync(map); + + public async Task GetTaxRuleAsync(Guid regionId) => + await context.RegionalTaxRules.FirstOrDefaultAsync(r => r.FK_RegionId == regionId && r.IsActive); + + 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 GetUserRoleLevelAsync(Guid userId) => + await context.Users + .Where(u => u.Id == userId && u.IsActive) + .Select(u => (int?)u.Role.Level) + .FirstOrDefaultAsync(); + + public async Task> GetRoutesAsync(Guid regionId) => + await context.Routes.Where(r => r.FK_RegionId == regionId && r.IsActive).ToListAsync(); + + public async Task> GetStoresByRouteIdsAsync(List routeIds) => + await context.Stores.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive).ToListAsync(); + + public async Task> 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(); } diff --git a/Distribution/Features/Region/RegionService.cs b/Distribution/Features/Region/RegionService.cs index 532b763..8a88406 100644 --- a/Distribution/Features/Region/RegionService.cs +++ b/Distribution/Features/Region/RegionService.cs @@ -1,9 +1,20 @@ +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 RegionalManagerRoleLevel = 50; + private const int RegionalAgencyRoleLevel = 60; + + private async Task CanManageRegionAsync(Guid regionId) + { + if (currentUser.RoleLevel >= RegionalAgencyRoleLevel) return true; + if (currentUser.RoleLevel < RegionalManagerRoleLevel || currentUser.UserId is null) return false; + return await scopeAccess.HasRegionScopeAsync(currentUser.UserId.Value, regionId); + } + public async Task> GetAllAsync() => (await repo.GetAllAsync()) .Where(r => r.IsActive) @@ -70,4 +81,161 @@ public class RegionService(IRegionRepository repo, IUnitOfWork uow) } return true; } + + public async Task SetTargetAsync(Guid regionId, string period, SetRegionalTargetRequest request, CancellationToken ct = default) + { + var region = await repo.GetByIdAsync(regionId); + if (region is null || !region.IsActive) return null; + if (!await CanManageRegionAsync(regionId)) return null; + + var target = await repo.GetTargetAsync(regionId, period); + await uow.BeginAsync(ct); + try + { + if (target is null) + { + target = new Models.RegionalTarget { FK_RegionId = regionId, Period = period, TargetValue = request.TargetValue }; + await repo.AddTargetAsync(target); + } + else + { + target.TargetValue = request.TargetValue; + repo.UpdateTarget(target); + } + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return new RegionalTargetResponse(regionId, period, target.TargetValue); + } + + public async Task SetBudgetAsync(Guid regionId, string period, SetRegionalBudgetRequest request, CancellationToken ct = default) + { + var region = await repo.GetByIdAsync(regionId); + if (region is null || !region.IsActive) return null; + if (!await CanManageRegionAsync(regionId)) return null; + + var budget = await repo.GetBudgetAsync(regionId, period); + await uow.BeginAsync(ct); + try + { + if (budget is null) + { + budget = new Models.RegionalBudget { FK_RegionId = regionId, Period = period, BudgetAmount = request.BudgetAmount }; + await repo.AddBudgetAsync(budget); + } + else + { + budget.BudgetAmount = request.BudgetAmount; + repo.UpdateBudget(budget); + } + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return new RegionalBudgetResponse(regionId, period, budget.BudgetAmount); + } + + public async Task MapAgencyAsync(Guid regionId, MapAgencyToRegionRequest request, CancellationToken ct = default) + { + var region = await repo.GetByIdAsync(regionId); + if (region is null || !region.IsActive) return null; + if (!await CanManageRegionAsync(regionId)) return null; + + var agencyRoleLevel = await repo.GetUserRoleLevelAsync(request.AgencyUserId); + if (agencyRoleLevel is null || agencyRoleLevel < RegionalAgencyRoleLevel) return null; + + var existing = await repo.GetAgencyMapAsync(regionId, request.AgencyUserId); + if (existing is not null) return new RegionAgencyMapResponse(regionId, request.AgencyUserId); + + var map = new Models.RegionAgencyMap { FK_RegionId = regionId, FK_AgencyUserId = request.AgencyUserId }; + await uow.BeginAsync(ct); + try + { + await repo.AddAgencyMapAsync(map); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return new RegionAgencyMapResponse(regionId, request.AgencyUserId); + } + + public async Task SetTaxRuleAsync(Guid regionId, SetRegionalTaxRuleRequest request, CancellationToken ct = default) + { + var region = await repo.GetByIdAsync(regionId); + if (region is null || !region.IsActive) return null; + if (!await CanManageRegionAsync(regionId)) return null; + + var rule = await repo.GetTaxRuleAsync(regionId); + await uow.BeginAsync(ct); + try + { + if (rule is null) + { + rule = new Models.RegionalTaxRule { FK_RegionId = regionId, RuleConfig = request.RuleConfig }; + await repo.AddTaxRuleAsync(rule); + } + else + { + rule.RuleConfig = request.RuleConfig; + repo.UpdateTaxRule(rule); + } + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return new RegionalTaxRuleResponse(regionId, rule.RuleConfig); + } + + public async Task 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 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); + + 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)); + } } From 4e12e9f8f1468aaaad5b51152cba09ebb2c5f798 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:45:43 +0530 Subject: [PATCH 03/14] 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 --- Distribution.Tests/Services/RegionServiceTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Distribution.Tests/Services/RegionServiceTests.cs b/Distribution.Tests/Services/RegionServiceTests.cs index 0b384cd..358cca7 100644 --- a/Distribution.Tests/Services/RegionServiceTests.cs +++ b/Distribution.Tests/Services/RegionServiceTests.cs @@ -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 _repo = new(); private readonly Mock _uow = new(); + private readonly Mock _currentUser = new(); + private readonly Mock _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") => From df27d58089206d703be37298d098488531eeaf42 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:45:55 +0530 Subject: [PATCH 04/14] 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 --- distribution_fe/lib/api/master.ts | 24 +++++++++++++++++ distribution_fe/types/api.ts | 44 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/distribution_fe/lib/api/master.ts b/distribution_fe/lib/api/master.ts index 2cf514e..5fec4f2 100644 --- a/distribution_fe/lib/api/master.ts +++ b/distribution_fe/lib/api/master.ts @@ -2,6 +2,12 @@ import { api } from '@/lib/api' import type { PaginatedResponse, RegionResponse, + RegionalTargetResponse, + RegionalBudgetResponse, + RegionAgencyMapResponse, + RegionalTaxRuleResponse, + RegionHierarchyResponse, + RegionDashboardResponse, RouteResponse, VehicleResponse, WarehouseResponse, @@ -96,6 +102,24 @@ export const regions = { remove: (id: string) => api.delete(`/regions/${id}`), + + setTarget: (id: string, period: string, targetValue: number) => + api.put(`/regions/${id}/targets/${period}`, { targetValue }).then(r => r.data), + + setBudget: (id: string, period: string, budgetAmount: number) => + api.put(`/regions/${id}/budgets/${period}`, { budgetAmount }).then(r => r.data), + + mapAgency: (id: string, agencyUserId: string) => + api.post(`/regions/${id}/agencies`, { agencyUserId }).then(r => r.data), + + setTaxRule: (id: string, ruleConfig: string) => + api.put(`/regions/${id}/tax-rule`, { ruleConfig }).then(r => r.data), + + hierarchy: (id: string) => + api.get(`/regions/${id}/hierarchy`).then(r => r.data), + + dashboard: (id: string, period?: string) => + api.get(`/regions/${id}/dashboard`, { params: { period } }).then(r => r.data), } // ─── Routes ─────────────────────────────────────────────────────────────────── diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 31f2162..8f6a317 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -85,6 +85,50 @@ export interface RouteResponse { createdAt: string } +export interface RegionalTargetResponse { + regionId: string + period: string + targetValue: number +} + +export interface RegionalBudgetResponse { + regionId: string + period: string + budgetAmount: number +} + +export interface RegionAgencyMapResponse { + regionId: string + agencyUserId: string +} + +export interface RegionalTaxRuleResponse { + regionId: string + ruleConfig: string +} + +export interface RegionHierarchyRouteItem { + routeId: string + routeName: string + storeIds: string[] +} + +export interface RegionHierarchyResponse { + regionId: string + regionName: string + routes: RegionHierarchyRouteItem[] +} + +export interface RegionDashboardResponse { + regionId: string + period: string + targetValue: number | null + budgetAmount: number | null + orderCount: number + actualSales: number + lastOrderDate: string | null +} + // ─── Vehicles / Warehouses ─────────────────────────────────────────────────── export interface VehicleResponse { From 9febae6d63e456b07d2adccb9983657c0f638b62 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:46:05 +0530 Subject: [PATCH 05/14] 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 --- .../_components/region-manage-sheet.tsx | 276 ++++++++++++++++++ .../regions/_components/regions-client.tsx | 22 +- 2 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx diff --git a/distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx b/distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx new file mode 100644 index 0000000..8d19d60 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/regions/_components/region-manage-sheet.tsx @@ -0,0 +1,276 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { + RegionResponse, + UserResponse, + RegionDashboardResponse, + RegionHierarchyResponse, +} from '@/types/api' + +interface RegionManageSheetProps { + open: boolean + onClose: () => void + region: RegionResponse | null +} + +function currentPeriod() { + const now = new Date() + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` +} + +export function RegionManageSheet({ open, onClose, region }: RegionManageSheetProps) { + const [period, setPeriod] = useState(currentPeriod()) + const [targetValue, setTargetValue] = useState('') + const [budgetAmount, setBudgetAmount] = useState('') + const [ruleConfig, setRuleConfig] = useState('') + const [agencyUserId, setAgencyUserId] = useState('') + const [agencyUsers, setAgencyUsers] = useState([]) + const [dashboard, setDashboard] = useState(null) + const [hierarchy, setHierarchy] = useState(null) + const [saving, setSaving] = useState(null) + + useEffect(() => { + if (!open || !region) return + + setPeriod(currentPeriod()) + setTargetValue('') + setBudgetAmount('') + setRuleConfig('') + setAgencyUserId('') + + usersApi.list({ page: 1, pageSize: 200 }) + .then((res) => setAgencyUsers(getPaginatedItems(res).filter((u) => u.roleName === 'RegionalAgency'))) + .catch(() => toast.add({ title: 'Failed to load agency users', type: 'error' })) + + regionsApi.hierarchy(region.id) + .then(setHierarchy) + .catch(() => toast.add({ title: 'Failed to load hierarchy', type: 'error' })) + }, [open, region]) + + useEffect(() => { + if (!open || !region) return + regionsApi.dashboard(region.id, period) + .then(setDashboard) + .catch(() => toast.add({ title: 'Failed to load dashboard', type: 'error' })) + }, [open, region, period]) + + if (!region) return null + + async function handleSetTarget() { + const value = parseFloat(targetValue) + if (Number.isNaN(value)) return + setSaving('target') + try { + await regionsApi.setTarget(region!.id, period, value) + toast.add({ title: 'Target updated', type: 'success' }) + regionsApi.dashboard(region!.id, period).then(setDashboard) + } catch (err) { + toast.add({ title: 'Failed to set target', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + async function handleSetBudget() { + const value = parseFloat(budgetAmount) + if (Number.isNaN(value)) return + setSaving('budget') + try { + await regionsApi.setBudget(region!.id, period, value) + toast.add({ title: 'Budget updated', type: 'success' }) + regionsApi.dashboard(region!.id, period).then(setDashboard) + } catch (err) { + toast.add({ title: 'Failed to set budget', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + async function handleSetTaxRule() { + if (!ruleConfig.trim()) return + setSaving('tax-rule') + try { + await regionsApi.setTaxRule(region!.id, ruleConfig) + toast.add({ title: 'Tax rule updated', type: 'success' }) + } catch (err) { + toast.add({ title: 'Failed to set tax rule', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + async function handleMapAgency() { + if (!agencyUserId) return + setSaving('agency') + try { + await regionsApi.mapAgency(region!.id, agencyUserId) + toast.add({ title: 'Agency mapped to region', type: 'success' }) + setAgencyUserId('') + } catch (err) { + toast.add({ title: 'Failed to map agency', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + return ( + !v && onClose()}> + + + Manage {region.name} + + Targets, budgets, tax rules, agency mapping, and dashboard for this region. + + + +
+
+ + setPeriod(e.target.value)} + className="w-40" + /> +
+ + + +
+ +
+ setTargetValue(e.target.value)} + /> + +
+ {dashboard?.targetValue != null && ( +

Current: {dashboard.targetValue}

+ )} +
+ +
+ +
+ setBudgetAmount(e.target.value)} + /> + +
+ {dashboard?.budgetAmount != null && ( +

Current: {dashboard.budgetAmount}

+ )} +
+ + + +
+ +
+ setRuleConfig(e.target.value)} + /> + +
+
+ + + +
+ +
+ + +
+ {agencyUsers.length === 0 && ( +

No users with the RegionalAgency role found.

+ )} +
+ + + +
+ + {hierarchy && hierarchy.routes.length > 0 ? ( +
    + {hierarchy.routes.map((r) => ( +
  • + {r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'} +
  • + ))} +
+ ) : ( +

No routes under this region yet.

+ )} +
+ + + +
+ + {dashboard ? ( +
+
Orders: {dashboard.orderCount}
+
Actual Sales: {dashboard.actualSales}
+
Target: {dashboard.targetValue ?? '—'}
+
Budget: {dashboard.budgetAmount ?? '—'}
+
+ ) : ( +

Loading dashboard…

+ )} +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx b/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx index 3017e36..2f23e80 100644 --- a/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx +++ b/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx @@ -13,6 +13,7 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api import type { RegionResponse } from '@/types/api' import { RegionSheet } from './region-sheet' +import { RegionManageSheet } from './region-manage-sheet' export function RegionsClient() { const [data, setData] = useState([]) @@ -24,6 +25,9 @@ export function RegionsClient() { const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) + const [manageOpen, setManageOpen] = useState(false) + const [managing, setManaging] = useState(null) + const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) try { @@ -51,6 +55,11 @@ export function RegionsClient() { setSheetOpen(true) } + function openManage(region: RegionResponse) { + setManaging(region) + setManageOpen(true) + } + async function toggleActive(region: RegionResponse) { try { await regionsApi.update(region.id, { isActive: !region.isActive }) @@ -103,6 +112,11 @@ export function RegionsClient() { ], rowActions: { actions: () => [ + { + action: 'confirm', + label: 'Manage targets, budgets & agencies', + onClick: openManage, + }, { action: 'edit', label: 'Edit region', @@ -122,7 +136,7 @@ export function RegionsClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, openEdit, openManage, toggleActive]) return ( <> @@ -156,6 +170,12 @@ export function RegionsClient() { region={editing} onSuccess={() => fetchData(page, pageSize)} /> + + setManageOpen(false)} + region={managing} + /> ) } From 8b84c8aeb3d89910c0b2151207b3b10b24798383 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:51:46 +0530 Subject: [PATCH 06/14] 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 --- Distribution/docs/10_region_management.md | 67 ++++++++++++------ Distribution/docs/mainPlan.md | 14 ++-- sharedDocs/BACKEND_API_REFERENCE.md | 82 +++++++++++++++++++++++ 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/Distribution/docs/10_region_management.md b/Distribution/docs/10_region_management.md index 5ed01e5..b586439 100644 --- a/Distribution/docs/10_region_management.md +++ b/Distribution/docs/10_region_management.md @@ -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,77 @@ 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)) | +| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` | Implemented | +| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented | +| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented | +| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` | 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`. --- ## 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. + set targets/budgets/tax-rule for their own region; only `RegionalAgency` can set them across + regions. Implemented via `RegionService.CanManageRegionAsync`: role level ≥ 60 (`RegionalAgency`) + is allowed unconditionally, role level ≥ 50 (`RegionalManager`) requires a matching + `UserRegionScope` row for that region, anything lower is rejected. The same gate is applied to + `mapAgencyToRegion`. +- `mapAgencyToRegion` additionally 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. --- ## 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. +- Outstanding: an EF Core migration for the four new tables still needs to be generated and + applied (blocked locally by the running dev server locking the build output) — no schema exists + in the database yet even though the code is complete. diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 24f5d6e..d1378a0 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -116,13 +116,13 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. | -| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. | -| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. | -| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. | -| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. | -| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. | -| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. | -| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. | +| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). | +| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. | +| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. | +| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role. | +| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Implemented — `PUT /api/regions/{id}/tax-rule`, backed by the `RegionalTaxRule` entity (`RuleConfig` stored as a raw JSON string, not yet consumed by pricing/tax logic elsewhere). | +| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Partially implemented — `GET /api/regions/{id}/hierarchy` returns the region's routes and each route's store ids; no `Area` level since that entity doesn't exist yet. | +| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Partially implemented — `GET /api/regions/{id}/dashboard?period=...` returns real-time order count, actual sales, target, and budget for the period; no fill-rate or agency-performance metrics yet. | ### 3. Route Management diff --git a/sharedDocs/BACKEND_API_REFERENCE.md b/sharedDocs/BACKEND_API_REFERENCE.md index 31a58a4..350d3ee 100644 --- a/sharedDocs/BACKEND_API_REFERENCE.md +++ b/sharedDocs/BACKEND_API_REFERENCE.md @@ -506,6 +506,88 @@ Array of: } ``` +**Beyond CRUD** — targets, budgets, agency mapping, tax rules, hierarchy, and dashboard. These are +not gated by the `regions.*` permission codes above; access is checked by role level in +`RegionService`: `RegionalManager` (level 50) can act only on a region they have scope over (via +`UserRegionScope`), `RegionalAgency` (level 60) can act on any region. + +| Method | Path | Description | +|---|---|---| +| `PUT` | `/api/regions/{id}/targets/{period}` | Set the sales target for a region for a period (`period` = `yyyy-MM`) | +| `PUT` | `/api/regions/{id}/budgets/{period}` | Set the budget for a region for a period | +| `POST` | `/api/regions/{id}/agencies` | Map an existing user (holding the `RegionalAgency` role) to this region | +| `PUT` | `/api/regions/{id}/tax-rule` | Set/replace the region's tax rule config | +| `GET` | `/api/regions/{id}/hierarchy` | Region's routes and each route's store ids | +| `GET` | `/api/regions/{id}/dashboard?period=yyyy-MM` | Real-time order count/sales/target/budget for the period | + +**Set target request / response:** +```json +// PUT /api/regions/{id}/targets/{period} +{ "targetValue": 500000 } +``` +```json +{ "regionId": "guid", "period": "2026-08", "targetValue": 500000 } +``` + +**Set budget request / response:** +```json +// PUT /api/regions/{id}/budgets/{period} +{ "budgetAmount": 200000 } +``` +```json +{ "regionId": "guid", "period": "2026-08", "budgetAmount": 200000 } +``` + +**Map agency request / response:** +```json +// POST /api/regions/{id}/agencies +{ "agencyUserId": "guid" } // must be a User with the RegionalAgency role +``` +```json +{ "regionId": "guid", "agencyUserId": "guid" } +``` + +**Set tax rule request / response:** +```json +// PUT /api/regions/{id}/tax-rule +{ "ruleConfig": "{\"rate\": 0.15}" } // arbitrary JSON stored as a string; not yet consumed by pricing +``` +```json +{ "regionId": "guid", "ruleConfig": "{\"rate\": 0.15}" } +``` + +**Hierarchy response:** +```json +{ + "regionId": "guid", + "regionName": "string", + "routes": [ + { "routeId": "guid", "routeName": "string", "storeIds": ["guid", "..."] } + ] +} +``` + +**Dashboard response:** +```json +{ + "regionId": "guid", + "period": "2026-08", + "targetValue": 500000, // null if no target set for the period + "budgetAmount": 200000, // null if no budget set for the period + "orderCount": 42, + "actualSales": 187500.00, + "lastOrderDate": "datetime | null" +} +``` + +All four of these endpoints return `404` if the region doesn't exist, the caller lacks the +required region scope, or (for agency mapping) the target user doesn't hold the `RegionalAgency` +role — the response body carries no distinction between "not found" and "forbidden". + +No EF Core migration for `RegionalTarget`/`RegionalBudget`/`RegionAgencyMap`/`RegionalTaxRule` has +been applied yet — these endpoints will 500 against a database that hasn't run the pending +migration. + --- ### 6.6 Routes From f0d7f939ef18fa4b6a2e72ba3f50628812bed3dc Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 30 Aug 2026 22:53:59 +0530 Subject: [PATCH 07/14] 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 --- Distribution/docs/mainPlan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index d1378a0..9881a26 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -54,7 +54,7 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba | 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) | | 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) | | 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) | -| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) | +| 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) | | 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) | | 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) | | 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) | From 320da0e1c9738c143fb2db1f87b2d61c5ce81f75 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 10:38:59 +0530 Subject: [PATCH 08/14] 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 --- ...0831050805_AddRegionManagement.Designer.cs | 2810 +++++++++++++++++ .../20260831050805_AddRegionManagement.cs | 159 + .../Migrations/AppDBContextModelSnapshot.cs | 167 + 3 files changed, 3136 insertions(+) create mode 100644 Distribution/Migrations/20260831050805_AddRegionManagement.Designer.cs create mode 100644 Distribution/Migrations/20260831050805_AddRegionManagement.cs diff --git a/Distribution/Migrations/20260831050805_AddRegionManagement.Designer.cs b/Distribution/Migrations/20260831050805_AddRegionManagement.Designer.cs new file mode 100644 index 0000000..9ec81e1 --- /dev/null +++ b/Distribution/Migrations/20260831050805_AddRegionManagement.Designer.cs @@ -0,0 +1,2810 @@ +// +using System; +using Distribution.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Distribution.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260831050805_AddRegionManagement")] + partial class AddRegionManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Distribution.Models.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("Distribution.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowCustomerReturn") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Distribution.Models.Claim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_AgencyUserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("SettledAt") + .HasColumnType("datetime(6)"); + + b.Property("SettledByUserId") + .HasColumnType("char(36)"); + + b.Property("SettlementAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_AgencyUserId"); + + b.HasIndex("SettledByUserId"); + + b.ToTable("Claims"); + }); + + modelBuilder.Entity("Distribution.Models.CreditNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleReturnId"); + + b.ToTable("CreditNotes"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActivatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_DriverProfileId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("FK_VehicleId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Meterend") + .HasColumnType("int"); + + b.Property("Meterstart") + .HasColumnType("int"); + + b.Property("RideType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_DriverProfileId"); + + b.HasIndex("FK_RouteId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.HasIndex("FK_VehicleId"); + + b.ToTable("DeliveryRuns"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_RideId"); + + b.ToTable("DeliveryTasks"); + }); + + modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EnforceLicenseExpiry") + .HasColumnType("tinyint(1)"); + + b.Property("EnforceMedicalClearance") + .HasColumnType("tinyint(1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("DriverComplianceConfigs"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LicenseExpiry") + .HasColumnType("datetime(6)"); + + b.Property("LicenseNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MedicalClearanceExpiry") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_UserId") + .IsUnique(); + + b.ToTable("DriverProfiles"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ReceivedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_WarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.HasIndex("FK_WarehouseId"); + + b.ToTable("GoodsReceipts"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcceptedQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_GoodsReceiptId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderItemId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RejectedQuantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_GoodsReceiptId"); + + b.HasIndex("FK_PurchaseOrderItemId"); + + b.ToTable("GoodsReceiptItems"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CostPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ExternalErpItemId") + .HasColumnType("int"); + + b.Property("FK_BaseUomId") + .HasColumnType("char(36)"); + + b.Property("FK_BrandId") + .HasColumnType("char(36)"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("FK_SubCategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SalePrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SyncSource") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_BaseUomId"); + + b.HasIndex("FK_BrandId"); + + b.HasIndex("FK_CategoryId"); + + b.HasIndex("FK_SubCategoryId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("ReceiptConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReceiptConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.ToTable("LoadingSheets"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_LoadingSheetId") + .HasColumnType("char(36)"); + + b.Property("QuantityDispatched") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityReceived") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityResolved") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_LoadingSheetId"); + + b.ToTable("LoadingSheetItems"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfilledAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId") + .IsUnique(); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("QuantityFulfilled") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(65,30)"); + + b.HasKey("FK_OrderId", "FK_ItemId"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CollectedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollectedBy") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Method") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Distribution.Models.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("PriceLists"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PriceListId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MinPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PriceListId", "FK_ItemId") + .IsUnique(); + + b.ToTable("PriceListItems"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelReason") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_SupplierId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SupplierId"); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelledQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsCancelled") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("ReceivedQuantity") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.ToTable("PurchaseOrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.ToTable("PurchaseReturns"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PurchaseReturnId"); + + b.ToTable("PurchaseReturnItems"); + }); + + modelBuilder.Entity("Distribution.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_AgencyUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("FK_AgencyUserId"); + + b.HasIndex("FK_RegionId", "FK_AgencyUserId") + .IsUnique(); + + b.ToTable("RegionAgencyMaps"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BudgetAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Period") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId", "Period") + .IsUnique(); + + b.ToTable("RegionalBudgets"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Period") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("TargetValue") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId", "Period") + .IsUnique(); + + b.ToTable("RegionalTargets"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RuleConfig") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId") + .IsUnique(); + + b.ToTable("RegionalTaxRules"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RequestedAt") + .HasColumnType("datetime(6)"); + + b.Property("RequestedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TypeCode") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.ToTable("RideCancelRequests"); + }); + + modelBuilder.Entity("Distribution.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 10, + Name = "SalesRep" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 20, + Name = "Supervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 30, + Name = "AreaManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 40, + Name = "AreaSupervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 50, + Name = "RegionalManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 60, + Name = "RegionalAgency" + }); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FK_PermissionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_RoleId", "FK_PermissionId"); + + b.HasIndex("FK_PermissionId"); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Length") + .HasColumnType("decimal(65,30)"); + + b.Property("RouteName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StartPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfillImmediately") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("SaleDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("TotalCreditAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FK_OrderId"); + + b.ToTable("SaleReturns"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturnItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SaleReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("ReasonCode") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SaleReturnId"); + + b.ToTable("SaleReturnItems"); + }); + + modelBuilder.Entity("Distribution.Models.SaleRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("SaleRoutes"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AgentLimitExempt") + .HasColumnType("tinyint(1)"); + + b.Property("BaselineExpectedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeFrom") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeTo") + .HasColumnType("datetime(6)"); + + b.Property("EligibleSegment") + .HasColumnType("longtext"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("FK_TriggerItemId") + .HasColumnType("char(36)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RuleConfig") + .HasColumnType("longtext"); + + b.Property("RuleType") + .HasColumnType("int"); + + b.Property("TriggerQty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_TriggerItemId"); + + b.HasIndex("RegionId"); + + b.ToTable("Schemes"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SchemeId", "FK_ItemId") + .IsUnique(); + + b.ToTable("SchemeBundleComponents"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("FieldInvoiceId") + .HasColumnType("char(36)"); + + b.Property("FreeItemCostValue") + .HasColumnType("decimal(65,30)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("RedeemedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_SchemeId"); + + b.ToTable("SchemeRedemptions"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("LocationId") + .HasColumnType("char(36)"); + + b.Property("LocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId", "LocationId") + .IsUnique(); + + b.ToTable("StockBalances"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FromLocationId") + .HasColumnType("char(36)"); + + b.Property("FromLocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("ReasonCode") + .HasColumnType("longtext"); + + b.Property("ReferenceId") + .HasColumnType("char(36)"); + + b.Property("ToLocationId") + .HasColumnType("char(36)"); + + b.Property("ToLocationType") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreditAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreditLimit") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("KycStatus") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("decimal(65,30)"); + + b.Property("Longitude") + .HasColumnType("decimal(65,30)"); + + b.Property("OneTimeAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("OutstandingBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("OwnerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Segment") + .HasColumnType("longtext"); + + b.Property("StoreAddress") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FileReference") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("StoreKycDocuments"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_CategoryId"); + + b.ToTable("SubCategories"); + }); + + modelBuilder.Entity("Distribution.Models.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ContactInfo") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PerformanceScore") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EvidenceUrl") + .HasColumnType("longtext"); + + b.Property("FK_GoodsReceiptId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RaisedByUserId") + .HasColumnType("char(36)"); + + b.Property("ResolutionAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("ResolutionNotes") + .HasColumnType("longtext"); + + b.Property("ResolutionType") + .HasColumnType("int"); + + b.Property("ResolvedAt") + .HasColumnType("datetime(6)"); + + b.Property("ResolvedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_GoodsReceiptId"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.HasIndex("RaisedByUserId"); + + b.HasIndex("ResolvedByUserId"); + + b.ToTable("SupplierClaims"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SupplierClaimId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IssueType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SupplierClaimId"); + + b.ToTable("SupplierClaimItems"); + }); + + modelBuilder.Entity("Distribution.Models.Uom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Uoms"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_FromUomId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_ToUomId") + .HasColumnType("char(36)"); + + b.Property("Factor") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FromUomId"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_ToUomId"); + + b.ToTable("UomConversions"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ReportsToUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_ReportsToUserId"); + + b.HasIndex("FK_RoleId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RegionId"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("UserRegionScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RouteId"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("UserRouteScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_StoreId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("UserStoreScopes"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("NumberPlate") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DefaultWarehouseId"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Location") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("VehicleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("Warehouses"); + }); + + modelBuilder.Entity("Distribution.Models.Claim", b => + { + b.HasOne("Distribution.Models.User", "AgencyUser") + .WithMany() + .HasForeignKey("FK_AgencyUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "SettledByUser") + .WithMany() + .HasForeignKey("SettledByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AgencyUser"); + + b.Navigation("SettledByUser"); + }); + + modelBuilder.Entity("Distribution.Models.CreditNote", b => + { + b.HasOne("Distribution.Models.SaleReturn", "SaleReturn") + .WithMany() + .HasForeignKey("FK_SaleReturnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SaleReturn"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.HasOne("Distribution.Models.DriverProfile", "DriverProfile") + .WithMany() + .HasForeignKey("FK_DriverProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("FK_VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DriverProfile"); + + b.Navigation("Route"); + + b.Navigation("SourceWarehouse"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("FK_WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b => + { + b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt") + .WithMany("Items") + .HasForeignKey("FK_GoodsReceiptId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem") + .WithMany() + .HasForeignKey("FK_PurchaseOrderItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GoodsReceipt"); + + b.Navigation("PurchaseOrderItem"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.HasOne("Distribution.Models.Uom", "BaseUom") + .WithMany() + .HasForeignKey("FK_BaseUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Brand", "Brand") + .WithMany() + .HasForeignKey("FK_BrandId"); + + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("FK_SubCategoryId"); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ride"); + + b.Navigation("SourceWarehouse"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.LoadingSheet", "LoadingSheet") + .WithMany("Items") + .HasForeignKey("FK_LoadingSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("LoadingSheet"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.PriceList", "PriceList") + .WithMany("Items") + .HasForeignKey("FK_PriceListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PriceList"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.HasOne("Distribution.Models.Supplier", "Supplier") + .WithMany() + .HasForeignKey("FK_SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany("Items") + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturn", b => + { + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("PurchaseOrder"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.PurchaseReturn", "PurchaseReturn") + .WithMany("Items") + .HasForeignKey("FK_PurchaseReturnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseReturn"); + }); + + modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b => + { + b.HasOne("Distribution.Models.User", "AgencyUser") + .WithMany() + .HasForeignKey("FK_AgencyUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AgencyUser"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalBudget", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTarget", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.HasOne("Distribution.Models.Permission", "Permission") + .WithMany() + .HasForeignKey("FK_PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.HasOne("Distribution.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Ride"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturn", b => + { + b.HasOne("Distribution.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturnItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.SaleReturn", "SaleReturn") + .WithMany("Items") + .HasForeignKey("FK_SaleReturnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("SaleReturn"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Item", "TriggerItem") + .WithMany() + .HasForeignKey("FK_TriggerItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("RegionId"); + + b.Navigation("FreeItem"); + + b.Navigation("Region"); + + b.Navigation("TriggerItem"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("BundleComponents") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("Redemptions") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FreeItem"); + + b.Navigation("Order"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaim", b => + { + b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt") + .WithMany() + .HasForeignKey("FK_GoodsReceiptId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "RaisedByUser") + .WithMany() + .HasForeignKey("RaisedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("GoodsReceipt"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("RaisedByUser"); + + b.Navigation("ResolvedByUser"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim") + .WithMany("Items") + .HasForeignKey("FK_SupplierClaimId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("SupplierClaim"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.HasOne("Distribution.Models.Uom", "FromUom") + .WithMany() + .HasForeignKey("FK_FromUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Item", "Item") + .WithMany("Conversions") + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Uom", "ToUom") + .WithMany() + .HasForeignKey("FK_ToUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.HasOne("Distribution.Models.User", "ReportsTo") + .WithMany() + .HasForeignKey("FK_ReportsToUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReportsTo"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.HasOne("Distribution.Models.Warehouse", null) + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Navigation("Conversions"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Navigation("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturn", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturn", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Navigation("BundleComponents"); + + b.Navigation("Redemptions"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaim", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Distribution/Migrations/20260831050805_AddRegionManagement.cs b/Distribution/Migrations/20260831050805_AddRegionManagement.cs new file mode 100644 index 0000000..ff51231 --- /dev/null +++ b/Distribution/Migrations/20260831050805_AddRegionManagement.cs @@ -0,0 +1,159 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + /// + public partial class AddRegionManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RegionAgencyMaps", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_AgencyUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Period = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + BudgetAmount = table.Column(type: "decimal(65,30)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Period = table.Column(type: "varchar(255)", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + TargetValue = table.Column(type: "decimal(65,30)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RegionId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RuleConfig = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RegionAgencyMaps"); + + migrationBuilder.DropTable( + name: "RegionalBudgets"); + + migrationBuilder.DropTable( + name: "RegionalTargets"); + + migrationBuilder.DropTable( + name: "RegionalTaxRules"); + } + } +} diff --git a/Distribution/Migrations/AppDBContextModelSnapshot.cs b/Distribution/Migrations/AppDBContextModelSnapshot.cs index ea26e45..da94a53 100644 --- a/Distribution/Migrations/AppDBContextModelSnapshot.cs +++ b/Distribution/Migrations/AppDBContextModelSnapshot.cs @@ -851,6 +851,121 @@ namespace Distribution.Migrations b.ToTable("Regions"); }); + modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_AgencyUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("FK_AgencyUserId"); + + b.HasIndex("FK_RegionId", "FK_AgencyUserId") + .IsUnique(); + + b.ToTable("RegionAgencyMaps"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BudgetAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Period") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId", "Period") + .IsUnique(); + + b.ToTable("RegionalBudgets"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Period") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("TargetValue") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId", "Period") + .IsUnique(); + + b.ToTable("RegionalTargets"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RuleConfig") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId") + .IsUnique(); + + b.ToTable("RegionalTaxRules"); + }); + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => { b.Property("Id") @@ -2185,6 +2300,58 @@ namespace Distribution.Migrations b.Navigation("PurchaseReturn"); }); + modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b => + { + b.HasOne("Distribution.Models.User", "AgencyUser") + .WithMany() + .HasForeignKey("FK_AgencyUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AgencyUser"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalBudget", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTarget", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => { b.HasOne("Distribution.Models.DeliveryRun", "Ride") From bfbfe352ee6720e2820e7a3ba217ceca3dc61688 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 11:11:15 +0530 Subject: [PATCH 09/14] 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 --- .../Features/Route/RouteController.cs | 7 ++++++ Distribution/Features/Route/RouteDTOs.cs | 2 ++ Distribution/Features/Route/RouteService.cs | 25 ++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Distribution/Features/Route/RouteController.cs b/Distribution/Features/Route/RouteController.cs index 2813e29..f4ed704 100644 --- a/Distribution/Features/Route/RouteController.cs +++ b/Distribution/Features/Route/RouteController.cs @@ -31,6 +31,13 @@ public class RouteController(RouteService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpPut("{id:guid}/region")] + public async Task 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}")] public async Task Delete(Guid id, CancellationToken ct) { diff --git a/Distribution/Features/Route/RouteDTOs.cs b/Distribution/Features/Route/RouteDTOs.cs index 9ec9018..fb918d1 100644 --- a/Distribution/Features/Route/RouteDTOs.cs +++ b/Distribution/Features/Route/RouteDTOs.cs @@ -5,3 +5,5 @@ 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 AssignRouteToRegionRequest(Guid RegionId); diff --git a/Distribution/Features/Route/RouteService.cs b/Distribution/Features/Route/RouteService.cs index 0d41774..8fce029 100644 --- a/Distribution/Features/Route/RouteService.cs +++ b/Distribution/Features/Route/RouteService.cs @@ -2,7 +2,7 @@ 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) { public async Task> GetAllAsync() => (await repo.GetAllAsync()) @@ -64,6 +64,29 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow) return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt); } + public async Task 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 new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.IsActive, route.CreatedAt); + } + public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var route = await repo.GetByIdAsync(id); From c0432a448fd2d646e8e32e87203e5f1bb62f6d74 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 11:11:25 +0530 Subject: [PATCH 10/14] 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 --- Distribution/docs/10_region_management.md | 8 ++++---- Distribution/docs/mainPlan.md | 4 ++-- sharedDocs/BACKEND_API_REFERENCE.md | 13 +++++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Distribution/docs/10_region_management.md b/Distribution/docs/10_region_management.md index b586439..4137265 100644 --- a/Distribution/docs/10_region_management.md +++ b/Distribution/docs/10_region_management.md @@ -49,7 +49,7 @@ it's stored for a future pricing-rule integration. | 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)) | +| `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}/targets/{period}` | Implemented | | `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` | Implemented | | `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` | Implemented | @@ -95,6 +95,6 @@ in `RegionController.cs` and `RegionService.cs`. (`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. -- Outstanding: an EF Core migration for the four new tables still needs to be generated and - applied (blocked locally by the running dev server locking the build output) — no schema exists - in the database yet even though the code is complete. +- The EF Core migration `20260831050805_AddRegionManagement` has been generated and applied to the + shared database — `RegionalTargets`, `RegionalBudgets`, `RegionAgencyMaps`, and `RegionalTaxRules` + all exist. diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 9881a26..0741239 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -116,7 +116,7 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. | -| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). | +| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)). As a stand-in, a route (the next tier down) can be assigned/reassigned to a region directly via `PUT /api/routes/{id}/region`. | | `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Implemented — `PUT /api/regions/{id}/targets/{period}`, backed by the `RegionalTarget` entity. Restricted to `RegionalManager`+ with region scope, or `RegionalAgency` across any region. | | `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Implemented — `PUT /api/regions/{id}/budgets/{period}`, backed by the `RegionalBudget` entity. Same access rule as `setRegionalTarget`. | | `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Implemented — `POST /api/regions/{id}/agencies`, backed by the `RegionAgencyMap` entity. `RegionalAgency` is still a `Role`, not a distinct agency entity — `agencyId` is the `Guid` of an existing `User` holding that role (consistent with `Claim.FK_AgencyUserId`, §18); the endpoint rejects a user who doesn't hold the role. | @@ -128,7 +128,7 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| -| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). | +| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. | | `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. | | `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. | | `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. | diff --git a/sharedDocs/BACKEND_API_REFERENCE.md b/sharedDocs/BACKEND_API_REFERENCE.md index 350d3ee..6dddc51 100644 --- a/sharedDocs/BACKEND_API_REFERENCE.md +++ b/sharedDocs/BACKEND_API_REFERENCE.md @@ -584,10 +584,6 @@ All four of these endpoints return `404` if the region doesn't exist, the caller required region scope, or (for agency mapping) the target user doesn't hold the `RegionalAgency` role — the response body carries no distinction between "not found" and "forbidden". -No EF Core migration for `RegionalTarget`/`RegionalBudget`/`RegionAgencyMap`/`RegionalTaxRule` has -been applied yet — these endpoints will 500 against a database that hasn't run the pending -migration. - --- ### 6.6 Routes @@ -600,6 +596,7 @@ migration. | `GET` | `/api/routes/{id}` | `routes.view` | Get route by ID | | `POST` | `/api/routes` | `routes.create` | Create route | | `PUT` | `/api/routes/{id}` | `routes.update` | Update route | +| `PUT` | `/api/routes/{id}/region` | `routes.update` | Assign/reassign the route to a region | | `DELETE` | `/api/routes/{id}` | `routes.delete` | Delete route | **Create/Update request:** @@ -627,6 +624,14 @@ migration. } ``` +**Assign to region request:** +```json +// PUT /api/routes/{id}/region +{ "regionId": "guid" } +``` +Returns the same `RouteResponse` shape as above. `404` if the route or the target region doesn't +exist (or either is inactive). + --- ### 6.7 Vehicles From 5c4e1b590a1a1059e4ae875702eebd1610ae0ccf Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 13:00:13 +0530 Subject: [PATCH 11/14] add route visit management --- Distribution/Data/AppDBContext.cs | 1 + .../Features/Route/IRouteRepository.cs | 10 + .../Features/Route/RouteController.cs | 32 + Distribution/Features/Route/RouteDTOs.cs | 12 +- .../Features/Route/RouteRepository.cs | 27 + Distribution/Features/Route/RouteService.cs | 157 +- Distribution/Features/Store/StoreService.cs | 10 +- ...060901_AddRouteVisitManagement.Designer.cs | 2895 +++++++++++++++++ .../20260831060901_AddRouteVisitManagement.cs | 108 + .../Migrations/AppDBContextModelSnapshot.cs | 85 + Distribution/Models/Route.cs | 4 + Distribution/Models/RouteVisitLog.cs | 26 + 12 files changed, 3357 insertions(+), 10 deletions(-) create mode 100644 Distribution/Migrations/20260831060901_AddRouteVisitManagement.Designer.cs create mode 100644 Distribution/Migrations/20260831060901_AddRouteVisitManagement.cs create mode 100644 Distribution/Models/RouteVisitLog.cs diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index 79a4e39..6fb5859 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -60,6 +60,7 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet RegionalBudgets { get; set; } public DbSet RegionAgencyMaps { get; set; } public DbSet RegionalTaxRules { get; set; } + public DbSet RouteVisitLogs { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Distribution/Features/Route/IRouteRepository.cs b/Distribution/Features/Route/IRouteRepository.cs index abf0bb3..c26a144 100644 --- a/Distribution/Features/Route/IRouteRepository.cs +++ b/Distribution/Features/Route/IRouteRepository.cs @@ -7,4 +7,14 @@ public interface IRouteRepository Task AddAsync(Models.Route entity); void Update(Models.Route entity); void Remove(Models.Route entity); + + Task GetActiveUserAsync(Guid userId); + Task> GetStoresByIdsAsync(List storeIds); + Task GetStoreByIdAsync(Guid storeId); + void UpdateStore(Models.Store store); + + Task GetOpenVisitAsync(Guid routeId, Guid storeId); + Task AddVisitLogAsync(Models.RouteVisitLog log); + void UpdateVisitLog(Models.RouteVisitLog log); + Task> GetVisitsByStoreIdAsync(Guid storeId); } diff --git a/Distribution/Features/Route/RouteController.cs b/Distribution/Features/Route/RouteController.cs index f4ed704..4d43bc6 100644 --- a/Distribution/Features/Route/RouteController.cs +++ b/Distribution/Features/Route/RouteController.cs @@ -44,4 +44,36 @@ public class RouteController(RouteService service) : ControllerBase var deleted = await service.DeleteAsync(id, ct); return deleted ? NoContent() : NotFound(); } + + [HttpPost("{id:guid}/stores")] + public async Task 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 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 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 CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct) + { + var result = await service.RecordCheckOutAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } } diff --git a/Distribution/Features/Route/RouteDTOs.cs b/Distribution/Features/Route/RouteDTOs.cs index fb918d1..fe9da7c 100644 --- a/Distribution/Features/Route/RouteDTOs.cs +++ b/Distribution/Features/Route/RouteDTOs.cs @@ -4,6 +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 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); diff --git a/Distribution/Features/Route/RouteRepository.cs b/Distribution/Features/Route/RouteRepository.cs index 9b99afc..6e15d18 100644 --- a/Distribution/Features/Route/RouteRepository.cs +++ b/Distribution/Features/Route/RouteRepository.cs @@ -22,4 +22,31 @@ public class RouteRepository(AppDBContext context) : IRouteRepository entity.IsActive = false; context.Routes.Update(entity); } + + public async Task GetActiveUserAsync(Guid userId) => + await context.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive); + + public async Task> GetStoresByIdsAsync(List storeIds) => + await context.Stores.Where(s => storeIds.Contains(s.Id)).ToListAsync(); + + public async Task GetStoreByIdAsync(Guid storeId) => + await context.Stores.FindAsync(storeId); + + public void UpdateStore(Models.Store store) => + context.Stores.Update(store); + + public async Task 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> GetVisitsByStoreIdAsync(Guid storeId) => + await context.RouteVisitLogs.Where(v => v.FK_StoreId == storeId).ToListAsync(); } diff --git a/Distribution/Features/Route/RouteService.cs b/Distribution/Features/Route/RouteService.cs index 8fce029..aedc652 100644 --- a/Distribution/Features/Route/RouteService.cs +++ b/Distribution/Features/Route/RouteService.cs @@ -1,20 +1,50 @@ +using Distribution.Infra.Authorization; using Distribution.Infra.UoW; namespace Distribution.Features.Route; -public class RouteService(IRouteRepository repo, Region.IRegionRepository regionRepo, 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 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> 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 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 CreateAsync(CreateRouteRequest request, CancellationToken ct = default) @@ -38,7 +68,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region 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 UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default) @@ -61,7 +91,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region 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 AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default) @@ -84,7 +114,7 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region 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 DeleteAsync(Guid id, CancellationToken ct = default) @@ -104,4 +134,119 @@ public class RouteService(IRouteRepository repo, Region.IRegionRepository region } return true; } + + public async Task 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 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 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 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); + } } + diff --git a/Distribution/Features/Store/StoreService.cs b/Distribution/Features/Store/StoreService.cs index 1683175..7068c9f 100644 --- a/Distribution/Features/Store/StoreService.cs +++ b/Distribution/Features/Store/StoreService.cs @@ -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; @@ -209,8 +209,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 CheckSchemeEligibilityAsync(Guid id, Guid schemeId) diff --git a/Distribution/Migrations/20260831060901_AddRouteVisitManagement.Designer.cs b/Distribution/Migrations/20260831060901_AddRouteVisitManagement.Designer.cs new file mode 100644 index 0000000..f754b89 --- /dev/null +++ b/Distribution/Migrations/20260831060901_AddRouteVisitManagement.Designer.cs @@ -0,0 +1,2895 @@ +// +using System; +using Distribution.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Distribution.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260831060901_AddRouteVisitManagement")] + partial class AddRouteVisitManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Distribution.Models.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("Distribution.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowCustomerReturn") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Distribution.Models.Claim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_AgencyUserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("SettledAt") + .HasColumnType("datetime(6)"); + + b.Property("SettledByUserId") + .HasColumnType("char(36)"); + + b.Property("SettlementAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_AgencyUserId"); + + b.HasIndex("SettledByUserId"); + + b.ToTable("Claims"); + }); + + modelBuilder.Entity("Distribution.Models.CreditNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleReturnId"); + + b.ToTable("CreditNotes"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActivatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_DriverProfileId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("FK_VehicleId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Meterend") + .HasColumnType("int"); + + b.Property("Meterstart") + .HasColumnType("int"); + + b.Property("RideType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_DriverProfileId"); + + b.HasIndex("FK_RouteId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.HasIndex("FK_VehicleId"); + + b.ToTable("DeliveryRuns"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_RideId"); + + b.ToTable("DeliveryTasks"); + }); + + modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EnforceLicenseExpiry") + .HasColumnType("tinyint(1)"); + + b.Property("EnforceMedicalClearance") + .HasColumnType("tinyint(1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("DriverComplianceConfigs"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LicenseExpiry") + .HasColumnType("datetime(6)"); + + b.Property("LicenseNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MedicalClearanceExpiry") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_UserId") + .IsUnique(); + + b.ToTable("DriverProfiles"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ReceivedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_WarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.HasIndex("FK_WarehouseId"); + + b.ToTable("GoodsReceipts"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcceptedQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_GoodsReceiptId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderItemId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RejectedQuantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_GoodsReceiptId"); + + b.HasIndex("FK_PurchaseOrderItemId"); + + b.ToTable("GoodsReceiptItems"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CostPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ExternalErpItemId") + .HasColumnType("int"); + + b.Property("FK_BaseUomId") + .HasColumnType("char(36)"); + + b.Property("FK_BrandId") + .HasColumnType("char(36)"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("FK_SubCategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SalePrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SyncSource") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_BaseUomId"); + + b.HasIndex("FK_BrandId"); + + b.HasIndex("FK_CategoryId"); + + b.HasIndex("FK_SubCategoryId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("ReceiptConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReceiptConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.ToTable("LoadingSheets"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_LoadingSheetId") + .HasColumnType("char(36)"); + + b.Property("QuantityDispatched") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityReceived") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityResolved") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_LoadingSheetId"); + + b.ToTable("LoadingSheetItems"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfilledAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId") + .IsUnique(); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("QuantityFulfilled") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(65,30)"); + + b.HasKey("FK_OrderId", "FK_ItemId"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CollectedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollectedBy") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Method") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Distribution.Models.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("PriceLists"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PriceListId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MinPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PriceListId", "FK_ItemId") + .IsUnique(); + + b.ToTable("PriceListItems"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelReason") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_SupplierId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SupplierId"); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelledQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsCancelled") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("ReceivedQuantity") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.ToTable("PurchaseOrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.ToTable("PurchaseReturns"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PurchaseReturnId"); + + b.ToTable("PurchaseReturnItems"); + }); + + modelBuilder.Entity("Distribution.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_AgencyUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("FK_AgencyUserId"); + + b.HasIndex("FK_RegionId", "FK_AgencyUserId") + .IsUnique(); + + b.ToTable("RegionAgencyMaps"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BudgetAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Period") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId", "Period") + .IsUnique(); + + b.ToTable("RegionalBudgets"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Period") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("TargetValue") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId", "Period") + .IsUnique(); + + b.ToTable("RegionalTargets"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RuleConfig") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId") + .IsUnique(); + + b.ToTable("RegionalTaxRules"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RequestedAt") + .HasColumnType("datetime(6)"); + + b.Property("RequestedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TypeCode") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.ToTable("RideCancelRequests"); + }); + + modelBuilder.Entity("Distribution.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 10, + Name = "SalesRep" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 20, + Name = "Supervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 30, + Name = "AreaManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 40, + Name = "AreaSupervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 50, + Name = "RegionalManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 60, + Name = "RegionalAgency" + }); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FK_PermissionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_RoleId", "FK_PermissionId"); + + b.HasIndex("FK_PermissionId"); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_OwnerUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Length") + .HasColumnType("decimal(65,30)"); + + b.Property("RouteName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StartPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_OwnerUserId"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("Distribution.Models.RouteVisitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CheckInAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInLatitude") + .HasColumnType("decimal(65,30)"); + + b.Property("CheckInLongitude") + .HasColumnType("decimal(65,30)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsDeviationFlagged") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RouteId"); + + b.HasIndex("FK_StoreId"); + + b.HasIndex("FK_UserId"); + + b.ToTable("RouteVisitLogs"); + }); + + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfillImmediately") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("SaleDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Mode") + .HasColumnType("int"); + + b.Property("TotalCreditAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FK_OrderId"); + + b.ToTable("SaleReturns"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturnItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SaleReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("ReasonCode") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SaleReturnId"); + + b.ToTable("SaleReturnItems"); + }); + + modelBuilder.Entity("Distribution.Models.SaleRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("SaleRoutes"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AgentLimitExempt") + .HasColumnType("tinyint(1)"); + + b.Property("BaselineExpectedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeFrom") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeTo") + .HasColumnType("datetime(6)"); + + b.Property("EligibleSegment") + .HasColumnType("longtext"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("FK_TriggerItemId") + .HasColumnType("char(36)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RuleConfig") + .HasColumnType("longtext"); + + b.Property("RuleType") + .HasColumnType("int"); + + b.Property("TriggerQty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_TriggerItemId"); + + b.HasIndex("RegionId"); + + b.ToTable("Schemes"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SchemeId", "FK_ItemId") + .IsUnique(); + + b.ToTable("SchemeBundleComponents"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("FieldInvoiceId") + .HasColumnType("char(36)"); + + b.Property("FreeItemCostValue") + .HasColumnType("decimal(65,30)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("RedeemedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_SchemeId"); + + b.ToTable("SchemeRedemptions"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("LocationId") + .HasColumnType("char(36)"); + + b.Property("LocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId", "LocationId") + .IsUnique(); + + b.ToTable("StockBalances"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FromLocationId") + .HasColumnType("char(36)"); + + b.Property("FromLocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("ReasonCode") + .HasColumnType("longtext"); + + b.Property("ReferenceId") + .HasColumnType("char(36)"); + + b.Property("ToLocationId") + .HasColumnType("char(36)"); + + b.Property("ToLocationType") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreditAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreditLimit") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("KycStatus") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("decimal(65,30)"); + + b.Property("Longitude") + .HasColumnType("decimal(65,30)"); + + b.Property("OneTimeAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("OutstandingBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("OwnerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Segment") + .HasColumnType("longtext"); + + b.Property("StoreAddress") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FileReference") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("StoreKycDocuments"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_CategoryId"); + + b.ToTable("SubCategories"); + }); + + modelBuilder.Entity("Distribution.Models.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ContactInfo") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PerformanceScore") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EvidenceUrl") + .HasColumnType("longtext"); + + b.Property("FK_GoodsReceiptId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RaisedByUserId") + .HasColumnType("char(36)"); + + b.Property("ResolutionAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("ResolutionNotes") + .HasColumnType("longtext"); + + b.Property("ResolutionType") + .HasColumnType("int"); + + b.Property("ResolvedAt") + .HasColumnType("datetime(6)"); + + b.Property("ResolvedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_GoodsReceiptId"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.HasIndex("RaisedByUserId"); + + b.HasIndex("ResolvedByUserId"); + + b.ToTable("SupplierClaims"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SupplierClaimId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IssueType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SupplierClaimId"); + + b.ToTable("SupplierClaimItems"); + }); + + modelBuilder.Entity("Distribution.Models.Uom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Uoms"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_FromUomId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_ToUomId") + .HasColumnType("char(36)"); + + b.Property("Factor") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FromUomId"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_ToUomId"); + + b.ToTable("UomConversions"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ReportsToUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_ReportsToUserId"); + + b.HasIndex("FK_RoleId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RegionId"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("UserRegionScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RouteId"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("UserRouteScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_StoreId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("UserStoreScopes"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("NumberPlate") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DefaultWarehouseId"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Location") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("VehicleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("Warehouses"); + }); + + modelBuilder.Entity("Distribution.Models.Claim", b => + { + b.HasOne("Distribution.Models.User", "AgencyUser") + .WithMany() + .HasForeignKey("FK_AgencyUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "SettledByUser") + .WithMany() + .HasForeignKey("SettledByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AgencyUser"); + + b.Navigation("SettledByUser"); + }); + + modelBuilder.Entity("Distribution.Models.CreditNote", b => + { + b.HasOne("Distribution.Models.SaleReturn", "SaleReturn") + .WithMany() + .HasForeignKey("FK_SaleReturnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SaleReturn"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.HasOne("Distribution.Models.DriverProfile", "DriverProfile") + .WithMany() + .HasForeignKey("FK_DriverProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("FK_VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DriverProfile"); + + b.Navigation("Route"); + + b.Navigation("SourceWarehouse"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("FK_WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b => + { + b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt") + .WithMany("Items") + .HasForeignKey("FK_GoodsReceiptId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem") + .WithMany() + .HasForeignKey("FK_PurchaseOrderItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GoodsReceipt"); + + b.Navigation("PurchaseOrderItem"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.HasOne("Distribution.Models.Uom", "BaseUom") + .WithMany() + .HasForeignKey("FK_BaseUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Brand", "Brand") + .WithMany() + .HasForeignKey("FK_BrandId"); + + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("FK_SubCategoryId"); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ride"); + + b.Navigation("SourceWarehouse"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.LoadingSheet", "LoadingSheet") + .WithMany("Items") + .HasForeignKey("FK_LoadingSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("LoadingSheet"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.PriceList", "PriceList") + .WithMany("Items") + .HasForeignKey("FK_PriceListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PriceList"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.HasOne("Distribution.Models.Supplier", "Supplier") + .WithMany() + .HasForeignKey("FK_SupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany("Items") + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturn", b => + { + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("PurchaseOrder"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.PurchaseReturn", "PurchaseReturn") + .WithMany("Items") + .HasForeignKey("FK_PurchaseReturnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseReturn"); + }); + + modelBuilder.Entity("Distribution.Models.RegionAgencyMap", b => + { + b.HasOne("Distribution.Models.User", "AgencyUser") + .WithMany() + .HasForeignKey("FK_AgencyUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AgencyUser"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalBudget", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTarget", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RegionalTaxRule", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.HasOne("Distribution.Models.Permission", "Permission") + .WithMany() + .HasForeignKey("FK_PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.HasOne("Distribution.Models.User", "OwnerUser") + .WithMany() + .HasForeignKey("FK_OwnerUserId"); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OwnerUser"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.RouteVisitLog", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("Store"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.HasOne("Distribution.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Ride"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturn", b => + { + b.HasOne("Distribution.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturnItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.SaleReturn", "SaleReturn") + .WithMany("Items") + .HasForeignKey("FK_SaleReturnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("SaleReturn"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Item", "TriggerItem") + .WithMany() + .HasForeignKey("FK_TriggerItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("RegionId"); + + b.Navigation("FreeItem"); + + b.Navigation("Region"); + + b.Navigation("TriggerItem"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("BundleComponents") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("Redemptions") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FreeItem"); + + b.Navigation("Order"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaim", b => + { + b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt") + .WithMany() + .HasForeignKey("FK_GoodsReceiptId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("FK_PurchaseOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "RaisedByUser") + .WithMany() + .HasForeignKey("RaisedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("GoodsReceipt"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("RaisedByUser"); + + b.Navigation("ResolvedByUser"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim") + .WithMany("Items") + .HasForeignKey("FK_SupplierClaimId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("SupplierClaim"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.HasOne("Distribution.Models.Uom", "FromUom") + .WithMany() + .HasForeignKey("FK_FromUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Item", "Item") + .WithMany("Conversions") + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Uom", "ToUom") + .WithMany() + .HasForeignKey("FK_ToUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.HasOne("Distribution.Models.User", "ReportsTo") + .WithMany() + .HasForeignKey("FK_ReportsToUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReportsTo"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.HasOne("Distribution.Models.Warehouse", null) + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Navigation("Conversions"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Navigation("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturn", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.SaleReturn", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Navigation("BundleComponents"); + + b.Navigation("Redemptions"); + }); + + modelBuilder.Entity("Distribution.Models.SupplierClaim", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Distribution/Migrations/20260831060901_AddRouteVisitManagement.cs b/Distribution/Migrations/20260831060901_AddRouteVisitManagement.cs new file mode 100644 index 0000000..747c2f9 --- /dev/null +++ b/Distribution/Migrations/20260831060901_AddRouteVisitManagement.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + /// + public partial class AddRouteVisitManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FK_OwnerUserId", + table: "Routes", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.CreateTable( + name: "RouteVisitLogs", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RouteId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_StoreId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CheckInAt = table.Column(type: "datetime(6)", nullable: false), + CheckInLatitude = table.Column(type: "decimal(65,30)", nullable: false), + CheckInLongitude = table.Column(type: "decimal(65,30)", nullable: false), + IsDeviationFlagged = table.Column(type: "tinyint(1)", nullable: false), + CheckOutAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/Distribution/Migrations/AppDBContextModelSnapshot.cs b/Distribution/Migrations/AppDBContextModelSnapshot.cs index da94a53..3fd51ea 100644 --- a/Distribution/Migrations/AppDBContextModelSnapshot.cs +++ b/Distribution/Migrations/AppDBContextModelSnapshot.cs @@ -1115,6 +1115,9 @@ namespace Distribution.Migrations .IsRequired() .HasColumnType("longtext"); + b.Property("FK_OwnerUserId") + .HasColumnType("char(36)"); + b.Property("FK_RegionId") .HasColumnType("char(36)"); @@ -1134,11 +1137,60 @@ namespace Distribution.Migrations b.HasKey("Id"); + b.HasIndex("FK_OwnerUserId"); + b.HasIndex("FK_RegionId"); b.ToTable("Routes"); }); + modelBuilder.Entity("Distribution.Models.RouteVisitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CheckInAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInLatitude") + .HasColumnType("decimal(65,30)"); + + b.Property("CheckInLongitude") + .HasColumnType("decimal(65,30)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsDeviationFlagged") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("FK_RouteId"); + + b.HasIndex("FK_StoreId"); + + b.HasIndex("FK_UserId"); + + b.ToTable("RouteVisitLogs"); + }); + modelBuilder.Entity("Distribution.Models.Sale", b => { b.Property("Id") @@ -2384,15 +2436,48 @@ namespace Distribution.Migrations modelBuilder.Entity("Distribution.Models.Route", b => { + b.HasOne("Distribution.Models.User", "OwnerUser") + .WithMany() + .HasForeignKey("FK_OwnerUserId"); + b.HasOne("Distribution.Models.Region", "Region") .WithMany() .HasForeignKey("FK_RegionId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("OwnerUser"); + b.Navigation("Region"); }); + modelBuilder.Entity("Distribution.Models.RouteVisitLog", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("Store"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Distribution.Models.Sale", b => { b.HasOne("Distribution.Models.User", "CreatedByUser") diff --git a/Distribution/Models/Route.cs b/Distribution/Models/Route.cs index 2e7dfe5..fde2bbd 100644 --- a/Distribution/Models/Route.cs +++ b/Distribution/Models/Route.cs @@ -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; } } } diff --git a/Distribution/Models/RouteVisitLog.cs b/Distribution/Models/RouteVisitLog.cs new file mode 100644 index 0000000..7f81294 --- /dev/null +++ b/Distribution/Models/RouteVisitLog.cs @@ -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; } + } +} From da9caa855a2a32cd9e3e39200ff9e71ad920da8d Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 13:00:33 +0530 Subject: [PATCH 12/14] add sales visits ui --- .../routes/_components/route-manage-sheet.tsx | 170 +++++++++++++++++ .../routes/_components/routes-client.tsx | 46 ++++- .../_components/sales-visits-client.tsx | 173 ++++++++++++++++++ .../app/(dashboard)/sales-visits/page.tsx | 5 + .../_components/store-visit-history-sheet.tsx | 69 +++++++ .../stores/_components/stores-client.tsx | 21 ++- distribution_fe/config/nav.ts | 1 + distribution_fe/contexts/auth-context.tsx | 1 + distribution_fe/lib/api/master.ts | 13 ++ distribution_fe/lib/api/stores.ts | 4 + distribution_fe/types/api.ts | 18 ++ 11 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx create mode 100644 distribution_fe/app/(dashboard)/sales-visits/page.tsx create mode 100644 distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx diff --git a/distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx b/distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx new file mode 100644 index 0000000..990aaff --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { routes as routesApi, stores as storesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RouteResponse, UserResponse, StoreResponse } from '@/types/api' + +interface RouteManageSheetProps { + open: boolean + onClose: () => void + route: RouteResponse | null + onSuccess: () => void +} + +export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManageSheetProps) { + const [salesReps, setSalesReps] = useState([]) + const [ownerUserId, setOwnerUserId] = useState('') + const [allStores, setAllStores] = useState([]) + const [checkedStoreIds, setCheckedStoreIds] = useState>(new Set()) + const [storeFilter, setStoreFilter] = useState('') + const [saving, setSaving] = useState(null) + + useEffect(() => { + if (!open || !route) return + + setOwnerUserId(route.ownerUserId ?? '') + setStoreFilter('') + + usersApi.list({ page: 1, pageSize: 200 }) + .then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep'))) + .catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' })) + + storesApi.list({ page: 1, pageSize: 200 }) + .then((res) => { + const items = getPaginatedItems(res) + setAllStores(items) + setCheckedStoreIds(new Set(items.filter((s) => s.routeId === route.id).map((s) => s.id))) + }) + .catch(() => toast.add({ title: 'Failed to load stores', type: 'error' })) + }, [open, route]) + + if (!route) return null + + async function handleAssignOwner() { + if (!ownerUserId) return + setSaving('owner') + try { + await routesApi.assignOwner(route!.id, ownerUserId) + toast.add({ title: 'Route owner assigned', type: 'success' }) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to assign owner', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + async function handleSaveStores() { + setSaving('stores') + try { + await routesApi.assignStores(route!.id, Array.from(checkedStoreIds)) + toast.add({ title: 'Store assignment saved', type: 'success' }) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to assign stores', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + function toggleStore(storeId: string) { + setCheckedStoreIds((prev) => { + const next = new Set(prev) + if (next.has(storeId)) next.delete(storeId) + else next.add(storeId) + return next + }) + } + + const filteredStores = allStores.filter((s) => + s.storeName.toLowerCase().includes(storeFilter.toLowerCase()) + ) + + return ( + !v && onClose()}> + + + Manage {route.routeName} + + Assign a sales rep owner and the stores on this route. + + + +
+
+ +
+ + +
+ {salesReps.length === 0 && ( +

No users with the SalesRep role found.

+ )} +
+ + + +
+ + setStoreFilter(e.target.value)} + /> +
+ {filteredStores.length === 0 && ( +

No stores match.

+ )} + {filteredStores.map((s) => ( + + ))} +
+ +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx b/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx index 942d587..f531b91 100644 --- a/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx +++ b/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx @@ -9,10 +9,11 @@ import type { TableConfig } from '@/components/shared/data-table' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/toast' -import { routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import { routes as routesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { RouteResponse } from '@/types/api' import { RouteSheet } from './route-sheet' +import { RouteManageSheet } from './route-manage-sheet' export function RoutesClient() { const [data, setData] = useState([]) @@ -20,9 +21,12 @@ export function RoutesClient() { const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) + const [userNameById, setUserNameById] = useState>({}) const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) + const [manageOpen, setManageOpen] = useState(false) + const [managing, setManaging] = useState(null) const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) @@ -41,6 +45,16 @@ export function RoutesClient() { fetchData(page, pageSize) }, [fetchData, page, pageSize]) + useEffect(() => { + usersApi.list({ page: 1, pageSize: 200 }) + .then((res) => { + const map: Record = {} + for (const u of getPaginatedItems(res)) map[u.id] = u.fullName + setUserNameById(map) + }) + .catch(() => toast.add({ title: 'Failed to load users', type: 'error' })) + }, []) + function openCreate() { setEditing(null) setSheetOpen(true) @@ -51,6 +65,11 @@ export function RoutesClient() { setSheetOpen(true) } + function openManage(route: RouteResponse) { + setManaging(route) + setManageOpen(true) + } + async function toggleActive(route: RouteResponse) { try { await routesApi.update(route.id, { isActive: !route.isActive }) @@ -95,6 +114,17 @@ export function RoutesClient() { fields: [{ key: 'length', transform: (v) => `${v} km` }], width: 100, }, + { + key: 'owner', + title: 'Owner', + fields: [{ key: 'ownerUserId' }], + minWidth: 140, + cellRender: (row) => ( + + {row.ownerUserId ? userNameById[row.ownerUserId] ?? 'Unknown' : 'Unassigned'} + + ), + }, { key: 'status', title: 'Status', @@ -125,6 +155,11 @@ export function RoutesClient() { label: 'Edit route', onClick: openEdit, }, + { + action: 'confirm', + label: 'Manage owner & stores', + onClick: openManage, + }, { action: 'deactivate', label: 'Deactivate route', @@ -139,7 +174,7 @@ export function RoutesClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, userNameById, openEdit, openManage, toggleActive]) return ( <> @@ -173,6 +208,13 @@ export function RoutesClient() { route={editing} onSuccess={() => fetchData(page, pageSize)} /> + + setManageOpen(false)} + route={managing} + onSuccess={() => fetchData(page, pageSize)} + /> ) } diff --git a/distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx b/distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx new file mode 100644 index 0000000..b4ad638 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { routes as routesApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RouteResponse, StoreResponse, RouteVisitResponse } from '@/types/api' + +type VisitState = 'idle' | 'checked-in' | 'checked-out' + +function getCurrentPosition(): Promise { + return new Promise((resolve, reject) => { + if (!('geolocation' in navigator)) { + reject(new Error('Geolocation is not supported by this browser')) + return + } + navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 10000 }) + }) +} + +export function SalesVisitsClient() { + const [routeList, setRouteList] = useState([]) + const [selectedRouteId, setSelectedRouteId] = useState('') + const [storeList, setStoreList] = useState([]) + const [loadingStores, setLoadingStores] = useState(false) + const [visitState, setVisitState] = useState>({}) + const [deviationByStore, setDeviationByStore] = useState>({}) + const [busyStoreId, setBusyStoreId] = useState(null) + + useEffect(() => { + routesApi.list({ page: 1, pageSize: 200 }) + .then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive))) + .catch(() => toast.add({ title: 'Failed to load routes', type: 'error' })) + }, []) + + useEffect(() => { + if (!selectedRouteId) { + setStoreList([]) + return + } + setLoadingStores(true) + storesApi.list({ routeId: selectedRouteId, page: 1, pageSize: 200 }) + .then((res) => { + setStoreList(getPaginatedItems(res).filter((s) => s.isActive)) + setVisitState({}) + setDeviationByStore({}) + }) + .catch(() => toast.add({ title: 'Failed to load stores', type: 'error' })) + .finally(() => setLoadingStores(false)) + }, [selectedRouteId]) + + async function handleCheckIn(store: StoreResponse) { + setBusyStoreId(store.id) + try { + const position = await getCurrentPosition() + const result: RouteVisitResponse = await routesApi.checkIn(selectedRouteId, { + storeId: store.id, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }) + setVisitState((prev) => ({ ...prev, [store.id]: 'checked-in' })) + setDeviationByStore((prev) => ({ ...prev, [store.id]: result.isDeviationFlagged })) + toast.add({ + title: 'Checked in', + description: result.isDeviationFlagged ? 'Location does not match the store’s registered address.' : undefined, + type: result.isDeviationFlagged ? 'error' : 'success', + }) + } catch (err) { + const message = err instanceof Error && !getApiError(err) ? err.message : getApiError(err) + toast.add({ title: 'Check-in failed', description: message, type: 'error' }) + } finally { + setBusyStoreId(null) + } + } + + async function handleCheckOut(store: StoreResponse) { + setBusyStoreId(store.id) + try { + await routesApi.checkOut(selectedRouteId, { storeId: store.id }) + setVisitState((prev) => ({ ...prev, [store.id]: 'checked-out' })) + toast.add({ title: 'Checked out', type: 'success' }) + } catch (err) { + toast.add({ title: 'Check-out failed', description: getApiError(err), type: 'error' }) + } finally { + setBusyStoreId(null) + } + } + + return ( +
+
+

Sales Visits

+

+ Check in and out of scheduled store visits along a route +

+
+ +
+ + +
+ + {loadingStores &&

Loading stores…

} + + {!loadingStores && selectedRouteId && storeList.length === 0 && ( +

No active stores on this route.

+ )} + +
+ {storeList.map((store) => { + const state = visitState[store.id] ?? 'idle' + const isBusy = busyStoreId === store.id + return ( +
+
+ {store.storeName} + {store.storeAddress} + {deviationByStore[store.id] && state !== 'idle' && ( + Location mismatch + )} +
+
+ {state === 'checked-out' && Completed} + {state !== 'checked-out' && ( + + )} + {state === 'checked-in' && ( + + )} +
+
+ ) + })} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales-visits/page.tsx b/distribution_fe/app/(dashboard)/sales-visits/page.tsx new file mode 100644 index 0000000..4cb4a13 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales-visits/page.tsx @@ -0,0 +1,5 @@ +import { SalesVisitsClient } from './_components/sales-visits-client' + +export default function SalesVisitsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx b/distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx new file mode 100644 index 0000000..12a9678 --- /dev/null +++ b/distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/toast' +import { stores as storesApi } from '@/lib/api/index' +import type { StoreResponse, StoreVisitHistoryItem } from '@/types/api' + +interface StoreVisitHistorySheetProps { + open: boolean + onClose: () => void + store: StoreResponse | null +} + +export function StoreVisitHistorySheet({ open, onClose, store }: StoreVisitHistorySheetProps) { + const [visits, setVisits] = useState(null) + + useEffect(() => { + if (!open || !store) return + setVisits(null) + storesApi.visitHistory(store.id) + .then(setVisits) + .catch(() => toast.add({ title: 'Failed to load visit history', type: 'error' })) + }, [open, store]) + + if (!store) return null + + return ( + !v && onClose()}> + + + Visit History — {store.storeName} + + Check-ins and check-outs logged against this store. + + + + {visits === null ? ( +

Loading…

+ ) : visits.length === 0 ? ( +

No visits logged yet.

+ ) : ( +
    + {visits.map((v) => ( +
  • + + {new Date(v.visitDate).toLocaleString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', + })} + + + {v.outcome} + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx index 017399f..e4f7094 100644 --- a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx +++ b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx @@ -13,6 +13,7 @@ import { stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/i import type { StoreResponse, KycStatus } from '@/types/api' import { StoreSheet } from './store-sheet' +import { StoreVisitHistorySheet } from './store-visit-history-sheet' function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' { if (status === 'Verified') return 'default' @@ -29,6 +30,8 @@ export function StoresClient() { const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) + const [historyOpen, setHistoryOpen] = useState(false) + const [viewingHistory, setViewingHistory] = useState(null) const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) @@ -57,6 +60,11 @@ export function StoresClient() { setSheetOpen(true) } + function openHistory(store: StoreResponse) { + setViewingHistory(store) + setHistoryOpen(true) + } + async function toggleActive(store: StoreResponse) { try { await storesApi.update(store.id, { isActive: !store.isActive }) @@ -125,6 +133,11 @@ export function StoresClient() { label: 'Edit store', onClick: openEdit, }, + { + action: 'confirm', + label: 'View visit history', + onClick: openHistory, + }, { action: 'deactivate', label: 'Deactivate store', @@ -139,7 +152,7 @@ export function StoresClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, openEdit, openHistory, toggleActive]) return ( <> @@ -173,6 +186,12 @@ export function StoresClient() { store={editing} onSuccess={() => fetchData(page, pageSize)} /> + + setHistoryOpen(false)} + store={viewingHistory} + /> ) } diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index 787bd83..bbdd53a 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -73,6 +73,7 @@ export const NAV_GROUPS: NavGroup[] = [ icon: Store01Icon, items: [ { title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits, and KYC status', permission: 'stores.view' }, + { title: 'Sales Visits', href: '/sales-visits', icon: Location01Icon, description: 'Check in and out of scheduled store visits', permission: 'sales_visits.checkin' }, ], }, { diff --git a/distribution_fe/contexts/auth-context.tsx b/distribution_fe/contexts/auth-context.tsx index f17fdf6..9728cb3 100644 --- a/distribution_fe/contexts/auth-context.tsx +++ b/distribution_fe/contexts/auth-context.tsx @@ -51,6 +51,7 @@ const MOCK_PERMISSIONS: Record = { 'routes.view', 'rides.view', 'driver_profiles.view', + 'sales_visits.checkin', ], } diff --git a/distribution_fe/lib/api/master.ts b/distribution_fe/lib/api/master.ts index 5fec4f2..ce18073 100644 --- a/distribution_fe/lib/api/master.ts +++ b/distribution_fe/lib/api/master.ts @@ -9,6 +9,7 @@ import type { RegionHierarchyResponse, RegionDashboardResponse, RouteResponse, + RouteVisitResponse, VehicleResponse, WarehouseResponse, VehicleType, @@ -148,6 +149,18 @@ export const routes = { remove: (id: string) => api.delete(`/routes/${id}`), + + assignStores: (id: string, storeIds: string[]) => + api.post(`/routes/${id}/stores`, { storeIds }).then(r => r.data), + + assignOwner: (id: string, salesRepId: string) => + api.put(`/routes/${id}/owner`, { salesRepId }).then(r => r.data), + + checkIn: (id: string, body: { storeId: string; latitude: number; longitude: number }) => + api.post(`/routes/${id}/visits/check-in`, body).then(r => r.data), + + checkOut: (id: string, body: { storeId: string }) => + api.post(`/routes/${id}/visits/check-out`, body).then(r => r.data), } // ─── Vehicles ───────────────────────────────────────────────────────────────── diff --git a/distribution_fe/lib/api/stores.ts b/distribution_fe/lib/api/stores.ts index 7804ad8..4188802 100644 --- a/distribution_fe/lib/api/stores.ts +++ b/distribution_fe/lib/api/stores.ts @@ -5,6 +5,7 @@ import type { KycDocumentResponse, OutstandingBalanceResponse, StoreDashboardResponse, + StoreVisitHistoryItem, KycStatus, } from '@/types/api' @@ -58,4 +59,7 @@ export const stores = { updateKycStatus: (id: string, kycStatus: KycStatus) => api.patch(`/stores/${id}/kyc-status`, { kycStatus }).then(r => r.data), + + visitHistory: (id: string) => + api.get(`/stores/${id}/visit-history`).then(r => r.data), } diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 8f6a317..b12bf4b 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -81,10 +81,28 @@ export interface RouteResponse { startPoint: string endPoint: string length: number + ownerUserId: string | null isActive: boolean createdAt: string } +export interface RouteVisitResponse { + id: string + routeId: string + storeId: string + userId: string + checkInAt: string + isDeviationFlagged: boolean + checkOutAt: string | null +} + +export interface StoreVisitHistoryItem { + visitId: string + visitDate: string + repUserId: string + outcome: string +} + export interface RegionalTargetResponse { regionId: string period: string From 2ecda623ac56240da082c47eea457181bac41b0a Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 13:00:42 +0530 Subject: [PATCH 13/14] add route service tests --- .../Services/RouteServiceTests.cs | 343 ++++++++++++++++++ .../Services/StoreServiceTests.cs | 85 +++++ 2 files changed, 428 insertions(+) create mode 100644 Distribution.Tests/Services/RouteServiceTests.cs create mode 100644 Distribution.Tests/Services/StoreServiceTests.cs diff --git a/Distribution.Tests/Services/RouteServiceTests.cs b/Distribution.Tests/Services/RouteServiceTests.cs new file mode 100644 index 0000000..9592a21 --- /dev/null +++ b/Distribution.Tests/Services/RouteServiceTests.cs @@ -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 _repo = new(); + private readonly Mock _regionRepo = new(); + private readonly Mock _uow = new(); + private readonly Mock _currentUser = new(); + private readonly Mock _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())).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())).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.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 { 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()), 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 { 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())).Throws(new Exception("db error")); + + await Assert.ThrowsAsync(() => _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())).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())).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())).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()), 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())).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())).Throws(new Exception("db error")); + + await Assert.ThrowsAsync(() => _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null))); + + _uow.Verify(u => u.RollbackAsync(default), Times.Once); + } +} diff --git a/Distribution.Tests/Services/StoreServiceTests.cs b/Distribution.Tests/Services/StoreServiceTests.cs new file mode 100644 index 0000000..6626009 --- /dev/null +++ b/Distribution.Tests/Services/StoreServiceTests.cs @@ -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 _repo = new(); + private readonly Mock _uow = new(); + private readonly Mock _currentUser = new(); + private readonly Mock _scopeAccess = new(); + private readonly Mock _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(), + Mock.Of(), + Mock.Of(), + _repo.Object, + Mock.Of(), + _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())).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); + } +} From e67675a12a9fcf549951e691972770fdf95037f7 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 13:00:49 +0530 Subject: [PATCH 14/14] update docs --- Distribution/docs/00_existing_gaps.md | 4 + Distribution/docs/09_fraud_controls.md | 7 +- .../docs/11_route_sales_management.md | 37 ++++++++- Distribution/docs/12_store_management.md | 2 +- Distribution/docs/21_notifications_alerts.md | 2 +- Distribution/docs/mainPlan.md | 20 ++--- sharedDocs/BACKEND_API_REFERENCE.md | 76 +++++++++++++++++++ 7 files changed, 130 insertions(+), 18 deletions(-) diff --git a/Distribution/docs/00_existing_gaps.md b/Distribution/docs/00_existing_gaps.md index 3c486e9..68dedc8 100644 --- a/Distribution/docs/00_existing_gaps.md +++ b/Distribution/docs/00_existing_gaps.md @@ -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 = "...")]` diff --git a/Distribution/docs/09_fraud_controls.md b/Distribution/docs/09_fraud_controls.md index a1ed569..6c6389e 100644 --- a/Distribution/docs/09_fraud_controls.md +++ b/Distribution/docs/09_fraud_controls.md @@ -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 diff --git a/Distribution/docs/11_route_sales_management.md b/Distribution/docs/11_route_sales_management.md index 54b369c..b35aec7 100644 --- a/Distribution/docs/11_route_sales_management.md +++ b/Distribution/docs/11_route_sales_management.md @@ -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/`). diff --git a/Distribution/docs/12_store_management.md b/Distribution/docs/12_store_management.md index daf6e56..a352f10 100644 --- a/Distribution/docs/12_store_management.md +++ b/Distribution/docs/12_store_management.md @@ -107,7 +107,7 @@ One migration needed: | 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 | diff --git a/Distribution/docs/21_notifications_alerts.md b/Distribution/docs/21_notifications_alerts.md index 6c7ea07..c339b8d 100644 --- a/Distribution/docs/21_notifications_alerts.md +++ b/Distribution/docs/21_notifications_alerts.md @@ -39,7 +39,7 @@ and trigger wiring; the underlying conditions it watches for are already defined | `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) | | `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` | | `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) | -| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag | +| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag. The source data now exists (`RouteVisitLog.IsDeviationFlagged`, see [11_route_sales_management.md](./11_route_sales_management.md)), but no anomaly flag or notification reads it yet. | | — | `GET /api/notifications` — recipient's own notification inbox | | — | `PUT /api/notifications/{id}/read` | diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 0741239..2934e21 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -55,7 +55,7 @@ Step 00 closed these. Kept here for history; all are fixed in the current codeba | 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) | | 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) | | 10 | Region Management (Beyond CRUD) | Done | 00 | [10_region_management.md](./10_region_management.md) | -| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) | +| 11 | Route & Sales Visit Management | Partially Implemented | 00 | [11_route_sales_management.md](./11_route_sales_management.md) | | 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) | | 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) | | 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | @@ -129,14 +129,14 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). A route's region can also be changed after creation via `PUT /api/routes/{id}/region`. | -| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. | -| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. | -| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. | -| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Not implemented. | -| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Not implemented. | -| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — depends on check-in/check-out above. | -| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Not implemented. | -| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented. | +| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Implemented — `POST /api/routes/{id}/stores` bulk-replaces `FK_RouteId` for the given store ids (all-or-nothing; 404 if any id doesn't exist). | +| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Implemented — `PUT /api/routes/{id}/owner`, backed by the new `Route.FK_OwnerUserId` field, distinct from the existing `UserRouteScope` grant. | +| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Stubbed — `POST /api/routes/{id}/optimize` always returns `501 Not Implemented`; no distance/priority computation exists yet (see open question in [11_route_sales_management.md](./11_route_sales_management.md)). | +| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Implemented — `POST /api/routes/{id}/visits/check-in`, backed by the new `RouteVisitLog` entity. Sets `IsDeviationFlagged` via a basic Haversine distance check (>500m) against the store's `Latitude`/`Longitude`; does not yet call `sendRouteDeviationAlert` (§9, unimplemented). | +| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Implemented — `POST /api/routes/{id}/visits/check-out`, closes the matching open `RouteVisitLog` row (404 if none). | +| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — metrics deferred, out of scope for the current pass. | +| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Implemented — same endpoint as `assignRouteToSalesRep` (`PUT /api/routes/{id}/owner`); the doc treats these as one action, distinguished only by caller intent. | +| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented — metrics deferred, out of scope for the current pass. | | `planDeliveryRoute(warehouseId, orderIds[])` | Builds the warehouse-to-outlet dispatch route (separate from the sales visit route). | Not implemented — this is the `DeliveryRun` concept; see [03_ride_lifecycle.md](./03_ride_lifecycle.md). | ### 4. Store (Outlet) Management @@ -152,7 +152,7 @@ here — see `SRS_User_Management_Module.pdf`. | `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. | | `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. | | `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. | -| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. | +| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Implemented — `GET /api/stores/{id}/visit-history` now reads from the `RouteVisitLog` table (§3), returning each visit's date, rep, and outcome (`In Progress`/`Completed`). | | `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). | | `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. | diff --git a/sharedDocs/BACKEND_API_REFERENCE.md b/sharedDocs/BACKEND_API_REFERENCE.md index 6dddc51..f605908 100644 --- a/sharedDocs/BACKEND_API_REFERENCE.md +++ b/sharedDocs/BACKEND_API_REFERENCE.md @@ -619,6 +619,7 @@ role — the response body carries no distinction between "not found" and "forbi "startPoint": "string", "endPoint": "string", "length": 12.5, + "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" } @@ -632,6 +633,81 @@ role — the response body carries no distinction between "not found" and "forbi Returns the same `RouteResponse` shape as above. `404` if the route or the target region doesn't exist (or either is inactive). +#### Route & Sales Visit Management + +Bulk store assignment, owner assignment, and GPS check-in/out for a SalesRep's daily visit +schedule. Not gated by the `routes.*` permission codes above — access is checked in +`RouteService` via `IScopeAccessService.HasRouteScopeAsync`, with a `RouteManagerRoleLevel` (20, +i.e. Supervisor and above) floor for the assignment endpoints. All endpoints below return `404` +if the route doesn't exist, is inactive, or the caller lacks route scope — the response body +carries no distinction between "not found" and "forbidden". + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/routes/{id}/stores` | Bulk-assign a set of stores to this route (replaces each store's `routeId`) | +| `PUT` | `/api/routes/{id}/owner` | Assign or reassign the route's owning SalesRep (`OwnerUserId`) | +| `POST` | `/api/routes/{id}/optimize` | Route sequence optimization — **not yet implemented**, always returns `501 Not Implemented` | +| `POST` | `/api/routes/{id}/visits/check-in` | Record a GPS check-in at a store on this route | +| `POST` | `/api/routes/{id}/visits/check-out` | Record check-out for an open visit at a store on this route | + +**Assign stores request / response:** +```json +// POST /api/routes/{id}/stores +{ "storeIds": ["guid", "guid", "..."] } +``` +Returns the `RouteResponse` shape. All IDs must reference existing stores or the request is +rejected with `404` (no partial assignment). +```json +{ "id": "guid", "regionId": "guid", "routeName": "string", "startPoint": "string", "endPoint": "string", "length": 12.5, "ownerUserId": "guid | null", "isActive": true, "createdAt": "datetime" } +``` + +**Assign owner request / response:** +```json +// PUT /api/routes/{id}/owner +{ "salesRepId": "guid" } // must be an active User +``` +Returns the same `RouteResponse` shape. `404` if the target user doesn't exist or is inactive. + +**Check-in request / response:** +```json +// POST /api/routes/{id}/visits/check-in +{ + "storeId": "guid", // must currently be assigned to this route + "latitude": 6.927079, + "longitude": 79.861244, + "timestamp": "datetime | null" // optional, defaults to server time +} +``` +```json +{ + "id": "guid", + "routeId": "guid", + "storeId": "guid", + "userId": "guid", // caller's user ID — the visiting rep + "checkInAt": "datetime", + "isDeviationFlagged": false, // true if GPS is > 500m (Haversine) from the store's registered lat/long + "checkOutAt": "datetime | null" +} +``` +`isDeviationFlagged` is a basic distance check only — it does not yet trigger any alert/notification +(see [docs/09_fraud_controls.md] `sendRouteDeviationAlert`, which is not implemented). + +**Check-out request / response:** +```json +// POST /api/routes/{id}/visits/check-out +{ + "storeId": "guid", + "timestamp": "datetime | null" // optional, defaults to server time +} +``` +Returns the same shape as check-in with `checkOutAt` populated. `404` if there's no open +(checked-in, not yet checked-out) visit for that route+store. + +There is no endpoint to list a route's open/in-progress visits — check-out requires the caller to +already know which store they checked into. A store's full visit history is available via +`GET /api/stores/{id}/visit-history` (see [§6.9 Stores](#69-stores)), which reads from this same +visit log. + --- ### 6.7 Vehicles