255 lines
8.8 KiB
C#
255 lines
8.8 KiB
C#
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<bool> CanManageRouteAsync(Guid routeId)
|
|
{
|
|
if (currentUser.RoleLevel < RouteManagerRoleLevel || currentUser.UserId is null) return false;
|
|
return await scopeAccess.HasRouteScopeAsync(currentUser.UserId.Value, routeId);
|
|
}
|
|
|
|
private static double DistanceMeters(decimal lat1, decimal lon1, decimal lat2, decimal lon2)
|
|
{
|
|
const double earthRadiusMeters = 6371000;
|
|
var dLat = ToRadians((double)(lat2 - lat1));
|
|
var dLon = ToRadians((double)(lon2 - lon1));
|
|
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
|
Math.Cos(ToRadians((double)lat1)) * Math.Cos(ToRadians((double)lat2)) *
|
|
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
|
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
|
return earthRadiusMeters * c;
|
|
}
|
|
|
|
private static double ToRadians(double degrees) => degrees * Math.PI / 180;
|
|
|
|
private static RouteResponse ToResponse(Models.Route route) =>
|
|
new(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
|
|
|
|
private static RouteVisitResponse ToVisitResponse(Models.RouteVisitLog log) =>
|
|
new(log.Id, log.FK_RouteId, log.FK_StoreId, log.FK_UserId, log.CheckInAt, log.IsDeviationFlagged, log.CheckOutAt);
|
|
|
|
public async Task<List<RouteResponse>> GetAllAsync() =>
|
|
(await repo.GetAllAsync())
|
|
.Where(r => r.IsActive)
|
|
.Select(r => new RouteResponse(r.Id, r.FK_RegionId, r.RouteName, r.StartPoint, r.EndPoint, r.Length, r.FK_OwnerUserId, r.IsActive, r.CreatedAt))
|
|
.ToList();
|
|
|
|
public async Task<RouteResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var route = await repo.GetByIdAsync(id);
|
|
if (route is null || !route.IsActive) return null;
|
|
return new RouteResponse(route.Id, route.FK_RegionId, route.RouteName, route.StartPoint, route.EndPoint, route.Length, route.FK_OwnerUserId, route.IsActive, route.CreatedAt);
|
|
}
|
|
|
|
public async Task<RouteResponse> 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<RouteResponse?> 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<RouteResponse?> AssignToRegionAsync(Guid id, AssignRouteToRegionRequest request, CancellationToken ct = default)
|
|
{
|
|
var route = await repo.GetByIdAsync(id);
|
|
if (route is null || !route.IsActive) return null;
|
|
|
|
var region = await regionRepo.GetByIdAsync(request.RegionId);
|
|
if (region is null || !region.IsActive) return null;
|
|
|
|
route.FK_RegionId = request.RegionId;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(route);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(route);
|
|
}
|
|
|
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
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<RouteResponse?> AssignStoresToRouteAsync(Guid id, AssignStoresToRouteRequest request, CancellationToken ct = default)
|
|
{
|
|
var route = await repo.GetByIdAsync(id);
|
|
if (route is null || !route.IsActive) return null;
|
|
if (!await CanManageRouteAsync(id)) return null;
|
|
|
|
var stores = await repo.GetStoresByIdsAsync(request.StoreIds);
|
|
if (stores.Count != request.StoreIds.Distinct().Count()) return null;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
foreach (var store in stores)
|
|
{
|
|
store.FK_RouteId = id;
|
|
repo.UpdateStore(store);
|
|
}
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(route);
|
|
}
|
|
|
|
public async Task<RouteResponse?> AssignOwnerAsync(Guid id, AssignRouteOwnerRequest request, CancellationToken ct = default)
|
|
{
|
|
var route = await repo.GetByIdAsync(id);
|
|
if (route is null || !route.IsActive) return null;
|
|
if (!await CanManageRouteAsync(id)) return null;
|
|
|
|
var salesRep = await repo.GetActiveUserAsync(request.SalesRepId);
|
|
if (salesRep is null) return null;
|
|
|
|
route.FK_OwnerUserId = request.SalesRepId;
|
|
await uow.BeginAsync(ct);
|
|
try
|
|
{
|
|
repo.Update(route);
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
return ToResponse(route);
|
|
}
|
|
|
|
public async Task<RouteVisitResponse?> RecordCheckInAsync(Guid id, RecordCheckInRequest request, CancellationToken ct = default)
|
|
{
|
|
// 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<RouteVisitResponse?> 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);
|
|
}
|
|
}
|
|
|