Compare commits

..

3 Commits

61 changed files with 2624 additions and 305 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
using Distribution.Features.User;
using Distribution.Infra.Auth;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Identity;
namespace Distribution.Features.Auth;
+1 -1
View File
@@ -1,4 +1,4 @@
using Distribution.Infra.Auth;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Order;
+3 -3
View File
@@ -1,7 +1,7 @@
namespace Distribution.Features.Route;
public record CreateRouteRequest(string RouteName, string StartPoint, string EndPoint, decimal Length);
public record CreateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record UpdateRouteRequest(string RouteName, string StartPoint, string EndPoint, decimal Length);
public record UpdateRouteRequest(Guid RegionId, string RouteName, string StartPoint, string EndPoint, decimal Length);
public record RouteResponse(Guid Id, 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, bool IsActive, DateTime CreatedAt);
+6 -4
View File
@@ -7,20 +7,21 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
public async Task<List<RouteResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RouteResponse(r.Id, 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.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.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.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,
@@ -37,13 +38,14 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, 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.IsActive, route.CreatedAt);
}
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;
@@ -59,7 +61,7 @@ public class RouteService(IRouteRepository repo, IUnitOfWork uow)
await uow.RollbackAsync(ct);
throw;
}
return new RouteResponse(route.Id, 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.IsActive, route.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
+3
View File
@@ -1,6 +1,7 @@
namespace Distribution.Features.Store;
public record CreateStoreRequest(
Guid RouteId,
string OwnerName,
string StoreName,
string StoreAddress,
@@ -10,6 +11,7 @@ public record CreateStoreRequest(
decimal CreditAmount);
public record UpdateStoreRequest(
Guid RouteId,
string OwnerName,
string StoreName,
string StoreAddress,
@@ -20,6 +22,7 @@ public record UpdateStoreRequest(
public record StoreResponse(
Guid Id,
Guid RouteId,
string OwnerName,
string StoreName,
string StoreAddress,
+7 -8
View File
@@ -1,4 +1,4 @@
using Distribution.Infra.Auth;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Store;
@@ -7,9 +7,6 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
{
private const int GlobalScopeRoleLevel = 50;
private static StoreResponse ToResponse(Models.Store s) =>
new(s.Id, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount, s.IsActive, s.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
@@ -26,7 +23,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
stores = stores.Where(s => accessibleStoreIds.Contains(s.Id)).ToList();
}
return stores.Select(ToResponse).ToList();
return stores.Select(s => new StoreResponse(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount, s.IsActive, s.CreatedAt)).ToList();
}
public async Task<StoreResponse?> GetByIdAsync(Guid id)
@@ -34,13 +31,14 @@ 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;
return ToResponse(store);
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
}
public async Task<StoreResponse> CreateAsync(CreateStoreRequest request, CancellationToken ct = default)
{
var store = new Models.Store
{
FK_RouteId = request.RouteId,
OwnerName = request.OwnerName,
StoreName = request.StoreName,
StoreAddress = request.StoreAddress,
@@ -60,7 +58,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
}
public async Task<StoreResponse?> UpdateAsync(Guid id, UpdateStoreRequest request, CancellationToken ct = default)
@@ -68,6 +66,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.FK_RouteId = request.RouteId;
store.OwnerName = request.OwnerName;
store.StoreName = request.StoreName;
store.StoreAddress = request.StoreAddress;
@@ -86,7 +85,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace Distribution.Infra.Auth
{
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
private System.Security.Claims.ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public Guid? UserId
{
get
{
var value = User?.FindFirst("sub")?.Value ?? User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public Guid? RoleId
{
get
{
var value = User?.FindFirst("role_id")?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public string? Username => User?.FindFirst("username")?.Value;
public int RoleLevel
{
get
{
var value = User?.FindFirst("role_level")?.Value;
return int.TryParse(value, out var level) ? level : 0;
}
}
}
}
@@ -1,11 +0,0 @@
namespace Distribution.Infra.Auth
{
public interface ICurrentUserService
{
Guid? UserId { get; }
Guid? RoleId { get; }
string? Username { get; }
int RoleLevel { get; }
bool IsAuthenticated { get; }
}
}
@@ -1,7 +0,0 @@
namespace Distribution.Infra.Auth
{
public interface IJwtTokenService
{
string GenerateToken(Models.User user);
}
}
@@ -1,7 +0,0 @@
namespace Distribution.Infra.Auth
{
public interface IPermissionService
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
}
}
@@ -1,10 +0,0 @@
namespace Distribution.Infra.Auth
{
public interface IScopeAccessService
{
Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId);
Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId);
Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId);
Task<List<Guid>> GetAccessibleStoreIdsAsync(Guid userId);
}
}
@@ -1,48 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Auth
{
public class JwtTokenService : IJwtTokenService
{
private readonly IConfiguration _configuration;
public JwtTokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateToken(Models.User user)
{
var jwtSection = _configuration.GetSection("Jwt");
var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = jwtSection["Issuer"];
var audience = jwtSection["Audience"];
var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120;
var claims = new List<Claim>
{
new("sub", user.Id.ToString()),
new("username", user.Username),
new("role", user.Role?.Name ?? string.Empty),
new("role_id", user.FK_RoleId.ToString()),
new("role_level", (user.Role?.Level ?? 0).ToString())
};
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.Now.AddMinutes(expiryMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
}
@@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace Distribution.Infra.Auth
{
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly IPermissionService _permissionService;
public PermissionAuthorizationHandler(IPermissionService permissionService)
{
_permissionService = permissionService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var roleIdClaim = context.User.FindFirst("role_id")?.Value;
if (!Guid.TryParse(roleIdClaim, out var roleId))
return;
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
context.Succeed(requirement);
}
}
}
@@ -1,33 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace Distribution.Infra.Auth
{
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.Contains('.'))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
}
}
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace Distribution.Infra.Auth
{
public class PermissionRequirement : IAuthorizationRequirement
{
public string PermissionCode { get; }
public PermissionRequirement(string permissionCode)
{
PermissionCode = permissionCode;
}
}
}
@@ -1,21 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Infra.Auth
{
public class PermissionService : IPermissionService
{
private readonly AppDBContext _context;
public PermissionService(AppDBContext context)
{
_context = context;
}
public async Task<bool> HasPermissionAsync(Guid roleId, string permissionCode) =>
await _context.RolePermissions
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.Join(_context.Permissions.Where(p => p.IsActive), rp => rp.FK_PermissionId, p => p.Id, (rp, p) => p.Code)
.AnyAsync(code => code == permissionCode);
}
}
@@ -1,63 +0,0 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Infra.Auth
{
public class ScopeAccessService : IScopeAccessService
{
private readonly AppDBContext _context;
public ScopeAccessService(AppDBContext context)
{
_context = context;
}
public async Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId) =>
await _context.UserStoreScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_StoreId == storeId && s.IsActive);
public async Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId) =>
await _context.UserRouteScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RouteId == routeId && s.IsActive);
public async Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId) =>
await _context.UserRegionScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId && s.IsActive);
public async Task<List<Guid>> GetAccessibleStoreIdsAsync(Guid userId)
{
var directStoreIds = await _context.UserStoreScopes
.Where(s => s.FK_UserId == userId && s.IsActive)
.Select(s => s.FK_StoreId)
.ToListAsync();
var routeIds = await _context.UserRouteScopes
.Where(s => s.FK_UserId == userId && s.IsActive)
.Select(s => s.FK_RouteId)
.ToListAsync();
var regionIds = await _context.UserRegionScopes
.Where(s => s.FK_UserId == userId && s.IsActive)
.Select(s => s.FK_RegionId)
.ToListAsync();
var storeIdsViaRoute = await _context.Stores
.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive)
.Select(s => s.Id)
.ToListAsync();
var routeIdsViaRegion = await _context.Routes
.Where(r => regionIds.Contains(r.FK_RegionId) && r.IsActive)
.Select(r => r.Id)
.ToListAsync();
var storeIdsViaRegion = await _context.Stores
.Where(s => routeIdsViaRegion.Contains(s.FK_RouteId) && s.IsActive)
.Select(s => s.Id)
.ToListAsync();
return directStoreIds
.Concat(storeIdsViaRoute)
.Concat(storeIdsViaRegion)
.Distinct()
.ToList();
}
}
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Infra.Authorization;
[ApiController]
[Route("api/authorization")]
[Authorize]
public class AuthorizationController(
IPermissionService permissionService,
IScopeAccessService scopeAccessService,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("permissions/{permissionCode}")]
public async Task<IActionResult> CheckPermission(string permissionCode)
{
if (currentUser.RoleId is not { } roleId) return Unauthorized();
var result = await permissionService.HasPermissionAsync(roleId, permissionCode);
return Ok(new PermissionCheckResponse(result));
}
[HttpGet("scopes/stores/{storeId:guid}")]
public async Task<IActionResult> CheckStoreScope(Guid storeId)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
var result = await scopeAccessService.HasStoreScopeAsync(userId, storeId);
return Ok(new StoreScopeCheckResponse(result));
}
[HttpGet("scopes/routes/{routeId:guid}")]
public async Task<IActionResult> CheckRouteScope(Guid routeId)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
var result = await scopeAccessService.HasRouteScopeAsync(userId, routeId);
return Ok(new RouteScopeCheckResponse(result));
}
[HttpGet("scopes/regions/{regionId:guid}")]
public async Task<IActionResult> CheckRegionScope(Guid regionId)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
var result = await scopeAccessService.HasRegionScopeAsync(userId, regionId);
return Ok(new RegionScopeCheckResponse(result));
}
[HttpGet("scopes/stores")]
public async Task<IActionResult> GetAccessibleStores()
{
if (currentUser.UserId is not { } userId) return Unauthorized();
var result = await scopeAccessService.GetAccessibleStoreIdsAsync(userId);
return Ok(new AccessibleStoresResponse(result));
}
}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Http;
namespace Distribution.Infra.Authorization;
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
private System.Security.Claims.ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public Guid? UserId
{
get
{
var value = User?.FindFirst("sub")?.Value ?? User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public Guid? RoleId
{
get
{
var value = User?.FindFirst("role_id")?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public string? Username => User?.FindFirst("username")?.Value;
public int RoleLevel
{
get
{
var value = User?.FindFirst("role_level")?.Value;
return int.TryParse(value, out var level) ? level : 0;
}
}
}
@@ -0,0 +1,10 @@
namespace Distribution.Infra.Authorization;
public interface ICurrentUserService
{
Guid? UserId { get; }
Guid? RoleId { get; }
string? Username { get; }
int RoleLevel { get; }
bool IsAuthenticated { get; }
}
@@ -0,0 +1,11 @@
namespace Distribution.Infra.Authorization;
public record PermissionCheckResponse(bool HasPermission);
public record StoreScopeCheckResponse(bool HasScope);
public record RouteScopeCheckResponse(bool HasScope);
public record RegionScopeCheckResponse(bool HasScope);
public record AccessibleStoresResponse(List<Guid> StoreIds);
@@ -0,0 +1,6 @@
namespace Distribution.Infra.Authorization;
public interface IJwtTokenService
{
string GenerateToken(Models.User user);
}
@@ -0,0 +1,47 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Authorization;
public class JwtTokenService : IJwtTokenService
{
private readonly IConfiguration _configuration;
public JwtTokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateToken(Models.User user)
{
var jwtSection = _configuration.GetSection("Jwt");
var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = jwtSection["Issuer"];
var audience = jwtSection["Audience"];
var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120;
var claims = new List<Claim>
{
new("sub", user.Id.ToString()),
new("username", user.Username),
new("role", user.Role?.Name ?? string.Empty),
new("role_id", user.FK_RoleId.ToString()),
new("role_level", (user.Role?.Level ?? 0).ToString())
};
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.Now.AddMinutes(expiryMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,6 @@
namespace Distribution.Infra.Authorization;
public interface IPermissionRepository
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
}
@@ -0,0 +1,6 @@
namespace Distribution.Infra.Authorization;
public interface IPermissionService
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
}
@@ -0,0 +1,13 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Infra.Authorization;
public class PermissionRepository(AppDBContext context) : IPermissionRepository
{
public async Task<bool> HasPermissionAsync(Guid roleId, string permissionCode) =>
await context.RolePermissions
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.Join(context.Permissions.Where(p => p.IsActive), rp => rp.FK_PermissionId, p => p.Id, (rp, p) => p.Code)
.AnyAsync(code => code == permissionCode);
}
@@ -0,0 +1,7 @@
namespace Distribution.Infra.Authorization;
public class PermissionService(IPermissionRepository repo) : IPermissionService
{
public Task<bool> HasPermissionAsync(Guid roleId, string permissionCode) =>
repo.HasPermissionAsync(roleId, permissionCode);
}
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Authorization;
namespace Distribution.Infra.Authorization;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly IPermissionService _permissionService;
public PermissionAuthorizationHandler(IPermissionService permissionService)
{
_permissionService = permissionService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var roleIdClaim = context.User.FindFirst("role_id")?.Value;
if (!Guid.TryParse(roleIdClaim, out var roleId))
return;
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
context.Succeed(requirement);
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace Distribution.Infra.Authorization;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.Contains('.'))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
}
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
namespace Distribution.Infra.Authorization;
public class PermissionRequirement : IAuthorizationRequirement
{
public string PermissionCode { get; }
public PermissionRequirement(string permissionCode)
{
PermissionCode = permissionCode;
}
}
@@ -0,0 +1,9 @@
namespace Distribution.Infra.Authorization;
public interface IScopeAccessService
{
Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId);
Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId);
Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId);
Task<List<Guid>> GetAccessibleStoreIdsAsync(Guid userId);
}
@@ -0,0 +1,13 @@
namespace Distribution.Infra.Authorization;
public interface IScopeRepository
{
Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId);
Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId);
Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId);
Task<List<Guid>> GetDirectStoreIdsAsync(Guid userId);
Task<List<Guid>> GetRouteIdsAsync(Guid userId);
Task<List<Guid>> GetRegionIdsAsync(Guid userId);
Task<List<Guid>> GetStoreIdsByRouteIdsAsync(List<Guid> routeIds);
Task<List<Guid>> GetRouteIdsByRegionIdsAsync(List<Guid> regionIds);
}
@@ -0,0 +1,30 @@
namespace Distribution.Infra.Authorization;
public class ScopeAccessService(IScopeRepository repo) : IScopeAccessService
{
public Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId) =>
repo.HasStoreScopeAsync(userId, storeId);
public Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId) =>
repo.HasRouteScopeAsync(userId, routeId);
public Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId) =>
repo.HasRegionScopeAsync(userId, regionId);
public async Task<List<Guid>> GetAccessibleStoreIdsAsync(Guid userId)
{
var directStoreIds = await repo.GetDirectStoreIdsAsync(userId);
var routeIds = await repo.GetRouteIdsAsync(userId);
var regionIds = await repo.GetRegionIdsAsync(userId);
var storeIdsViaRoute = await repo.GetStoreIdsByRouteIdsAsync(routeIds);
var routeIdsViaRegion = await repo.GetRouteIdsByRegionIdsAsync(regionIds);
var storeIdsViaRegion = await repo.GetStoreIdsByRouteIdsAsync(routeIdsViaRegion);
return directStoreIds
.Concat(storeIdsViaRoute)
.Concat(storeIdsViaRegion)
.Distinct()
.ToList();
}
}
@@ -0,0 +1,46 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Infra.Authorization;
public class ScopeRepository(AppDBContext context) : IScopeRepository
{
public async Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId) =>
await context.UserStoreScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_StoreId == storeId && s.IsActive);
public async Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId) =>
await context.UserRouteScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RouteId == routeId && s.IsActive);
public async Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId) =>
await context.UserRegionScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId && s.IsActive);
public async Task<List<Guid>> GetDirectStoreIdsAsync(Guid userId) =>
await context.UserStoreScopes
.Where(s => s.FK_UserId == userId && s.IsActive)
.Select(s => s.FK_StoreId)
.ToListAsync();
public async Task<List<Guid>> GetRouteIdsAsync(Guid userId) =>
await context.UserRouteScopes
.Where(s => s.FK_UserId == userId && s.IsActive)
.Select(s => s.FK_RouteId)
.ToListAsync();
public async Task<List<Guid>> GetRegionIdsAsync(Guid userId) =>
await context.UserRegionScopes
.Where(s => s.FK_UserId == userId && s.IsActive)
.Select(s => s.FK_RegionId)
.ToListAsync();
public async Task<List<Guid>> GetStoreIdsByRouteIdsAsync(List<Guid> routeIds) =>
await context.Stores
.Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive)
.Select(s => s.Id)
.ToListAsync();
public async Task<List<Guid>> GetRouteIdsByRegionIdsAsync(List<Guid> regionIds) =>
await context.Routes
.Where(r => regionIds.Contains(r.FK_RegionId) && r.IsActive)
.Select(r => r.Id)
.ToListAsync();
}
+3 -1
View File
@@ -9,7 +9,7 @@ using Distribution.Features.Store;
using Distribution.Features.User;
using Distribution.Features.Vehicle;
using Distribution.Features.Warehouse;
using Distribution.Infra.Auth;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
@@ -49,6 +49,8 @@ builder.Services.AddScoped<AuthService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Port=3306;Database=distribution_db;User=root;Password=root"
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution_db;User=kalana;Password=kalana@hexdive"
},
"Logging": {
"LogLevel": {
+115
View File
@@ -0,0 +1,115 @@
# 00 — Existing Gap Fixes
**Status**: Not Started
**Depends On**: —
---
## Goal
Close every hole identified in the currently-implemented modules (Region, Route, Vehicle, Store,
Warehouse, Order, User/Role/Permission) before any feature from [mainPlan.md](./mainPlan.md)
is built on top of them. This file is the running list of "Partially implemented" and foundational
"Not implemented" items pulled from that reference doc that block later steps, not just cosmetic
cleanup.
---
## Gaps to Fix
### 1. Route DTO — Missing RegionId
`RouteDTOs.cs` does not expose `RegionId` on create/update, so a Route cannot be linked to a
Region via the API even though `Route.FK_RegionId` exists on the model. Blocks §2/§3 of the
function reference (`assignAreaToRegion`, `getRegionHierarchy`) and any region-scoped authorization.
### 2. Store DTO — Missing RouteId
`StoreDTOs.cs` does not expose `RouteId` on create/update, so a Store cannot be linked to a Route
via the API even though `Store.FK_RouteId` exists on the model. Blocks `assignOutletsToRoute` (§3)
and route/region-scoped authorization for Stores and Orders.
### 3. Role Controller — Missing Authorization
`RoleController`'s GET endpoints carry only `[Authorize]`, with no `[Authorize(Policy = "...")]`
per action like every other controller. Any authenticated user — including a SalesRep — can read
every role and its full permission list today.
### 4. Secrets & Configuration Management
JWT signing key and DB connection string currently live in `appsettings.json`. Move both to
environment variables or user secrets before any non-local environment is stood up.
### 5. Input Validation
No DTO in any feature carries validation attributes (`[Required]`, `[MaxLength]`, range checks,
etc.). Bad data (empty names, negative levels, malformed emails) can enter the system silently
through any existing POST/PUT endpoint.
### 6. Item Model — Missing Properties
`Item : Template` has no properties (no SKU, name, unit of measure, category). `OrderItem`
references `Item`, but there's nothing there to reference. Fully scoped as its own step in
[01_item_catalog.md](./01_item_catalog.md) — tracked here only as the blocking dependency.
### 7. Order Has No Status / State Machine
`Order` has no status field, so `updateOrderStatus`, `approveOrder`/`rejectOrder`, and
`convertOrderToInvoice` (§1 of the function reference) have nowhere to write to. Every order-status
consumer downstream (delivery ride execution, sale ride execution, reconciliation, agent controls)
depends on this existing first.
### 8. No Credit Limit / Segment Fields on Store
`validateCreditLimit`, `updateStoreCreditLimit`, `getStoreOutstandingBalance`, and `classifyStore`
(§1, §4) all need fields that don't exist on `Store` today (credit limit, outstanding balance,
segment). Not scoped to a single feature file yet — flagged here so it isn't silently assumed by
a later step.
### 9. No Area Entity Between Region and Route
`assignAreaToRegion`, `createRoute(name, areaId)` (§2, §3) assume an `Area` level between Region
and Route that doesn't exist in the schema — only `Region → Route → Store` exists. Confirm with
the user whether `Area` is a real requirement or whether Region/Route is the intended two-level
hierarchy before building anything that assumes a third level.
### 10. No Driver Entity
`DeliveryRun.FK_DriverId`/`Driver` navigation is commented out in the model. `assignDriverToVehicle`,
`checkDriverCompliance` (§5) and any ride that needs a named driver depend on this existing.
Blocks [03_ride_lifecycle.md](./03_ride_lifecycle.md).
---
## Scope (What This Step Does NOT Include)
- No new feature modules (Procurement, Finance, Promotions, etc.) — those are separate steps,
see [mainPlan.md](./mainPlan.md) for the full list and their own
numbered files below.
- No UI/client changes.
- No changes to authentication (JWT) — only authorization policy coverage on `RoleController`.
---
## Business Rules
- A gap fix must not change the shape of an already-shipped response DTO in a breaking way without
flagging it — additive fields only unless explicitly agreed.
- Soft-delete (`IsActive`) pattern must be preserved on every entity touched.
---
## Open Questions
- Is `Area` a real business concept (a level between Region and Route), or should
`createRoute`/`assignAreaToRegion` in the function reference be reinterpreted as Region-only?
- Should `Store.CreditLimit` and `Store.OutstandingBalance` be added now as part of gap-fixing, or
deferred to a dedicated Finance step (§13)?
- Does `Order.Status` need a fixed enum now (`pending/approved/picked/dispatched/delivered/invoiced`
per §1), or a smaller interim set until the fuller order lifecycle is built?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+79
View File
@@ -0,0 +1,79 @@
# 01 — Item Catalog
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Give `Item` real properties and a CRUD feature slice, so it can be created, updated, deactivated,
and referenced meaningfully from `OrderItem`. Covers `createProduct` and `deactivateSku` from
[mainPlan.md §7](./mainPlan.md#7-master-data-management). Price lists and
supplier master data are intentionally out of scope — see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md).
---
## Background / Context
`Models/Item.cs` currently only inherits `Template` (`Id`, `CreatedAt`, `IsActive`) with no
properties of its own. `OrderItem` already has `FK_ItemId` wired, but there's no data behind it and
no `Features/Item/` slice exists. This is the same gap flagged as #6 in [00_existing_gaps.md](./00_existing_gaps.md).
---
## Data Model Changes
### Item
Add to `Models/Item.cs` (still inherits `Template`):
- `Sku` (string, unique, required) — the stock-keeping unit code.
- `Name` (string, required)
- `Unit` (string, required) — unit of measure (e.g. `EA`, `CASE`, `KG`).
- `Category` (string, optional) — for grouping/reporting; not a separate entity in this step.
- `BasePrice` (decimal) — default price used until §15 (price lists) is built.
### Related Changes to Existing Models
- No changes to `OrderItem` — `FK_ItemId` already points at the right place.
- `deactivateSku` maps directly onto the existing soft-delete (`IsActive = false`) pattern; no new
field needed.
---
## API Endpoints
### Item — `/api/items`
| Method | Route | Description | Function reference |
|---|---|---|---|
| GET | `/` | Get all active items | — |
| GET | `/{id}` | Get item by id | — |
| POST | `/` | Create item | `createProduct(skuData)` |
| PUT | `/{id}` | Update item | — |
| DELETE | `/{id}` | Soft delete (deactivate) | `deactivateSku(sku, reason)` — reason is logged, not stored, in v1 |
---
## Business Rules
- `Sku` must be unique among active items.
- An `Item` cannot be hard-deleted once referenced by any `OrderItem` — deactivate only.
- Follow the existing feature-slice pattern exactly: `ItemDTOs.cs`, `IItemRepository.cs`,
`ItemRepository.cs`, `ItemService.cs`, `ItemController.cs` (see `Features/Region/` for the
reference shape).
---
## Open Questions
- Does `deactivateSku` need a stored `reason` field, or is an audit-log entry (§18, once built)
sufficient instead of a new column on `Item`?
- Should `Category` be a free-text field in v1, or a proper lookup entity from day one?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+88
View File
@@ -0,0 +1,88 @@
# 02 — Stock Ledger
**Status**: Not Started
**Depends On**: 01 — Item Catalog
---
## Goal
Introduce stock-per-location as a first-class, transaction-driven concept. Covers
[mainPlan.md §8](./mainPlan.md#8-enterprise-wide-inventory--stock) in full,
plus the location-query half of §6 (`getStockByLocation`, `putawayStock`). Everything downstream —
loading events, delivery/sale ride execution, reconciliation — reads or writes through this ledger.
---
## Background / Context
Nothing in the current schema tracks quantity of an `Item` anywhere. `checkStockAvailability` (§1)
and every warehouse function in §6 have no data source to query today. This step must land before
[04_loading_event.md](./04_loading_event.md).
---
## Data Model Changes
### Stock Balance (per location)
A denormalized running balance per `(ItemId, LocationId)` pair, kept in sync by the movement
transaction table below — never edited directly by any service.
### Stock Movement Transaction
One row per stock change: `ItemId`, `LocationId`, `Quantity` (signed: +load/return, -sale/damage),
`MovementType` (`Load`, `Sale`, `Return`, `Damage`, `Adjustment`), `ReferenceId` (the order/ride/GRN
that caused it), `CreatedAt`. This is the source of truth; the balance is its running sum.
### Location Abstraction (Warehouse vs Vehicle)
A `Location` concept that can resolve to either a `Warehouse` or a `Vehicle` (once a ride is open —
see [03_ride_lifecycle.md](./03_ride_lifecycle.md)), so the same movement/balance model serves both
`getMultiLocationStock` (§8) and the vehicle-as-temporary-warehouse pattern.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `getMultiLocationStock(sku)` (§8) | `GET /api/stock/items/{sku}` — balance across every location |
| `getStockByLocation(warehouseId, sku)` (§6) | `GET /api/stock/locations/{locationId}/items/{sku}` |
| `setReorderPoint(sku, warehouseId, threshold)` (§8) | `PUT /api/stock/locations/{locationId}/items/{sku}/reorder-point` |
| `createStockTransfer(fromWarehouseId, toWarehouseId, items[])` (§8) | `POST /api/stock/transfers` |
| `trackBatchExpiry(sku, batchNo, expiryDate)` (§8) | Deferred — see Open Questions |
| `calculateStockValuation(warehouseId, method)` (§8) | `GET /api/stock/locations/{locationId}/valuation?method=fifo\|weighted-average` |
---
## Business Rules
- Stock is never edited directly — every change is a `Stock Movement Transaction`; the balance is
always the running sum, recalculated or cached but never hand-edited.
- A movement cannot take a balance negative unless explicitly flagged as an `Adjustment` (physical
count correction).
- Every movement must carry a `ReferenceId` back to the business event that caused it — no
freestanding movements outside of documented `Adjustment`s.
---
## Fraud / Control Considerations
- `Adjustment` movements should be restricted to Supervisor level and above (see role-permission
mapping in `Data/Seed/RoleAndPermissionSeed.cs`), and should require a reason code once
[09_fraud_controls.md](./09_fraud_controls.md) lands.
---
## Open Questions
- Is batch/expiry tracking (`trackBatchExpiry`) needed in v1, or can it be deferred until a
regulated/perishable product line requires FEFO enforcement?
- FIFO vs weighted-average valuation — does the business need both, or a single fixed method?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+82
View File
@@ -0,0 +1,82 @@
# 03 — Ride Lifecycle
**Status**: Not Started
**Depends On**: 02 — Stock Ledger
---
## Goal
Turn the currently-unused `DeliveryRun` model into a working ride: a session that turns a vehicle
into a temporary warehouse. Covers `assignVehicleToRoute`, `assignDriverToVehicle`,
`checkDriverCompliance` from [mainPlan.md §5](./mainPlan.md#5-vehicle--fleet-management)
and `planDeliveryRoute` from §3.
---
## Background / Context
`Models/DeliveryRun.cs` exists (`FK_VehicleId`, `FK_RouteId`, `Meterstart`, `Meterend`) but has no
repository, service, or controller — it is completely unwired today, per [mainPlan.md](./mainPlan.md)'s
"Known Gaps" table. `FK_DriverId`/`Driver` navigation is commented out because no `Driver` entity
exists yet (gap #10 in [00_existing_gaps.md](./00_existing_gaps.md)) — this step is blocked on that
being resolved first if driver assignment is required at ride-open time.
---
## Data Model Changes
### Ride (extending DeliveryRun)
Add a `Status` field to `DeliveryRun` for the state machine below, and a `RideType` field
(`Delivery` | `Sale`).
### Ride State Machine
`Draft → Open → Active → Closed`. A vehicle can only be on one `Open`/`Active` ride at a time —
enforced in the service layer, not the database.
### Ride Type (Delivery vs Sale)
`Delivery` rides fulfill pre-confirmed orders (see [05_delivery_ride.md](./05_delivery_ride.md));
`Sale` rides create invoices on the spot with no pre-orders (see [06_sale_ride.md](./06_sale_ride.md)).
Both share the same ride lifecycle and loading event.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignVehicleToRoute(vehicleId, routeId)` (§5) | Implicit in `POST /api/rides` — a ride binds a vehicle to a route for its duration |
| `assignDriverToVehicle(vehicleId, driverId)` (§5) | `PUT /api/rides/{id}/driver` — blocked on Driver entity, see gap #10 |
| `checkDriverCompliance(driverId)` (§5) | Called internally before a ride can move `Draft → Open` |
| `planDeliveryRoute(warehouseId, orderIds[])` (§3) | `POST /api/rides` (for `RideType = Delivery`) |
| — | `POST /api/rides/{id}/open`, `POST /api/rides/{id}/close` — state transitions |
---
## Business Rules
- A ride cannot open without a compliant driver (once Driver entity exists) and a vehicle not
already on an open ride.
- Opening a ride creates the vehicle's stock ledger location (see [02_stock_ledger.md](./02_stock_ledger.md)).
---
## Fraud / Control Considerations
- `checkDriverCompliance` failures must block ride open, not just warn.
---
## Open Questions
- Is a `Driver` a new entity distinct from `User`, or is "driver" just a `User` with a specific
`Role`? Affects whether gap #10 needs a new table or just a role addition.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+83
View File
@@ -0,0 +1,83 @@
# 04 — Loading Event
**Status**: Not Started
**Depends On**: 03 — Ride Lifecycle
---
## Goal
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
Covers the dispatch half of [mainPlan.md §6](./mainPlan.md#6-warehouse-management):
`generatePickList`, `confirmPicking`, `packOrder`, `buildDispatchLoad`, and `calculateLoadPlan`
from §5.
---
## Background / Context
This is the first place the [02_stock_ledger.md](./02_stock_ledger.md) movement model gets
exercised end-to-end: a `Load` movement debits the warehouse and credits the vehicle location
created when the ride opened ([03_ride_lifecycle.md](./03_ride_lifecycle.md)).
---
## Data Model Changes
### Loading Sheet
Header record: `RideId`, `SourceWarehouseId`, `Status` (`Pending`, `DispatchConfirmed`,
`ReceiptConfirmed`, `DiscrepancyFlagged`, `Resolved`).
### Loading Line Item
`LoadingSheetId`, `ItemId`, `QuantityDispatched` (set by warehouse manager), `QuantityReceived`
(set by driver) — a mismatch between the two is a discrepancy.
### Two-Party Confirmation
Warehouse manager confirms what was dispatched (`generatePickList` → `confirmPicking` →
`packOrder`); driver separately confirms what was received onto the vehicle. Both confirmations are
required before the ride can move `Open → Active`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `generatePickList(orderIds[])` (§6) | `POST /api/loading-sheets` |
| `confirmPicking(pickListId, pickedItems[])` (§6) | `POST /api/loading-sheets/{id}/confirm-dispatch` |
| `packOrder(orderId, packageDetails)` (§6) | `POST /api/loading-sheets/{id}/pack` |
| `buildDispatchLoad(vehicleId, orderIds[])` (§6) | Groups multiple orders into one loading sheet |
| `calculateLoadPlan(vehicleId, orderIds[])` (§5) | Validates total volume/weight against vehicle capacity before dispatch confirmation |
| — | `POST /api/loading-sheets/{id}/confirm-receipt` — driver-side confirmation |
---
## Business Rules
- `QuantityDispatched != QuantityReceived` on any line flags the sheet `DiscrepancyFlagged` and
blocks ride activation until resolved.
- `calculateLoadPlan` must reject a load exceeding vehicle capacity before dispatch is confirmed,
not after.
---
## Fraud / Control Considerations
- Discrepancies must be immediately visible, not silently averaged into stock adjustments — see
[09_fraud_controls.md](./09_fraud_controls.md).
---
## Open Questions
- Does `Vehicle` need a `Capacity` field added now (volume/weight), or is `calculateLoadPlan`
deferred until that's available?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+79
View File
@@ -0,0 +1,79 @@
# 05 — Delivery Ride Execution
**Status**: Not Started
**Depends On**: 04 — Loading Event
---
## Goal
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half
of [mainPlan.md §1](./mainPlan.md#1-order-management):
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
---
## Background / Context
Today `Order` has no status and no link to a ride. This step is where the order lifecycle
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)).
---
## Data Model Changes
### Delivery Task (per order on ride)
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`,
`Failed`).
### Delivery Outcome
Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder`
for the shortfall.
### Customer Confirmation
Signature or OTP captured against the delivery task before it can be marked complete.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` |
| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) |
| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` |
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP |
---
## Business Rules
- A delivery task cannot be marked `Delivered` without a customer confirmation.
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape).
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle
location per [02_stock_ledger.md](./02_stock_ledger.md).
---
## Fraud / Control Considerations
- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not
editable after submission).
---
## Open Questions
- Signature capture (image) or OTP (SMS code) — one, or both, in v1?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+80
View File
@@ -0,0 +1,80 @@
# 06 — Sale Ride Execution
**Status**: Not Started
**Depends On**: 04 — Loading Event
---
## Goal
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is
generated immediately, and payment is captured at point of sale. Covers `createInvoice`,
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting),
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
(§11) if promotions are live by the time this ships.
---
## Background / Context
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row —
the field invoice *is* the order, created and settled in one step.
---
## Data Model Changes
### Field Invoice
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`,
`OnCredit`).
### Field Invoice Line Item
`FieldInvoiceId`, `ItemId`, `Quantity`, `UnitPrice` (subject to the discount-limit rules in
[08_sales_agent_controls.md](./08_sales_agent_controls.md)).
### Payment Record
`FieldInvoiceId`, `Amount`, `Method` (`Cash`, `Credit`), `CollectedAt`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here |
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` |
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits |
---
## Business Rules
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the
invoice is finalized.
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location,
immediately (not batched at ride close).
- Discount applied per line must not exceed the agent's role-level limit (§8).
---
## Fraud / Control Considerations
- Cash collected must reconcile against invoiced totals at ride close — see
[07_ride_reconciliation.md](./07_ride_reconciliation.md).
---
## Open Questions
- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one
sequence?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,89 @@
# 07 — Ride Close & Reconciliation
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
---
## Goal
Physically count what's left on the vehicle at ride close and reconcile it against the stock
ledger and cash collected. Covers `runCycleCount` and `reconcileStock` from
[mainPlan.md §6](./mainPlan.md#6-warehouse-management), applied to a
vehicle location instead of a warehouse zone.
---
## Background / Context
This is the control point that catches loading discrepancies ([04_loading_event.md](./04_loading_event.md))
that went unresolved, and any shortfall between what was sold/delivered and what's physically on
the vehicle.
---
## Data Model Changes
### Ride Close Summary
`RideId`, `ClosedAt`, `ClosedByUserId`, computed totals for the reconciliation equation below.
### Variance Record
`RideId`, `ItemId` (or `Cash` for the monetary variance), `ExpectedQuantity`/`ExpectedAmount`,
`ActualQuantity`/`ActualAmount`, `Variance`, `ReasonCode`, `Status` (`Pending`, `Approved`,
`Rejected`).
### Supervisor Approval
Every non-zero variance requires a supervisor-level approval (`Role.Level >= 20`) before the ride
can transition to `Closed`.
---
## The Reconciliation Equation
```
Opening Stock − Sales − Damages + Returns = Closing Stock (physical count) [runCycleCount / reconcileStock]
Cash Collected + Credit Extended = Total Sales − Returns
```
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `runCycleCount(warehouseId, zoneId)` (§6) | `POST /api/rides/{id}/cycle-count` — `zoneId` maps to the ride's vehicle location |
| `reconcileStock(warehouseId, countResults)` (§6) | `POST /api/rides/{id}/reconcile` |
| — | `POST /api/rides/{id}/variances/{id}/approve` — supervisor sign-off |
| — | `POST /api/rides/{id}/close` — only succeeds once every variance is `Approved` |
---
## Business Rules
- A ride cannot close with an unexplained variance — every `Variance` row must be `Approved` or
`Rejected` (with the discrepancy converted to a stock `Adjustment` movement) before close.
- `reconcileStock` writes an `Adjustment` movement per variance line, never edits the running
balance directly (see [02_stock_ledger.md](./02_stock_ledger.md)).
---
## Fraud / Control Considerations
- Unresolved/high-frequency discrepancies are logged permanently against the agent's record — feeds
the anomaly detection in [09_fraud_controls.md](./09_fraud_controls.md).
---
## Open Questions
- Is there a variance threshold below which supervisor approval is auto-granted (e.g. rounding-
level cash differences), or does every non-zero variance require explicit sign-off?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,87 @@
# 08 — Sales Agent Controls
**Status**: Not Started
**Depends On**: 07 — Ride Close & Reconciliation
---
## Goal
Wire role levels to concrete business limits and approval chains. Covers the approval-routing half
of [mainPlan.md §1](./mainPlan.md#1-order-management)
(`submitOrderForApproval`, `approveOrder`, `rejectOrder`) and the performance-tracking functions
from [§10 SFA/CRM](./mainPlan.md#10-sales-force-automation-sfa--crm)
(`trackTargetVsAchievement`, `calculateIncentive`). Lead/interaction functions from §10
(`logCustomerInteraction`, `createLead`, `convertLeadToStore`) are out of scope here — see
[16_sfa_crm.md](./16_sfa_crm.md).
---
## Background / Context
`Role.Level` already exists and drives the permission seed
(`Data/Seed/RoleAndPermissionSeed.cs`), but nothing today turns a level into a discount limit,
credit limit, or approval threshold — it only gates which API endpoints a role can call.
---
## Data Model Changes
### Agent Limit Configuration (per role level)
`RoleId`, `MaxDiscountPercent`, `MaxSingleTransactionValue` — read by both
[06_sale_ride.md](./06_sale_ride.md) (line-item discount check) and order approval routing here.
### Override Log
`OverrideById` (the supervisor), `TargetType`/`TargetId` (e.g. `Order`, `FieldInvoice`), `OldValue`,
`NewValue`, `Reason`, `CreatedAt` — a one-time exception, logged against the supervisor's account.
### Customer Credit Profile
Reuses `Store.CreditLimit`/`Store.OutstandingBalance` once added (gap #8 in
[00_existing_gaps.md](./00_existing_gaps.md)) rather than a separate entity.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `submitOrderForApproval(orderId)` (§1) | Triggered automatically when an order exceeds its agent's limit |
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` (§1) | `POST /api/orders/{id}/approve`, `POST /api/orders/{id}/reject` |
| `trackTargetVsAchievement(userId, period)` (§10) | `GET /api/agents/{userId}/performance?period=...` |
| `calculateIncentive(userId, period)` (§10) | `GET /api/agents/{userId}/incentive?period=...` |
| — | `POST /api/orders/{id}/override` — supervisor one-time exception, writes an Override Log entry |
---
## Business Rules
- An order/invoice whose value or discount exceeds the agent's `Agent Limit Configuration` cannot
proceed without either supervisor approval or a logged override — no silent pass-through.
- `calculateIncentive` reads only settled/closed rides (post-[07_ride_reconciliation.md](./07_ride_reconciliation.md)),
never in-flight ones.
---
## Fraud / Control Considerations
- Every `Override Log` entry is permanent and immutable once written — feeds
[09_fraud_controls.md](./09_fraud_controls.md)'s anomaly detection (agents with unusually
frequent overrides).
---
## Open Questions
- Are discount/transaction limits configured per `Role`, or can an individual `User` have a
tighter/looser override of their role's default?
- What sales target source feeds `trackTargetVsAchievement` — is there a target-setting endpoint
needed here, or does it come from §2's `setRegionalTarget` rolled down to the individual?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+97
View File
@@ -0,0 +1,97 @@
# 09 — Fraud Controls & Audit Trail
**Status**: Not Started
**Depends On**: 08 — Sales Agent Controls
---
## Goal
A dedicated, immutable audit log plus automated anomaly detection over everything built in steps
01–08. Covers [mainPlan.md §18](./mainPlan.md#18-compliance--audit)
(`logAuditEvent`, `checkLicenseCompliance`, `generateComplianceReport`) and the fraud-relevant piece
of §15 (`sendRouteDeviationAlert`) and §12 (return-without-prior-sale detection).
---
## Background / Context
Every prior step in this plan references "logged" or "flagged" behavior (override logs, loading
discrepancies, reconciliation variances) — this step is where that logging actually becomes a
queryable, immutable audit trail instead of scattered per-feature notes.
---
## Data Model Changes
### Audit Log Entry
`UserId`, `Action`, `EntityType`, `EntityId`, `OldValue`/`NewValue` (JSON snapshot), `CreatedAt`,
`SourceIp`/`RequestId` if available. Append-only — no update or delete path exposed anywhere.
### Anomaly Flag
`Type` (`ReturnWithoutSale`, `RouteDeviation`, `HighVarianceRate`, `CashShortfall`), `EntityType`/
`EntityId`, `RaisedAt`, `Status` (`Open`, `Reviewed`, `Dismissed`).
### Return Approval Record
Reuses the `approveReturn` flow from [18_returns_claims.md](./18_returns_claims.md) — this step
adds the automated pre-check (`ReturnWithoutSale`) that runs before a human approver sees it.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `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)) |
| — | `GET /api/audit-log?entityType=...&entityId=...` |
| — | `GET /api/anomaly-flags?status=Open` |
---
## 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).
- 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
[06_sale_ride.md](./06_sale_ride.md).
---
## Business Rules
- Every state-changing action across every feature must call `logAuditEvent` — this is the one
cross-cutting requirement every other numbered step inherits once this lands.
- Return approval requires supervisor sign-off before stock is credited back
([18_returns_claims.md](./18_returns_claims.md)), regardless of whether an anomaly flag fired.
---
## Fraud / Control Considerations
- Anomaly reports are surfaced to area managers on a scheduled basis — batch job or on-demand
endpoint, not real-time push, in v1.
---
## Open Questions
- Should `logAuditEvent` be wired in as a cross-cutting concern (e.g. an EF `SaveChanges`
interceptor) rather than an explicit call in every service, to guarantee nothing is missed?
- What licenses does `checkLicenseCompliance` actually need to validate for this business — is
regulated goods handling in scope at all, or is this function speculative until a real
requirement shows up?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+71
View File
@@ -0,0 +1,71 @@
# 10 — Region Management (Beyond CRUD)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Region CRUD already exists (`Features/Region`). This step covers everything in
[mainPlan.md §2](./mainPlan.md#2-region-management) that isn't plain
CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
---
## Background / Context
`Region` today has only a `Name`. Nothing points to it except `Route.FK_RegionId`. There is no
`Area` level (see Open Question in [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)).
---
## Data Model Changes
### Regional Target / Budget
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
### Agency-Region Map
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
`Role` (see Open Questions).
### Regional Tax Rule
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
---
## Open Questions
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
third party with its own record), or is the role sufficient?
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,71 @@
# 11 — Route & Sales Visit Management
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Route CRUD already exists (`Features/Route`). This step covers the sales-visit half of
[mainPlan.md §3](./mainPlan.md#3-route-management) — the part that is
about a SalesRep's daily visit schedule, not the ride/dispatch route from
[03_ride_lifecycle.md](./03_ride_lifecycle.md)'s `planDeliveryRoute`.
---
## Background / Context
Today a `Store` has a single `FK_RouteId` (bulk-assign not yet supported via API — see gap #2 in
[00_existing_gaps.md](./00_existing_gaps.md)), and route access is only granted via `UserRouteScope`
(a many-to-many grant), not single ownership. There is no visit check-in/out data anywhere.
---
## Data Model Changes
### Route Ownership
`RouteId`, `OwnerUserId` — single-owner assignment, distinct from the broader `UserRouteScope`
access grant which may cover multiple users (e.g. a Supervisor overseeing several reps' routes).
### Route Visit Log
`RouteId`, `StoreId`, `UserId`, `CheckInAt`, `CheckInGps`, `CheckOutAt` — one row per planned visit.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignOutletsToRoute(routeId, outletIds[])` | `POST /api/routes/{id}/stores` — bulk version of the existing single `Store.RouteId` field |
| `assignRouteToSalesRep(routeId, salesRepId)` | `PUT /api/routes/{id}/owner` |
| `optimizeRouteSequence(routeId)` | `POST /api/routes/{id}/optimize` |
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | `POST /api/routes/{id}/visits/check-in` |
| `recordRouteCheckOut(routeId, outletId, timestamp)` | `POST /api/routes/{id}/visits/check-out` |
| `getRouteAdherence(routeId, date)` | `GET /api/routes/{id}/adherence?date=...` |
| `reassignRoute(routeId, newSalesRepId)` | `PUT /api/routes/{id}/owner` (same as assign, different actor) |
| `getRoutePerformance(routeId, period)` | `GET /api/routes/{id}/performance?period=...` |
---
## Business Rules
- Check-in `gpsCoords` feeds `sendRouteDeviationAlert` in
[09_fraud_controls.md](./09_fraud_controls.md) — a check-in materially outside the store's
registered location should flag, not silently accept.
- `getStoreVisitHistory` (§4) reads directly from `Route Visit Log` — no separate storage.
---
## Open Questions
- Is route sequence optimization (`optimizeRouteSequence`) a manual reorder endpoint, or does it
need actual distance/priority-based computation (external mapping API)?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+74
View File
@@ -0,0 +1,74 @@
# 12 — Store Management (Beyond CRUD)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Store CRUD already exists (`Features/Store`). This step covers everything in
[mainPlan.md §4](./mainPlan.md#4-store-outlet-management) that isn't
plain CRUD: KYC, credit, segmentation, and dashboarding.
---
## Background / Context
`Store` currently has no credit limit, outstanding balance, segment, or KYC fields — flagged as
gap #8 in [00_existing_gaps.md](./00_existing_gaps.md).
---
## Data Model Changes
### Store — New Fields
`CreditLimit` (decimal), `OutstandingBalance` (decimal, derived/cached from Finance ledger once
[19_finance_accounting.md](./19_finance_accounting.md) exists), `Segment` (string), `KycStatus`
(`Pending`, `Verified`, `Rejected`).
### KYC Document
`StoreId`, `DocumentType`, `FileReference`, `UploadedAt` — file storage mechanism TBD (out of scope
for this step's data model, just the record).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createStore(storeData, kycDocs)` | Extends existing `POST /api/stores` with doc upload |
| `verifyStoreKyc(storeId)` | `PUT /api/stores/{id}/kyc/verify` |
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | `PUT /api/stores/{id}/credit-limit` |
| `getStoreOutstandingBalance(storeId)` | `GET /api/stores/{id}/outstanding-balance` |
| `classifyStore(storeId, segment)` | `PUT /api/stores/{id}/segment` |
| `deactivateStore(storeId, reason)` | Extends existing `DELETE /api/stores/{id}` with a reason field |
| `getStoreVisitHistory(storeId)` | `GET /api/stores/{id}/visit-history` — reads from [11_route_sales_management.md](./11_route_sales_management.md) |
| `checkStoreSchemeEligibility(storeId, schemeId)` | `GET /api/stores/{id}/schemes/{schemeId}/eligibility` — depends on [17_promotions_schemes.md](./17_promotions_schemes.md) |
| `getStoreSalesDashboard(storeId, period)` | `GET /api/stores/{id}/dashboard?period=...` |
---
## Business Rules
- `updateStoreCreditLimit` requires an approver at Supervisor level or above.
- A store with `KycStatus != Verified` should not be able to place orders above a configurable
threshold (or at all) — confirm exact rule with the user before implementing.
- `deactivateStore` blocks new orders against it, matching the existing `IsActive` soft-delete
filter already used in `OrderService`.
---
## Open Questions
- Is `OutstandingBalance` a cached/denormalized field updated on invoice/payment events, or always
computed live from Finance records?
- What document types are required for KYC, and is file storage local, blob, or third-party?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+78
View File
@@ -0,0 +1,78 @@
# 13 — Fleet Management (Beyond CRUD)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Vehicle CRUD already exists (`Features/Vehicle`). This step covers everything in
[mainPlan.md §5](./mainPlan.md#5-vehicle--fleet-management) that isn't
plain CRUD or already scoped into [03_ride_lifecycle.md](./03_ride_lifecycle.md)/
[04_loading_event.md](./04_loading_event.md): maintenance, fuel, telemetry, and utilization.
---
## Background / Context
`Vehicle` today only has `RegNumber`/`NumberPlate`. No capacity, ownership, maintenance, or fuel
data exists. There is no `Driver` entity — see gap #10 in
[00_existing_gaps.md](./00_existing_gaps.md).
---
## Data Model Changes
### Vehicle — New Fields
`CapacityVolume`, `CapacityWeight` (needed by `calculateLoadPlan` in
[04_loading_event.md](./04_loading_event.md)), `OwnershipType` (`Owned`, `Leased`, `Contracted`).
### Maintenance Task
`VehicleId`, `MaintenanceType`, `DueAt` (date or mileage-based), `CompletedAt`.
### Fuel Log
`VehicleId`, `Amount`, `Liters`, `Date`.
### Telemetry Record
`VehicleId`, `GpsCoords`, `Speed`, `Timestamp` — high-volume; likely a separate store/retention
policy from the rest of the schema (see Open Questions).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `scheduleMaintenance(vehicleId, maintenanceType, dueAt)` | `POST /api/vehicles/{id}/maintenance` |
| `logFuelExpense(vehicleId, amount, liters, date)` | `POST /api/vehicles/{id}/fuel-logs` |
| `recordVehicleTelemetry(vehicleId, gpsCoords, speed, timestamp)` | `POST /api/vehicles/{id}/telemetry` — likely ingested via [22_integration_api.md](./22_integration_api.md) rather than direct client calls |
| `getVehicleUtilization(vehicleId, period)` | `GET /api/vehicles/{id}/utilization?period=...` |
| `retireVehicle(vehicleId, reason)` | Extends existing `DELETE /api/vehicles/{id}` with a reason field |
---
## Business Rules
- `retireVehicle` must be blocked while the vehicle is on an open/active ride
([03_ride_lifecycle.md](./03_ride_lifecycle.md)).
- Overdue `Maintenance Task`s should block a vehicle from being assigned to a new ride — exact
enforcement level (hard block vs. warning) to confirm.
---
## Open Questions
- Is real-time telemetry (`recordVehicleTelemetry`) actually in scope, or is odometer
start/end (`DeliveryRun.Meterstart`/`Meterend`, already modeled) sufficient for this business?
- What third-party telematics provider, if any, is being integrated (affects [22_integration_api.md](./22_integration_api.md))?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,70 @@
# 14 — Pricing & Supplier Master Data
**Status**: Not Started
**Depends On**: 01 — Item Catalog
---
## Goal
Covers the remainder of [mainPlan.md §7](./mainPlan.md#7-master-data-management)
not already handled by [01_item_catalog.md](./01_item_catalog.md): price lists and supplier master
records. `createProduct`/`deactivateSku` live in 01; this step is everything else in §7.
---
## Background / Context
`Item.BasePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat price. There is
no concept of a region- or date-scoped price list, and no `Supplier` entity anywhere in the schema.
---
## Data Model Changes
### Price List
`Name`, `RegionId` (nullable — null means global), `EffectiveFrom`, `EffectiveTo`.
### Price List Item
`PriceListId`, `ItemId`, `Price` — overrides `Item.BasePrice` when an applicable, currently-
effective price list exists for the order's region.
### Supplier
`Name`, `ContactInfo`, `PerformanceScore` (nullable, populated by
[15_procurement.md](./15_procurement.md)'s `evaluateSupplier`).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createPriceList(name, region, effectiveDates)` | `POST /api/price-lists` |
| `updateProductPricing(sku, priceListId, price)` | `PUT /api/price-lists/{id}/items/{sku}` |
| `createSupplierMaster(supplierData)` | `POST /api/suppliers` |
---
## Business Rules
- `applyPricingAndDiscounts` (§1, used in [01_item_catalog.md](./01_item_catalog.md) and
[06_sale_ride.md](./06_sale_ride.md)) resolves price in this order: region-scoped active price
list → global active price list → `Item.BasePrice`.
- Two price lists cannot have overlapping `EffectiveFrom`/`EffectiveTo` ranges for the same
`(ItemId, RegionId)` pair.
---
## Open Questions
- Are price lists customer/segment-specific as well as region-specific (ties into `Store.Segment`
from [12_store_management.md](./12_store_management.md)), or region-only in v1?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+74
View File
@@ -0,0 +1,74 @@
# 15 — Procurement / Purchasing
**Status**: Not Started
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
---
## Goal
Covers [mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) in full
— an entirely new subsystem, nothing in this area exists in the current schema.
---
## Background / Context
Inbound stock (`createGoodsReceipt` in [04_loading_event.md](./04_loading_event.md)/
[mainPlan.md §6](./mainPlan.md#6-warehouse-management)) currently has no
purchase order to receive against — this step is what creates that PO in the first place.
---
## Data Model Changes
### Purchase Requisition
`RequestedByUserId`, `Status` (`Draft`, `Submitted`, `Approved`, `Rejected`), line items
(`ItemId`, `Quantity`).
### Purchase Order
`SupplierId`, `RequisitionId` (nullable — a PO can be raised without a prior requisition), `Status`
(`Issued`, `PartiallyReceived`, `Received`, `Closed`), line items (`ItemId`, `Quantity`, `UnitCost`).
### Purchase Return
`PurchaseOrderId`, reason, line items — goods sent back to the supplier post-receipt.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createPurchaseRequisition(items[], requestedBy)` | `POST /api/purchase-requisitions` |
| `createPurchaseOrder(supplierId, items[])` | `POST /api/purchase-orders` |
| `matchInvoiceToPO(invoiceId, poId, grnId)` | `POST /api/purchase-orders/{id}/match-invoice` |
| `evaluateSupplier(supplierId, criteria)` | `POST /api/suppliers/{id}/evaluate` |
| `createPurchaseReturn(poId, items[], reason)` | `POST /api/purchase-orders/{id}/returns` |
---
## Business Rules
- `createGoodsReceipt` (§6, [04_loading_event.md](./04_loading_event.md)'s warehouse-inbound
counterpart) must reference an `Issued`/`PartiallyReceived` Purchase Order — no receipts against
a closed or nonexistent PO.
- `matchInvoiceToPO` (3-way match) must block payment posting in
[19_finance_accounting.md](./19_finance_accounting.md) until PO, GRN, and invoice quantities/
amounts reconcile within a configurable tolerance.
---
## Open Questions
- Is procurement in scope for this project at all right now, or is inbound stock assumed to be
seeded/manual for the foreseeable future? Confirm before investing in this step — it's the
largest net-new subsystem in the reference doc.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+68
View File
@@ -0,0 +1,68 @@
# 16 — Sales Force Automation (SFA) / CRM
**Status**: Not Started
**Depends On**: 12 — Store Management
---
## Goal
Covers the lead/interaction half of
[mainPlan.md §10](./mainPlan.md#10-sales-force-automation-sfa--crm):
`logCustomerInteraction`, `createLead`, `convertLeadToStore`. Performance/incentive functions
(`trackTargetVsAchievement`, `calculateIncentive`) are already scoped in
[08_sales_agent_controls.md](./08_sales_agent_controls.md) — not repeated here.
---
## Background / Context
There is no `Lead` entity or interaction log anywhere in the schema today — new outlet onboarding
currently goes straight to `POST /api/stores` with no pre-qualification step.
---
## Data Model Changes
### Lead
`ProspectName`, `ContactInfo`, `Notes`, `Status` (`New`, `Qualified`, `Converted`, `Rejected`),
`AssignedToUserId`.
### Customer Interaction
`StoreId` (nullable — can be logged against a `Lead` instead), `LeadId` (nullable), `UserId`,
`Notes`, `CreatedAt`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `logCustomerInteraction(storeId, notes)` | `POST /api/stores/{id}/interactions` |
| `createLead(prospectData)` | `POST /api/leads` |
| `convertLeadToStore(leadId)` | `POST /api/leads/{id}/convert` — creates a `Store` via the existing `POST /api/stores` path internally |
---
## Business Rules
- `convertLeadToStore` reuses `createStore` (§4, [12_store_management.md](./12_store_management.md))
rather than duplicating store-creation logic — a `Lead` conversion is not a separate creation
path.
- Exactly one of `StoreId`/`LeadId` must be set on a `Customer Interaction` row, never both, never
neither.
---
## Open Questions
- Does a `Lead` need its own scope/authorization model (region/route), or does it inherit the
creating user's scope with no further restriction until converted?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,70 @@
# 17 — Promotions & Trade Scheme Management
**Status**: Not Started
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
---
## Goal
Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management)
in full — an entirely new subsystem. Once built, `applyPricingAndDiscounts` (§1) and
`checkStoreSchemeEligibility` (§4) both read from it.
---
## Background / Context
No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this
doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source
— it only enforces the flat per-role discount ceiling from
[08_sales_agent_controls.md](./08_sales_agent_controls.md).
---
## Data Model Changes
### Scheme
`Name`, `RuleType` (`BuyXGetY`, `SlabDiscount`, `FlatPercent`), `RuleConfig` (JSON), `Eligibility`
(store segment / region scope), `DateRangeFrom`, `DateRangeTo`.
### Scheme Redemption
`SchemeId`, `OrderId` (or `FieldInvoiceId`), `DiscountAmount`, `RedeemedAt` — feeds
`trackSchemeSpend` and `getSchemeROI` without re-scanning every order.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` |
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` |
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` |
---
## Business Rules
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless explicitly flagged as an
agent-limit-exempt scheme.
- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data
source before implementing (see Open Questions).
---
## Open Questions
- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme
is created, or a trailing-period average computed automatically?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+74
View File
@@ -0,0 +1,74 @@
# 18 — Returns & Claims Management
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
---
## Goal
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
both reference this step's credit note and approval shapes — this is where those are actually
defined.
---
## Background / Context
No return, credit note, or claim entity exists today. `createBackorder` (§1,
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
---
## Data Model Changes
### Return Request
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
`Rejected`), `RequestedByUserId`.
### Credit Note
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`.
### Claim
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` |
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
---
## Business Rules
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
the `ReturnWithoutSale` anomaly flagged automatically in
[09_fraud_controls.md](./09_fraud_controls.md).
- Approving a return creates a `Return` stock movement crediting the relevant location, per
[02_stock_ledger.md](./02_stock_ledger.md).
---
## Open Questions
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
also be raised against an internal warehouse/supplier discrepancy?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,75 @@
# 19 — Finance & Accounting
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 18 — Returns & Claims Management
---
## Goal
Covers [mainPlan.md §13](./mainPlan.md#13-finance--accounting) beyond
what [06_sale_ride.md](./06_sale_ride.md) already scoped for field invoices
(`createInvoice`/`recordPayment` there are the van-sale-specific case; this step is the general
warehouse-fulfilled-order invoicing path plus GL posting and reporting).
---
## Background / Context
`convertOrderToInvoice` is referenced from [05_delivery_ride.md](./05_delivery_ride.md) but has no
home until this step. There is no invoice, payment, or GL entity for warehouse-fulfilled
(non-field-sale) orders today.
---
## Data Model Changes
### Invoice
`OrderId` (nullable if it originates from a field invoice instead), `StoreId`, `TotalAmount`,
`TaxAmount`, `Status` (`Issued`, `PartiallyPaid`, `Paid`), `IssuedAt`.
### Payment
`InvoiceId`, `Amount`, `Method`, `CollectedAt` — same shape as the `Payment Record` in
[06_sale_ride.md](./06_sale_ride.md); consider whether to unify into one table keyed by a generic
`InvoiceId` covering both field invoices and warehouse invoices.
### GL Posting
`TransactionId`, `Account`, `DebitAmount`, `CreditAmount`, `PostedAt`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` |
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |
---
## Business Rules
- `Store.OutstandingBalance` (from [12_store_management.md](./12_store_management.md)) is
maintained by this step's invoice/payment events, not edited directly.
- `postToGeneralLedger` must be idempotent per `TransactionId` — replays must not double-post.
---
## Open Questions
- Should field invoices ([06_sale_ride.md](./06_sale_ride.md)) and warehouse invoices share one
`Invoice` table, or stay as separate entities that both feed the same GL posting logic?
- Is e-invoicing (`generateTaxInvoice` for a specific jurisdiction like GST/VAT) an actual near-term
requirement, or speculative until a specific market is targeted?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,65 @@
# 20 — Reporting, Analytics & BI
**Status**: Not Started
**Depends On**: Every prior numbered step whose data it reports on (this step is a read-only
aggregation layer, not a new source of truth)
---
## Goal
Covers [mainPlan.md §14](./mainPlan.md#14-reporting-analytics--bi) in
full. This step deliberately reads data other steps already own — it does not introduce new
mutable entities.
---
## Background / Context
Individual dashboards are already referenced piecemeal elsewhere (`getRegionDashboard` in
[10_region_management.md](./10_region_management.md), `getStoreSalesDashboard` in
[12_store_management.md](./12_store_management.md), `getWarehouseKpis` in
[04_loading_event.md](./04_loading_event.md)/[07_ride_reconciliation.md](./07_ride_reconciliation.md)
context). This step is the role-aware, cross-cutting dashboard/report layer that sits above all of
them.
---
## Data Model Changes
None expected — this step reads existing tables. If a specific report proves too expensive to
compute live, a cached/materialized summary table may be added per-report, decided when that report
is actually built.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `getDashboardData(role, scope)` | `GET /api/dashboard?scope=...` — role taken from the authenticated user, not a request param |
| `generateKpiReport(kpiType, filters)` | `GET /api/reports/{kpiType}?filters=...` |
| `getPredictiveAlert(type, scope)` | `GET /api/alerts/predictive?type=...&scope=...` — explicitly flagged below as the highest-risk item in this step |
---
## Business Rules
- `getDashboardData` must respect the same scope rules (`IScopeAccessService`) as every other
endpoint — a dashboard is not an exception to region/route/store scoping.
---
## Open Questions
- `getPredictiveAlert` implies a forecasting/ML component that doesn't exist anywhere in this
codebase today — is this in scope at all for the near term, or should it be dropped from this
step until a concrete predictive use case and data volume exist to justify it?
- Which specific KPIs matter first (fill rate, OOS%, credit risk are mentioned in the reference —
confirm the actual priority list with the user before building `generateKpiReport`)?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,67 @@
# 21 — Notifications & Alerts
**Status**: Not Started
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
---
## Goal
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
produce.
---
## Background / Context
No notification channel exists in this codebase today. This step is purely the delivery mechanism
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in
[12_store_management.md](./12_store_management.md), route deviation logic in
[09_fraud_controls.md](./09_fraud_controls.md)).
---
## Data Model Changes
### Notification
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
`ReadAt` (nullable).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `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 |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
---
## Business Rules
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing
endpoints are inbox read/list.
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
transaction that triggered them — fire-and-forget, logged separately.
---
## Open Questions
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
significantly changes the infrastructure needed (third-party provider integration, see
[22_integration_api.md](./22_integration_api.md)).
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+64
View File
@@ -0,0 +1,64 @@
# 22 — Integration & API Layer
**Status**: Not Started
**Depends On**: 19 — Finance & Accounting, 13 — Fleet Management
---
## Goal
Covers [mainPlan.md §16](./mainPlan.md#16-integration--api-layer) in full
— outbound/inbound integration with third-party systems.
---
## Background / Context
No external system integration exists today. Each function here depends on a specific named
third-party provider (accounting platform, telematics provider, B2B portal) that hasn't been
chosen yet — this step cannot be scoped precisely until that choice is made per Open Questions.
---
## Data Model Changes
### Integration Sync Log
`TargetSystem`, `EntityType`, `EntityId`, `Status` (`Success`, `Failed`, `Retrying`), `AttemptedAt`,
`ErrorDetail` (nullable).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `syncToAccountingSystem(transactionId, targetSystem)` | Internal/background job — triggered on GL posting ([19_finance_accounting.md](./19_finance_accounting.md)) |
| `syncTelematicsData(vehicleId, provider)` | Internal/background job — feeds `recordVehicleTelemetry` ([13_fleet_management.md](./13_fleet_management.md)) |
| `pushOrderToDistributorPortal(orderId)` | Internal/background job — triggered on order status change ([05_delivery_ride.md](./05_delivery_ride.md)) |
| — | `GET /api/integrations/sync-log?targetSystem=...` — for support/debugging |
---
## Business Rules
- Every outbound sync must be retryable and idempotent — a failed sync logs to `Integration Sync
Log` and retries rather than silently dropping.
- No integration should be able to block the originating business transaction (order creation, GL
posting) — sync happens after commit, asynchronously.
---
## Open Questions
- Which accounting platform (QuickBooks/SAP/Xero — reference doc names all three as examples), and
which telematics provider — is either actually contracted yet, or is this step purely
speculative until a vendor is chosen?
- Does a "distributor-facing B2B portal" already exist to push orders to, or is this describing a
future/external system not yet built?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+66
View File
@@ -0,0 +1,66 @@
# 23 — Mobile & Offline Capability
**Status**: Not Started
**Depends On**: 06 — Sale Ride Execution, 05 — Delivery Ride Execution
---
## Goal
Covers [mainPlan.md §17](./mainPlan.md#17-mobile--offline-capability) in
full — offline-first support for field agents (drivers/sales reps) whose connectivity can't be
assumed during a ride.
---
## Background / Context
There is no mobile client in this repository — this step is the server-side contract a future
mobile client would sync against (queueing, conflict resolution, barcode resolution), not a client
implementation itself.
---
## Data Model Changes
### Offline Action Queue Entry
`DeviceId`, `UserId`, `ActionType` (e.g. `CreateFieldInvoice`, `RecordDelivery`), `Payload` (JSON),
`ClientCreatedAt`, `SyncedAt` (nullable), `ConflictStatus` (`None`, `Resolved`, `Unresolved`).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `queueOfflineOrder(orderData)` | Client-side only — server sees it as a normal write once synced |
| `syncOfflineQueue(deviceId)` | `POST /api/sync?deviceId=...` — batch endpoint accepting queued actions, applying them in order, returning per-action success/conflict |
| `scanBarcode(barcodeValue)` | `GET /api/items/by-barcode/{barcodeValue}` — resolves to an `Item` (needs a `Barcode` field added to `Item`, see [01_item_catalog.md](./01_item_catalog.md)) |
---
## Business Rules
- Every action type accepted by `syncOfflineQueue` must be safely replayable (idempotent by a
client-generated id) — a duplicate sync of the same queued action must not double-apply (e.g.
double-decrement stock).
- A sync conflict (e.g. stock sold twice from two devices while offline) must not silently pick a
winner — flagged `Unresolved` and surfaced to a supervisor, consistent with the reconciliation
approach in [07_ride_reconciliation.md](./07_ride_reconciliation.md).
---
## Open Questions
- Is a mobile client actually planned/being built by this team, or is offline support scoped for a
hypothetical future client? This changes whether the sync contract needs to be finalized now or
can wait.
- Does `Item` need a `Barcode` field added now (extends [01_item_catalog.md](./01_item_catalog.md)),
or is barcode scanning deferred with the rest of this step?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+361
View File
@@ -0,0 +1,361 @@
# ERP Distribution — Master Plan
## What Already Exists
| Area | Status | Notes |
|---|---|---|
| JWT Authentication | Done | Token generation, claims, 120-min expiry |
| Permission-based Authorization | Done | Dynamic policy provider, role → permission mapping |
| User Management | Done | CRUD, role assignment, scope assignment (region/route/store) |
| Role Management | Done (read-only) | 6 roles seeded, no create/update via API |
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
| Unit of Work + Repositories | Done | Transaction support throughout |
| Soft Deletes | Done | IsActive pattern on all entities |
## Known Gaps in Existing System (Pre-Work)
These are not new features — they are fixable holes in what's already built.
They must be resolved before new feature steps begin because later steps depend on correct foundations.
| Gap | Impact |
|---|---|
| Route DTOs missing RegionId | Can't link routes to regions via API |
| Store DTOs missing RouteId | Can't link stores to routes via API |
| Role endpoints have no [Authorize] | Anyone can read roles without authentication |
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment |
| No input validation on any DTO | Bad data can enter the system silently |
| Item model is empty (no properties) | OrderItems reference items that have no data |
---
## Feature Steps
| # | Feature | Status | Depends On | Detail File |
|---|---|---|---|---|
| 00 | Existing Gap Fixes | Not Started | — | [00_existing_gaps.md](./00_existing_gaps.md) |
| 01 | Item Catalog | Not Started | 00 | [01_item_catalog.md](./01_item_catalog.md) |
| 02 | Stock Ledger | Not Started | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
| 03 | Ride Lifecycle | Not Started | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
| 04 | Loading Event | Not Started | 03 | [04_loading_event.md](./04_loading_event.md) |
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) |
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
| 12 | Store Management (Beyond CRUD) | Not Started | 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 | Not Started | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) |
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
| 17 | Promotions & Trade Scheme Management | Not Started | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
| 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
| 22 | Integration & API Layer | Not Started | 19, 13 | [22_integration_api.md](./22_integration_api.md) |
| 23 | Mobile & Offline Capability | Not Started | 05, 06 | [23_mobile_offline.md](./23_mobile_offline.md) |
Steps 00–09 are the original ride/stock-ledger roadmap and keep their strict dependency order.
Steps 10–23 fill in every remaining area from the [Function Reference by Feature](#function-reference-by-feature)
section above that wasn't already covered by 00–09; they depend only on the specific earlier steps
noted above, not on the full 00–09 chain, and can be resequenced relative to each other as
priorities become clearer.
---
## Function Reference by Feature
Reference only, not a build plan — background material to consult while planning/implementing the
steps above and below. Service-layer function names and what each should do when implemented.
Names are illustrative (camelCase) — adapt to your language/framework conventions. None of the
functions below are implemented yet unless noted. User Management/RBAC functions aren't repeated
here — see `SRS_User_Management_Module.pdf`.
### 1. Order Management
| Function | What it does | Status |
|---|---|---|
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented — `OrderService.CreateAsync` exists but has no credit/stock validation or state machine yet. |
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented — no credit limit field on `Store` yet. |
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Not implemented — depends on §7 Master Data and §11 Promotions below. |
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. |
| `createBackorder(orderId, unfulfilledItems[])` | Splits unfulfillable quantity into a linked backorder. | Not implemented. |
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Not implemented — `OrderController` supports soft delete only, no reason code or stock release. |
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. |
| `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
### 2. Region Management
| Function | What it does | Status |
|---|---|---|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
### 3. Route Management
| 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). |
| `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. |
| `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
| Function | What it does | Status |
|---|---|---|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented — `StoreController` create exists, no KYC document handling. |
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Not implemented. |
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Not implemented — no credit limit field on `Store`. |
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Not implemented — depends on §13 Finance & Accounting. |
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Not implemented — no segment field on `Store`. |
| `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. | Not implemented — depends on route check-in/check-out (§3). |
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Not implemented — depends on §11 Promotions. |
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Not implemented. |
### 5. Vehicle / Fleet Management
| Function | What it does | Status |
|---|---|---|
| `registerVehicle(vehicleData)` | Creates the vehicle master record (registration, capacity, ownership). | Partially implemented — `VehicleController` create exists with `RegNumber`/`NumberPlate` only, no capacity/ownership fields. |
| `assignVehicleToRoute(vehicleId, routeId)` | Assigns a vehicle to a delivery/sales route. | Not implemented — `Vehicle` has no route link; only `DeliveryRun.FK_RouteId`/`FK_VehicleId` model this, and `DeliveryRun` is unwired (see [03_ride_lifecycle.md](./03_ride_lifecycle.md)). |
| `assignDriverToVehicle(vehicleId, driverId)` | Links a driver to a vehicle, checking license validity. | Not implemented — no `Driver` entity exists yet (`DeliveryRun.FK_DriverId` is commented out in the model). |
| `scheduleMaintenance(vehicleId, maintenanceType, dueAt)` | Sets up a preventive maintenance task (mileage/calendar-based). | Not implemented. |
| `logFuelExpense(vehicleId, amount, liters, date)` | Records a fuel transaction against the vehicle. | Not implemented. |
| `recordVehicleTelemetry(vehicleId, gpsCoords, speed, timestamp)` | Ingests telematics data (location, driver behavior). | Not implemented — see §16 Integration. |
| `checkDriverCompliance(driverId)` | Validates license/permit status before dispatch. | Not implemented — depends on a `Driver` entity. |
| `calculateLoadPlan(vehicleId, orderIds[])` | Matches order volume/weight to available vehicle capacity. | Not implemented — depends on [04_loading_event.md](./04_loading_event.md). |
| `getVehicleUtilization(vehicleId, period)` | Utilization and cost-per-delivery metrics. | Not implemented. |
| `retireVehicle(vehicleId, reason)` | Removes a vehicle from active fleet, retaining history. | Partially implemented — `DELETE /api/vehicles/{id}` soft-deletes via `IsActive`, no reason code captured. |
### 6. Warehouse Management
| Function | What it does | Status |
|---|---|---|
| `createGoodsReceipt(poId, receivedItems[])` | Records inbound stock against a purchase order (GRN). | Not implemented — depends on §9 Procurement. |
| `inspectGoodsReceipt(grnId, inspectionResult)` | Logs quality-check outcome before putaway. | Not implemented. |
| `putawayStock(grnId, locationAssignments[])` | Assigns received stock to warehouse locations (zone/ABC-based). | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
| `getStockByLocation(warehouseId, sku)` | Returns real-time quantity and location for a SKU. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
| `generatePickList(orderIds[])` | Creates a pick list/task for one or more orders. | Not implemented — overlaps with [04_loading_event.md](./04_loading_event.md). |
| `confirmPicking(pickListId, pickedItems[])` | Marks items as picked, decrementing available stock. | Not implemented. |
| `packOrder(orderId, packageDetails)` | Records packing completion and package details. | Not implemented. |
| `createCrossDockTransfer(inboundId, outboundOrderId)` | Routes inbound stock directly to an outbound order, skipping storage. | Not implemented. |
| `runCycleCount(warehouseId, zoneId)` | Initiates a scheduled partial stock count. | Not implemented — overlaps with [07_ride_reconciliation.md](./07_ride_reconciliation.md) physical count concept. |
| `reconcileStock(warehouseId, countResults)` | Adjusts system stock to match physical count results. | Not implemented — see [07_ride_reconciliation.md](./07_ride_reconciliation.md). |
| `buildDispatchLoad(vehicleId, orderIds[])` | Groups orders into a dispatch load for a specific vehicle/route. | Not implemented — see [04_loading_event.md](./04_loading_event.md). |
| `receiveReturn(returnId, items[], condition)` | Processes returned goods back into the warehouse. | Not implemented — see §12 Returns & Claims. |
| `getWarehouseKpis(warehouseId, period)` | Dock-to-stock time, pick accuracy, turnover rate. | Not implemented. |
### 7. Master Data Management
| Function | What it does | Status |
|---|---|---|
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Not implemented — no price list entity exists. |
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Not implemented. |
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Not implemented — no `Supplier` entity exists. |
### 8. Enterprise-wide Inventory & Stock
| Function | What it does | Status |
|---|---|---|
| `getMultiLocationStock(sku)` | Returns stock levels for a SKU across all warehouses/agencies. | Not implemented — see [02_stock_ledger.md](./02_stock_ledger.md). |
| `setReorderPoint(sku, warehouseId, threshold)` | Configures the auto-replenishment trigger level. | Not implemented. |
| `createStockTransfer(fromWarehouseId, toWarehouseId, items[])` | Initiates an inter-warehouse stock movement. | Not implemented. |
| `trackBatchExpiry(sku, batchNo, expiryDate)` | Registers batch/expiry data for FEFO enforcement. | Not implemented. |
| `calculateStockValuation(warehouseId, method)` | Computes stock value using FIFO/weighted-average. | Not implemented. |
### 9. Procurement / Purchasing
| Function | What it does | Status |
|---|---|---|
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Not implemented. |
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Not implemented. |
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Not implemented. |
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Not implemented. |
### 10. Sales Force Automation (SFA) / CRM
| Function | What it does | Status |
|---|---|---|
| `logCustomerInteraction(storeId, notes)` | Records a visit note or customer interaction. | Not implemented. |
| `trackTargetVsAchievement(userId, period)` | Compares a user's sales target to actuals. | Not implemented. |
| `calculateIncentive(userId, period)` | Computes commission/incentive payout based on performance. | Not implemented. |
| `createLead(prospectData)` | Captures a prospective new outlet. | Not implemented — no `Lead` entity exists. |
| `convertLeadToStore(leadId)` | Converts a qualified lead into an active store record. | Not implemented. |
### 11. Promotions & Trade Scheme Management
| Function | What it does | Status |
|---|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Not implemented — no scheme entity exists. |
| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Not implemented. |
| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Not implemented. |
| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Not implemented. |
| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Not implemented. |
### 12. Returns & Claims Management
| Function | What it does | Status |
|---|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | Initiates a return against a delivered order. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) for the fraud-control angle on returns. |
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. |
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. |
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. |
### 13. Finance & Accounting
| Function | What it does | Status |
|---|---|---|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. |
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
### 14. Reporting, Analytics & BI
| Function | What it does | Status |
|---|---|---|
| `getDashboardData(role, scope)` | Returns the role-appropriate dashboard payload. | Not implemented. |
| `generateKpiReport(kpiType, filters)` | Produces a specific KPI report (fill rate, OOS%, credit risk, etc.). | Not implemented. |
| `getPredictiveAlert(type, scope)` | Returns AI-flagged issues, e.g. a region's declining fill rate. | Not implemented. |
### 15. Notifications & Alerts
| Function | What it does | Status |
|---|---|---|
| `sendLowStockAlert(warehouseId, sku)` | Notifies relevant roles when stock drops below the reorder point. | Not implemented. |
| `sendCreditLimitBreachAlert(storeId)` | Notifies approvers when a store exceeds its credit limit. | Not implemented. |
| `sendApprovalPendingNotification(approverId, requestId)` | Notifies an approver of a pending action. | Not implemented. |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Flags when an SR deviates materially from the planned route. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) geofencing check. |
### 16. Integration & API Layer
| Function | What it does | Status |
|---|---|---|
| `syncToAccountingSystem(transactionId, targetSystem)` | Pushes a transaction to an external accounting platform (QuickBooks/SAP/Xero). | Not implemented. |
| `syncTelematicsData(vehicleId, provider)` | Pulls GPS/telematics data from a third-party provider. | Not implemented. |
| `pushOrderToDistributorPortal(orderId)` | Syncs order status to a distributor-facing B2B portal. | Not implemented. |
### 17. Mobile & Offline Capability
| Function | What it does | Status |
|---|---|---|
| `queueOfflineOrder(orderData)` | Stores an order locally on the device when offline. | Not implemented — no mobile client in this repository. |
| `syncOfflineQueue(deviceId)` | Pushes queued offline actions to the server once connectivity returns. | Not implemented. |
| `scanBarcode(barcodeValue)` | Resolves a scanned barcode to a SKU or warehouse location. | Not implemented. |
### 18. Compliance & Audit
| Function | What it does | Status |
|---|---|---|
| `logAuditEvent(userId, action, entityType, entityId)` | Writes an immutable audit trail entry for a state-changing action. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md), which scopes an audit log as part of Fraud Controls. |
| `checkLicenseCompliance(entityId, licenseType)` | Validates a required license/permit is current (e.g., regulated goods). | Not implemented. |
| `generateComplianceReport(period)` | Produces a report of compliance status across the org for a period. | Not implemented. |
---
## Feature Summaries
### 00 — Existing Gap Fixes
Fix all known holes in the current codebase before new features are layered on top.
Includes DTO field gaps, missing authorization on role endpoints, secrets management,
and adding input validation across all existing endpoints.
**Gate**: All existing API endpoints behave correctly and securely before step 01 begins.
### 01 — Item Catalog
Define the Item entity with real properties (name, SKU, unit of measure, category, price tiers).
Items are the unit of stock — nothing in the stock ledger makes sense without them.
This step also covers the Item CRUD API and how items relate to orders.
**Gate**: An item can be created, updated, and referenced in an order with quantity and price.
### 02 — Stock Ledger
Introduce the concept of stock per location. Every warehouse (fixed or vehicle) holds a quantity
of each item. Stock is never edited directly — all changes happen through movement transactions
(load, sale, return, damage, adjustment). The current balance is always the running sum.
This is the single most important architectural decision for the whole system.
**Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction.
### 03 — Ride Lifecycle
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine:
Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time.
Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving,
which route, which vehicle, and when it started and ended.
**Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens.
### 04 — Loading Event
When a ride opens, stock must be physically loaded from a source warehouse onto the vehicle.
This is a two-party event: warehouse manager confirms what was dispatched, driver confirms what was received.
Discrepancies between the two are flagged immediately and require resolution before the ride goes active.
The loading event creates stock movement transactions that debit the warehouse and credit the vehicle.
**Gate**: Stock moves from warehouse to vehicle via a confirmed loading event. Any discrepancy is logged.
### 05 — Delivery Ride Execution
On a delivery ride, the driver fulfills pre-confirmed orders. Each order becomes a delivery task.
The driver marks each as delivered, partially delivered, or failed. Partially delivered orders
generate a credit note for the undelivered quantity. Customer confirmation (signature or OTP)
is required to mark a delivery complete. Stock movements are created per delivery event.
**Gate**: A delivery ride can execute all orders on its route and produce a delivery report.
### 06 — Sale Ride Execution
On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist.
The driver selects items, quantities, and prices (within approved discount limits), and the invoice
is generated immediately. Payment is collected (cash or credit) at the point of sale.
The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded.
**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale.
### 07 — Ride Close & Reconciliation
When a ride ends, the system performs a full reconciliation. The equation must balance:
Opening Stock − Sales − Damages + Returns = Closing Stock (physical count)
Cash Collected + Credit Extended = Total Sales − Returns
Any variance must be approved by a supervisor with a reason code before the ride can close.
Unresolved variances are logged as discrepancies against the agent's record.
**Gate**: A ride cannot close with an unexplained variance. All discrepancies are permanently recorded.
### 08 — Sales Agent Controls
Agents operate within boundaries set by their role level. This step wires role levels to business rules:
discount limits per agent level, credit limits per customer, maximum single-transaction value,
and approval chains for exceptions. Supervisors can grant one-time overrides logged against their account.
**Gate**: An agent cannot exceed their limits without a logged, authorized override.
### 09 — Fraud Controls & Audit Trail
A dedicated audit log records every sensitive event (who, what, when, from where).
Automated checks flag: returns without a matching prior sale, sales outside the geofenced route,
agents with consistently high variance rates, and cash collected below invoiced amounts.
Return approval requires supervisor sign-off before stock is credited. Anomaly reports are
surfaced to area managers on a scheduled basis.
**Gate**: Every sensitive event is logged immutably. Automated flags surface to the right level of management.
---
## Rules
1. A feature file must be filled in and reviewed before implementation begins on that step.
2. Status must be updated in this file whenever a step transitions.
3. If implementation reveals a design change, the feature file is updated and this plan is revised.
4. Steps must be done in dependency order — no skipping.