using Distribution.Infra.Authorization; using Distribution.Infra.UoW; namespace Distribution.Features.Route; 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.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.FK_OwnerUserId, route.IsActive, route.CreatedAt); } public async Task CreateAsync(CreateRouteRequest request, CancellationToken ct = default) { var route = new Models.Route { FK_RegionId = request.RegionId, RouteName = request.RouteName, StartPoint = request.StartPoint, EndPoint = request.EndPoint, Length = request.Length }; await uow.BeginAsync(ct); try { await repo.AddAsync(route); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(route); } public async Task UpdateAsync(Guid id, UpdateRouteRequest request, CancellationToken ct = default) { var route = await repo.GetByIdAsync(id); if (route is null || !route.IsActive) return null; route.FK_RegionId = request.RegionId; route.RouteName = request.RouteName; route.StartPoint = request.StartPoint; route.EndPoint = request.EndPoint; route.Length = request.Length; await uow.BeginAsync(ct); try { repo.Update(route); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(route); } public async Task AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default) { var route = await repo.GetByIdAsync(id); if (route is null || !route.IsActive) return null; var region = await regionRepo.GetByIdAsync(request.RegionId); if (region is null || !region.IsActive) return null; route.FK_RegionId = request.RegionId; await uow.BeginAsync(ct); try { repo.Update(route); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(route); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var route = await repo.GetByIdAsync(id); if (route is null || !route.IsActive) return false; await uow.BeginAsync(ct); try { repo.Remove(route); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } 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) { // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware // (testbug.md #28) — fall back to the explicitly supplied user, and skip the route-scope // check (which also depends on a resolved user) until that's fixed. var userId = currentUser.UserId ?? request.CheckedInByUserId ?? throw new InvalidOperationException("CheckedInByUserId is required while auth is unavailable"); var route = await repo.GetByIdAsync(id); if (route is null || !route.IsActive) 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 = userId, 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) { // TEMPORARY: the route-scope check depends on the broken auth middleware // (testbug.md #28) — skipped for now, same as check-in. var route = await repo.GetByIdAsync(id); if (route is null || !route.IsActive) 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); } }