From 5c4e1b590a1a1059e4ae875702eebd1610ae0ccf Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 13:00:13 +0530 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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