Compare commits

...

17 Commits

Author SHA1 Message Date
DeepnaPooja 08ad17f15b Document auth flow 2026-08-25 16:46:12 +05:30
DeepnaPooja 97a1db1343 Refactor auth to AuthHex 2026-08-25 16:46:05 +05:30
DeepnaPooja 916a2b8f0a Merge pull request 'Feat/returns & claims' (#11) from feat/returns-&-claims into dev
Reviewed-on: #11
2026-08-25 10:14:38 +00:00
DeepnaPooja 3caacf44d0 update md files 2026-08-25 15:38:22 +05:30
DeepnaPooja fe9ccb4de7 Update procurement docs 2026-08-25 14:48:02 +05:30
DeepnaPooja f244a04dc0 Add returns and claims 2026-08-25 14:47:51 +05:30
DeepnaPooja aa543d0d0d Merge pull request 'Production' (#10) from production into dev
Reviewed-on: #10
2026-08-25 05:42:16 +00:00
Kalana B. Thilakarathna 87146e4662 Merge branch 'dev' into production 2026-08-24 21:42:41 +05:30
Kalana B. Thilakarathna cefefcbc49 md update 2026-08-24 21:42:13 +05:30
Kalana B. Thilakarathna fcca03d0b2 Merge branch 'dev' into production 2026-08-24 21:38:13 +05:30
Kalana B. Thilakarathna 6d8b6b3e59 Add DeliveryTask, Payment, and Sale features
Implemented DeliveryTask, Payment, and Sale entities with full CRUD, business logic, and API endpoints. Updated Order/OrderItem for sale linkage and fulfillment tracking. Registered new DbSets and DI services. Revised documentation for delivery, sale, and payment workflows.
2026-08-24 21:34:57 +05:30
DeepnaPooja 3837facb02 Merge pull request 'Dev' (#9) from dev into production
Reviewed-on: #9
2026-08-24 11:29:34 +00:00
DeepnaPooja a833586e83 Merge pull request 'Feat/procurement' (#8) from feat/procurement into dev
Reviewed-on: #8
2026-08-24 11:28:48 +00:00
Kalana B. Thilakarathna ade41b4a60 Merge branch 'dev' into production 2026-08-24 11:24:06 +05:30
DeepnaPooja 3680fc9035 Merge pull request 'Dev' (#6) from dev into production
Reviewed-on: #6
2026-08-21 08:52:16 +00:00
Kalana B. Thilakarathna f6211a8edd Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-08-21 02:08:50 +05:30
DeepnaPooja 83bf715716 Merge pull request 'Dev' (#4) from dev into production
Reviewed-on: #4
2026-08-20 08:14:20 +00:00
83 changed files with 11877 additions and 733 deletions
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
+135
View File
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; } public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; } public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; } public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; } public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; } public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; } public DbSet<DeliveryRun> DeliveryRuns { get; set; }
@@ -41,12 +42,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; } public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
public DbSet<LoadingSheet> LoadingSheets { get; set; } public DbSet<LoadingSheet> LoadingSheets { get; set; }
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; } public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
public DbSet<Payment> Payments { get; set; }
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
public DbSet<PurchaseOrder> PurchaseOrders { get; set; } public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; } public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; } public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; } public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; } public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; } public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
public DbSet<SaleReturn> SaleReturns { get; set; }
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
public DbSet<CreditNote> CreditNotes { get; set; }
public DbSet<Claim> Claims { get; set; }
public DbSet<SupplierClaim> SupplierClaims { get; set; }
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -79,6 +88,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId }) .HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique(); .IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>() modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId) .HasIndex(dp => dp.FK_UserId)
.IsUnique(); .IsUnique();
@@ -102,6 +115,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId) .HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>() modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme) .HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions) .WithMany(s => s.Redemptions)
@@ -238,6 +276,103 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<PurchaseReturnItem>() modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId); .HasIndex(i => i.FK_PurchaseReturnId);
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.Order)
.WithMany()
.HasForeignKey(r => r.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturn>()
.HasOne(r => r.CreatedByUser)
.WithMany()
.HasForeignKey(r => r.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasOne(i => i.SaleReturn)
.WithMany(r => r.Items)
.HasForeignKey(i => i.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SaleReturnItem>()
.HasIndex(i => i.FK_SaleReturnId);
modelBuilder.Entity<CreditNote>()
.HasOne(n => n.SaleReturn)
.WithMany()
.HasForeignKey(n => n.FK_SaleReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Claim>()
.HasOne(c => c.AgencyUser)
.WithMany()
.HasForeignKey(c => c.FK_AgencyUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Claim>()
.HasOne(c => c.SettledByUser)
.WithMany()
.HasForeignKey(c => c.SettledByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.PurchaseOrder)
.WithMany()
.HasForeignKey(c => c.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.GoodsReceipt)
.WithMany()
.HasForeignKey(c => c.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.RaisedByUser)
.WithMany()
.HasForeignKey(c => c.RaisedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaim>()
.HasOne(c => c.ResolvedByUser)
.WithMany()
.HasForeignKey(c => c.ResolvedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasOne(i => i.SupplierClaim)
.WithMany(c => c.Items)
.HasForeignKey(i => i.FK_SupplierClaimId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierClaimItem>()
.HasIndex(i => i.FK_SupplierClaimId);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
.HasForeignKey(t => t.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
Seed.RoleAndPermissionSeed.Apply(modelBuilder); Seed.RoleAndPermissionSeed.Apply(modelBuilder);
} }
} }
+26 -4
View File
@@ -1,15 +1,37 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Auth; namespace Distribution.Features.Auth;
[ApiController] [ApiController]
[Route("api/auth")] [Route("api/auth")]
public class AuthController(AuthService service) : ControllerBase public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
{ {
private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request) public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken ct)
{ {
var result = await service.LoginAsync(request); var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return result is null ? Unauthorized() : Ok(result); return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
{
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
} }
} }
+1 -1
View File
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
public record LoginRequest(string Username, string Password); public record LoginRequest(string Username, string Password);
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName); public record RegisterRequest(string Username, string Password, string? FullName);
+15 -15
View File
@@ -1,24 +1,24 @@
using Distribution.Features.User;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Identity;
namespace Distribution.Features.Auth; namespace Distribution.Features.Auth;
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService) /// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// </summary>
public class AuthService(IAuthHexClient authHex)
{ {
private readonly PasswordHasher<Models.User> _hasher = new(); public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
public async Task<LoginResponse?> LoginAsync(LoginRequest request) public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
{ authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
var user = await userRepo.GetByUsernameAsync(request.Username);
if (user is null || !user.IsActive)
return null;
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
if (result == PasswordVerificationResult.Failed) authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
return null;
var token = tokenService.GenerateToken(user); public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty); authHex.CallAsync("LogoutUser", new { token }, ct: ct);
}
} }
@@ -0,0 +1,42 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
[ApiController]
[Authorize]
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("api/claims")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize(Policy = "claims.settle")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.SettleAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+17
View File
@@ -0,0 +1,17 @@
using Distribution.Models;
namespace Distribution.Features.Claims;
public record CreateClaimRequest(Guid AgencyUserId, string Details);
public record SettleClaimRequest(decimal SettlementAmount);
public record ClaimResponse(
Guid Id,
Guid AgencyUserId,
string Details,
ClaimStatus Status,
decimal? SettlementAmount,
DateTime? SettledAt,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,22 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Claims;
public class ClaimRepository(AppDBContext context) : IClaimRepository
{
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
await context.Claims
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.Claim entity) =>
await context.Claims.AddAsync(entity);
public void Update(Models.Claim entity) =>
context.Claims.Update(entity);
}
@@ -0,0 +1,68 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Claims;
public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
{
private static ClaimResponse Map(Claim c) =>
new(c.Id, c.FK_AgencyUserId, c.Details, c.Status, c.SettlementAmount, c.SettledAt, c.IsActive, c.CreatedAt);
public async Task<ClaimResponse?> GetByIdAsync(Guid id)
{
var c = await repo.GetByIdAsync(id);
if (c is null || !c.IsActive) return null;
return Map(c);
}
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
public async Task<ClaimResponse> CreateAsync(CreateClaimRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.Details))
throw new InvalidOperationException("Claim details are required.");
var claim = new Claim
{
FK_AgencyUserId = request.AgencyUserId,
Details = request.Details
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id);
if (claim is null || !claim.IsActive)
throw new KeyNotFoundException("Claim not found.");
if (claim.Status == ClaimStatus.Settled)
throw new InvalidOperationException("Claim is already settled.");
if (request.SettlementAmount < 0)
throw new InvalidOperationException("Settlement amount cannot be negative.");
claim.Status = ClaimStatus.Settled;
claim.SettlementAmount = request.SettlementAmount;
claim.SettledByUserId = approverId;
claim.SettledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
}
@@ -0,0 +1,9 @@
namespace Distribution.Features.Claims;
public interface IClaimRepository
{
Task<Models.Claim?> GetByIdAsync(Guid id);
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
Task AddAsync(Models.Claim entity);
void Update(Models.Claim entity);
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Authorize]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
{
var result = await service.GenerateAsync(rideId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("rides/{rideId:guid}/tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
{
var result = await service.SetQuantitiesAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
{
var result = await service.FailAsync(id, request.Note, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
DeliveryTaskStatus Status,
bool CustomerConfirmed,
DateTime? ConfirmedAt,
string? Note,
List<DeliveryTaskItemResponse> Items,
DateTime CreatedAt);
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
public record FailTaskRequest(string Note);
@@ -0,0 +1,38 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
public void Update(DeliveryTask task) =>
context.DeliveryTasks.Update(task);
}
@@ -0,0 +1,224 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
i.QuantityFulfilled,
i.UnitPrice)).ToList(),
t.CreatedAt);
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
var tasks = await repo.GetByRideIdAsync(rideId);
return tasks.Select(ToResponse).ToList();
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
return task is null ? null : ToResponse(task);
}
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Quantities can only be set for a Pending task");
foreach (var input in request.Items)
{
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
vehicleBalance.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be failed");
task.Status = DeliveryTaskStatus.Failed;
task.Note = note;
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(task);
}
}
@@ -0,0 +1,12 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
}
@@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId) public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{ {
var groups = await context.OrderItems var groups = await context.OrderItems
.Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId) .Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId) .GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) }) .Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync(); .ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList(); return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
} }
@@ -4,10 +4,12 @@ public interface IOrderRepository
{ {
Task<Models.Order?> GetByIdAsync(Guid id); Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync(); Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity); Task AddAsync(Models.Order entity);
void Update(Models.Order entity); void Update(Models.Order entity);
void Remove(Models.Order entity); void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId); Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId); Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items); Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
} }
@@ -32,18 +32,26 @@ public class OrderController(OrderService service) : ControllerBase
[HttpPost] [HttpPost]
[Authorize(Policy = "orders.create")] [Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
try
{ {
var result = await service.CreateAsync(request, ct); var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result); return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
} }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")] [Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct) public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
try
{ {
var result = await service.UpdateAsync(id, request, ct); var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Policy = "orders.delete")] [Authorize(Policy = "orders.delete")]
+8 -5
View File
@@ -1,18 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Order; namespace Distribution.Features.Order;
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null); public record CreateOrderRequest(Guid SaleId);
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items); public record UpdateOrderRequest(OrderStatus Status);
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount); public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
public record OrderResponse( public record OrderResponse(
Guid Id, Guid Id,
Guid SaleId,
DateTime OrderDate, DateTime OrderDate,
Guid StoreId, Guid StoreId,
decimal TotalAmount, decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items, List<OrderItemResponse> Items,
bool IsActive, bool IsActive,
DateTime CreatedAt); DateTime CreatedAt);
@@ -1,4 +1,5 @@
using Distribution.Data; using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order; namespace Distribution.Features.Order;
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetAllAsync() => public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync(); await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) => public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity); await context.Orders.AddAsync(entity);
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public void Remove(Models.Order entity) public void Remove(Models.Order entity)
{ {
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false; entity.IsActive = false;
context.Orders.Update(entity); context.Orders.Update(entity);
} }
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) => public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items); await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
} }
+38 -87
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Sale;
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Store; using Distribution.Features.Store;
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
using Distribution.Infra.UoW; using Distribution.Infra.UoW;
using Distribution.Models; using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order; namespace Distribution.Features.Order;
@@ -15,16 +15,16 @@ public class OrderService(
IItemRepository itemRepo, IItemRepository itemRepo,
IStockRepository stockRepo, IStockRepository stockRepo,
PriceListService priceListService, PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow, IUnitOfWork uow,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IScopeAccessService scopeAccess, IScopeAccessService scopeAccess)
IConfiguration configuration)
{ {
private const int GlobalScopeRoleLevel = 50; private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) => private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount, new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(), items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt); o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -35,42 +35,6 @@ public class OrderService(
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId); return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
} }
/// <summary>
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
/// can be resolved, or if the requested price is outside the allowed band.
/// </summary>
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
/// <summary>
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
/// </summary>
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
{
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
var totalAvailable = balances.Sum(b => b.Quantity);
return quantity <= totalAvailable;
}
public async Task<List<OrderResponse>> GetAllAsync() public async Task<List<OrderResponse>> GetAllAsync()
{ {
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList(); var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -112,52 +76,15 @@ public class OrderService(
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default) public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{ {
if (!await HasStoreScopeAsync(request.StoreId)) return null; var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId); var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
if (store is null || !store.IsActive) return null; ?? throw new InvalidOperationException("Orders are created automatically from sales");
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var order = new Models.Order var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
{ return ToResponse(existingOrder, items);
FK_StoreId = request.StoreId,
OrderDate = request.OrderDate,
TotalAmount = request.TotalAmount
};
var orderItems = new List<Models.OrderItem>();
foreach (var i in request.Items)
{
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity,
UnitPrice = unitPrice.Value
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(order);
await repo.AddItemsAsync(orderItems);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, orderItems);
} }
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default) public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
@@ -165,12 +92,26 @@ public class OrderService(
var order = await repo.GetByIdAsync(id); var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null; if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null; if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
order.OrderDate = request.OrderDate;
order.TotalAmount = request.TotalAmount; var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
repo.Update(order); repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch catch
@@ -178,6 +119,7 @@ public class OrderService(
await uow.RollbackAsync(ct); await uow.RollbackAsync(ct);
throw; throw;
} }
var items = await repo.GetItemsByOrderIdAsync(id); var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items); return ToResponse(order, items);
} }
@@ -187,10 +129,19 @@ public class OrderService(
var order = await repo.GetByIdAsync(id); var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false; if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false; if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
repo.Remove(order); repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch catch
@@ -0,0 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Authorize]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
Guid CollectedBy,
DateTime CreatedAt);
@@ -0,0 +1,17 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Payment;
public class PaymentRepository(AppDBContext context) : IPaymentRepository
{
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
}
@@ -0,0 +1,67 @@
using Distribution.Features.Sale;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var payment = new Models.Payment
{
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
CollectedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(payment);
}
}
@@ -0,0 +1,12 @@
namespace Distribution.Features.Returns;
public interface IReturnRepository
{
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
Task AddAsync(Models.SaleReturn entity);
Task AddCreditNoteAsync(Models.CreditNote entity);
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
}
@@ -0,0 +1,33 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
[ApiController]
[Authorize]
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/orders/{id:guid}/returns")]
public async Task<IActionResult> GetByOrder(Guid id) =>
Ok(await service.GetByOrderIdAsync(id));
[HttpPost("api/orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.Returns;
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
public record CreateSaleReturnRequest(ReturnMode Mode, List<ReturnItemRequest> Items);
public record SaleReturnItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
decimal Quantity,
ReturnReasonCode ReasonCode,
decimal UnitPrice);
public record SaleReturnResponse(
Guid Id,
Guid OrderId,
ReturnMode Mode,
decimal TotalCreditAmount,
decimal OrderTotalAmountAfter,
bool IsActive,
DateTime CreatedAt,
List<SaleReturnItemResponse> Items);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Returns;
public class ReturnRepository(AppDBContext context) : IReturnRepository
{
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
await context.SaleReturns
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
await context.SaleReturns
.Include(r => r.Order)
.Include(r => r.Items).ThenInclude(i => i.Item)
.Where(r => r.FK_OrderId == orderId && r.IsActive)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.SaleReturn entity) =>
await context.SaleReturns.AddAsync(entity);
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
await context.CreditNotes.AddAsync(entity);
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
await context.OrderItems
.Include(oi => oi.Order)
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
}
@@ -0,0 +1,169 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Returns;
public class ReturnService(
IReturnRepository repo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IRideRepository rideRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
private static SaleReturnResponse Map(SaleReturn r) =>
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
{
var r = await repo.GetByIdAsync(id);
if (r is null || !r.IsActive) return null;
return Map(r);
}
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
/// <summary>
/// Records a return recognized during the same store visit as an open order and settles it
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
/// A return is only allowed for an item the store has previously ordered.
/// </summary>
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var order = await orderRepo.GetByIdAsync(orderId);
if (order is null || !order.IsActive)
throw new InvalidOperationException("Order not found.");
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
throw new InvalidOperationException("Cannot record a return against a closed order.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order.");
if (sale.FK_RideId is null)
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
?? throw new InvalidOperationException("Ride not found.");
var vehicleId = ride.FK_VehicleId;
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
var saleReturn = new SaleReturn
{
FK_OrderId = orderId,
Mode = request.Mode,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
? matchingOrderItem.UnitPrice
: item.SalePrice ?? 0;
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = unitPrice
});
}
await uow.BeginAsync(ct);
try
{
foreach (var line in saleReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = line.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = line.Quantity
});
}
else
{
balance.Quantity += line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = line.Quantity,
Type = MovementType.CustomerReturn,
ReasonCode = line.ReasonCode.ToString(),
ReferenceId = saleReturn.Id,
CreatedBy = createdByUserId
});
}
await repo.AddAsync(saleReturn);
if (request.Mode == ReturnMode.CreditDeduction)
{
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
saleReturn.TotalCreditAmount = total;
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
orderRepo.Update(order);
await repo.AddCreditNoteAsync(new CreditNote
{
FK_SaleReturnId = saleReturn.Id,
Amount = total
});
}
else // ItemExchange
{
foreach (var line in saleReturn.Items)
{
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
continue; // no matching line on this order — nothing to offset
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
orderItem.Quantity -= (int)offsetQty;
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
}
orderRepo.Update(order);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
}
@@ -0,0 +1,14 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync();
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
[Authorize]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
}
+33
View File
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync() =>
await context.Sales.ToListAsync();
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+233
View File
@@ -0,0 +1,233 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<List<SaleResponse>> GetAllAsync()
{
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
}
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
TotalAmount = request.TotalAmount,
Status = saleStatus,
FulfillImmediately = request.FulfillImmediately,
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
UnitPrice = unitPrice.Value
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
TotalAmount = request.TotalAmount,
Status = orderStatus,
FulfilledAt = fulfilledAt
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
if (ride is null) return null;
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
if (balance is null || balance.Quantity < item.Quantity)
return null;
}
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
Quantity = item.Quantity,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
@@ -0,0 +1,9 @@
namespace Distribution.Features.SupplierClaims;
public interface ISupplierClaimRepository
{
Task<Models.SupplierClaim?> GetByIdAsync(Guid id);
Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.SupplierClaim entity);
void Update(Models.SupplierClaim entity);
}
@@ -0,0 +1,61 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
[ApiController]
[Authorize]
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/purchase-orders/{id:guid}/claims")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/claims")]
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
[Authorize(Policy = "suppliers.claims.approve")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ApproveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
[Authorize(Policy = "suppliers.claims.approve")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.RejectAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,41 @@
using Distribution.Models;
namespace Distribution.Features.SupplierClaims;
public record SupplierClaimItemRequest(Guid ItemId, decimal Quantity, SupplierClaimIssueType IssueType);
public record CreateSupplierClaimRequest(
Guid? GoodsReceiptId,
string Details,
string? EvidenceUrl,
List<SupplierClaimItemRequest> Items);
public record ApproveSupplierClaimRequest(
SupplierClaimResolutionType ResolutionType,
decimal? ResolutionAmount,
string? ResolutionNotes);
public record RejectSupplierClaimRequest(string? ResolutionNotes);
public record SupplierClaimItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
decimal Quantity,
SupplierClaimIssueType IssueType);
public record SupplierClaimResponse(
Guid Id,
Guid PurchaseOrderId,
Guid? GoodsReceiptId,
string Details,
string? EvidenceUrl,
SupplierClaimStatus Status,
SupplierClaimResolutionType? ResolutionType,
decimal? ResolutionAmount,
string? ResolutionNotes,
DateTime? ResolvedAt,
bool IsActive,
DateTime CreatedAt,
List<SupplierClaimItemResponse> Items);
@@ -0,0 +1,25 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SupplierClaims;
public class SupplierClaimRepository(AppDBContext context) : ISupplierClaimRepository
{
public async Task<Models.SupplierClaim?> GetByIdAsync(Guid id) =>
await context.SupplierClaims
.Include(c => c.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.SupplierClaims
.Include(c => c.Items).ThenInclude(i => i.Item)
.Where(c => c.FK_PurchaseOrderId == purchaseOrderId && c.IsActive)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.SupplierClaim entity) =>
await context.SupplierClaims.AddAsync(entity);
public void Update(Models.SupplierClaim entity) =>
context.SupplierClaims.Update(entity);
}
@@ -0,0 +1,136 @@
using Distribution.Features.Grn;
using Distribution.Features.Po;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.SupplierClaims;
public class SupplierClaimService(
ISupplierClaimRepository repo,
IPoRepository poRepo,
IGrnRepository grnRepo,
IUnitOfWork uow)
{
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.IssueType);
private static SupplierClaimResponse Map(SupplierClaim c) =>
new(c.Id, c.FK_PurchaseOrderId, c.FK_GoodsReceiptId, c.Details, c.EvidenceUrl, c.Status,
c.ResolutionType, c.ResolutionAmount, c.ResolutionNotes, c.ResolvedAt, c.IsActive, c.CreatedAt,
c.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<SupplierClaimResponse?> GetByIdAsync(Guid id)
{
var c = await repo.GetByIdAsync(id);
if (c is null || !c.IsActive) return null;
return Map(c);
}
public async Task<List<SupplierClaimResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
public async Task<SupplierClaimResponse> CreateAsync(Guid purchaseOrderId, CreateSupplierClaimRequest request, Guid raisedByUserId, CancellationToken ct = default)
{
var po = await poRepo.GetByIdAsync(purchaseOrderId);
if (po is null || !po.IsActive)
throw new InvalidOperationException("Purchase order not found.");
if (string.IsNullOrWhiteSpace(request.Details))
throw new InvalidOperationException("Claim details are required.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A supplier claim must have at least one line item.");
if (request.GoodsReceiptId is not null)
{
var grn = await grnRepo.GetByIdAsync(request.GoodsReceiptId.Value)
?? throw new InvalidOperationException("Goods receipt not found.");
if (grn.FK_PurchaseOrderId != purchaseOrderId)
throw new InvalidOperationException("Goods receipt does not belong to this purchase order.");
}
var claim = new SupplierClaim
{
FK_PurchaseOrderId = purchaseOrderId,
FK_GoodsReceiptId = request.GoodsReceiptId,
Details = request.Details,
EvidenceUrl = request.EvidenceUrl,
RaisedByUserId = raisedByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Claim quantity must be greater than zero.");
claim.Items.Add(new SupplierClaimItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
IssueType = line.IssueType
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(claim.Id);
return Map(saved!);
}
public async Task<SupplierClaimResponse> ApproveAsync(Guid id, ApproveSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Supplier claim not found.");
if (claim.Status != SupplierClaimStatus.Open)
throw new InvalidOperationException("Only an open claim can be approved.");
if (request.ResolutionType != SupplierClaimResolutionType.Replacement && request.ResolutionAmount is null)
throw new InvalidOperationException("ResolutionAmount is required for Credit, Refund, and PriceAdjustment.");
claim.Status = SupplierClaimStatus.Approved;
claim.ResolutionType = request.ResolutionType;
claim.ResolutionAmount = request.ResolutionType == SupplierClaimResolutionType.Replacement ? null : request.ResolutionAmount;
claim.ResolutionNotes = request.ResolutionNotes;
claim.ResolvedByUserId = approverId;
claim.ResolvedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
{
var claim = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Supplier claim not found.");
if (claim.Status != SupplierClaimStatus.Open)
throw new InvalidOperationException("Only an open claim can be rejected.");
claim.Status = SupplierClaimStatus.Rejected;
claim.ResolutionNotes = request.ResolutionNotes;
claim.ResolvedByUserId = approverId;
claim.ResolvedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(claim);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return Map(claim);
}
}
@@ -0,0 +1,21 @@
using System.Net.Http.Json;
namespace Distribution.Infra.Authorization;
/// <summary>
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
/// </summary>
public class AuthHexClient(HttpClient http) : IAuthHexClient
{
public async Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default)
{
var body = new { functionName, payload, reference };
var response = await http.PostAsJsonAsync("/api/user", body, ct);
var result = await response.Content.ReadFromJsonAsync<AuthHexResponse>(cancellationToken: ct);
return result ?? new AuthHexResponse((int)response.StatusCode, false, "Empty response from AuthHex.", null);
}
}
@@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Distribution.Infra.Authorization;
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
public record AuthHexResponse(
[property: JsonPropertyName("statusCode")] int StatusCode,
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("data")] JsonElement? Data);
@@ -0,0 +1,6 @@
namespace Distribution.Infra.Authorization;
public interface IAuthHexClient
{
Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default);
}
@@ -1,6 +0,0 @@
namespace Distribution.Infra.Authorization;
public interface IJwtTokenService
{
string GenerateToken(Models.User user);
}
@@ -1,47 +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.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,32 @@
using System.Security.Cryptography;
using System.Xml.Linq;
namespace Distribution.Infra.Authorization;
/// <summary>
/// Parses an XML-format RSA public key (`&lt;RSAKeyValue&gt;...&lt;/RSAKeyValue&gt;`, as issued by
/// AuthHex) into an <see cref="RSA"/> instance. Written by hand instead of relying on
/// RSA.FromXmlString, which is Windows-only in .NET (throws PlatformNotSupportedException on
/// Linux/macOS) — this keeps token validation portable across hosts.
/// </summary>
public static class RsaXmlKeyReader
{
public static RSA ReadPublicKey(string xml)
{
var root = XElement.Parse(xml);
var modulus = root.Element("Modulus")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Modulus>.");
var exponent = root.Element("Exponent")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Exponent>.");
var parameters = new RSAParameters
{
Modulus = Convert.FromBase64String(modulus),
Exponent = Convert.FromBase64String(exponent)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return rsa;
}
}
@@ -0,0 +1,10 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>Stand-in for OSMCore's OMSCore.Common.Models.IOs.ApiResponse&lt;T&gt;, which has no equivalent in this project.</summary>
public sealed record ApiResponse<T>
{
public int StatusCode { get; init; }
public bool Success { get; init; }
public string? Message { get; init; }
public T? Data { get; init; }
}
@@ -0,0 +1,185 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/AuthMiddleware.cs (global path-skip-based
/// middleware). NOT registered in Program.cs — this project's actual token validation is the
/// standard AddJwtBearer handler (see Program.cs), same as OSMCore's own recommendation. Kept here
/// unregistered, exactly like OSMCore's own copy, for reference/reuse if a project needs a
/// middleware-based (rather than AddJwtBearer-based) validation entry point.
///
/// Two adaptations from the OSMCore original, both required for this to actually work against
/// this project's authorization system rather than just compiling:
/// 1. RSA key parsing goes through <see cref="RsaXmlKeyReader"/> instead of RSA.FromXmlString,
/// which is Windows-only in modern .NET (see docs/authentication_flow.md §2.2 Gotcha).
/// 2. After validating the token, the user id is mapped onto a local User/Role row and
/// role_id/role_level/username claims are attached — the same step Program.cs's
/// OnTokenValidated performs for the AddJwtBearer path. Without this, a request authenticated
/// through this middleware would have no role_id claim, and every
/// [Authorize(Policy = "resource.action")] check (PermissionAuthorizationHandler) would fail.
/// </summary>
public sealed class AuthMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<AuthMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (ShouldSkipAuthentication(context.Request.Path))
{
await next(context);
return;
}
if (!TryGetToken(context, out var token))
{
await RejectAsync(context, "Authentication token is missing.");
return;
}
try
{
var principal = ValidateToken(token);
var userRepo = context.RequestServices.GetRequiredService<IUserRepository>();
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
{
await RejectAsync(context, "User not found or inactive.");
return;
}
context.User = principal;
PopulateContextItems(context, principal);
await next(context);
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException)
{
logger.LogInformation(ex, "JWT validation failed for request path {Path}", context.Request.Path);
await RejectAsync(context, "Invalid or expired token.");
}
}
private ClaimsPrincipal ValidateToken(string token)
{
var jwtOptions = configuration.GetSection("Jwt").Get<JwtOptions>()
?? throw new InvalidOperationException("Jwt configuration is missing.");
if (string.IsNullOrWhiteSpace(jwtOptions.PublicKey))
{
throw new InvalidOperationException("Jwt:PublicKey configuration is missing.");
}
using var rsa = RsaXmlKeyReader.ReadPublicKey(jwtOptions.PublicKey);
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(rsa),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
return handler.ValidateToken(token, parameters, out _);
}
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
{
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(subClaim, out var userId))
return false;
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
return false;
var identity = (ClaimsIdentity)principal.Identity!;
identity.AddClaim(new Claim("username", user.Username));
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
return true;
}
private static bool TryGetToken(HttpContext context, out string token)
{
token = string.Empty;
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
{
return true;
}
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
if (TryGetBearerToken(headerToken, out token))
{
return true;
}
var cookieToken = context.Request.Cookies["access_token"];
if (!string.IsNullOrWhiteSpace(cookieToken))
{
token = cookieToken.Trim();
return true;
}
return false;
}
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
{
token = string.Empty;
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return false;
}
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
token = authorizationHeader.Trim();
return !string.IsNullOrWhiteSpace(token);
}
token = authorizationHeader[bearerPrefix.Length..].Trim();
return !string.IsNullOrWhiteSpace(token);
}
/// <summary>
/// Adapted from OSMCore's path list: this project has no Shopify-style webhook route, so that
/// skip is dropped; /api/auth is narrowed to just login/register (this project's /api/auth/me
/// and /api/auth/logout are meant to require auth, unlike a blanket "/api/auth" skip).
/// </summary>
private static bool ShouldSkipAuthentication(PathString path)
=> path.Equals("/health", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/api/auth/register", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase);
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
{
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
context.Items["Username"] = principal.FindFirst("username")?.Value;
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
}
private static async Task RejectAsync(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
{
StatusCode = StatusCodes.Status401Unauthorized,
Success = false,
Message = message,
Data = null
});
}
}
@@ -0,0 +1,191 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/JwtMiddleware.cs (action-filter attribute pattern,
/// applied per controller/action as [JwtMiddleware.CustomMiddleware]). Not applied anywhere in
/// this project — the standard AddJwtBearer handler (Program.cs) is this project's actual
/// validation path.
///
/// docs/authentication_flow.md explicitly calls out not running this alongside AuthMiddleware or
/// AddJwtBearer at the same time — all three independently re-validate the same RSA-signed token,
/// which is wasteful and error-prone (this is the same duplication OSMCore's own guide flags as a
/// Gotcha). Kept here, unregistered, only as a ported reference implementation.
///
/// Same two adaptations as AuthMiddleware: portable RSA parsing via
/// <see cref="RsaXmlKeyReader"/>, and local User/Role → role_id/role_level claim mapping after
/// validation (see AuthMiddleware's remarks for why this is required, not optional).
/// </summary>
public sealed class JwtMiddleware
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
if (!TryGetToken(context.HttpContext, out var token))
{
await RejectAsync(context.HttpContext, "Authentication token is missing.");
return;
}
try
{
if (configuration is null)
{
throw new InvalidOperationException("Jwt configuration is missing.");
}
var principal = ValidateToken(token, configuration);
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
{
await RejectAsync(context.HttpContext, "User not found or inactive.");
return;
}
context.HttpContext.User = principal;
PopulateContextItems(context.HttpContext, principal);
await next();
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException or InvalidOperationException)
{
await RejectAsync(context.HttpContext, "Invalid or expired token.");
}
}
private static ClaimsPrincipal ValidateToken(string token, IConfiguration configuration)
{
var xmlKey = configuration["Jwt:PublicKey"];
if (string.IsNullOrWhiteSpace(xmlKey))
throw new SecurityTokenException("JWT public key not configured.");
using var rsa = RsaXmlKeyReader.ReadPublicKey(xmlKey);
var rsaParameters = rsa.ExportParameters(false);
var key = new RsaSecurityKey(rsaParameters)
{
KeyId = ComputeKeyId(rsaParameters)
};
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = configuration["Jwt:Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
return handler.ValidateToken(token, parameters, out _);
}
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
{
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(subClaim, out var userId))
return false;
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
return false;
var identity = (ClaimsIdentity)principal.Identity!;
identity.AddClaim(new Claim("username", user.Username));
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
return true;
}
private static bool TryGetToken(HttpContext context, out string token)
{
token = string.Empty;
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
{
return true;
}
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
if (TryGetBearerToken(headerToken, out token))
{
return true;
}
var cookieToken = context.Request.Cookies["access_token"];
if (!string.IsNullOrWhiteSpace(cookieToken))
{
token = cookieToken.Trim();
return true;
}
return false;
}
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
{
token = string.Empty;
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return false;
}
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
token = authorizationHeader.Trim();
return !string.IsNullOrWhiteSpace(token);
}
token = authorizationHeader[bearerPrefix.Length..].Trim();
return !string.IsNullOrWhiteSpace(token);
}
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
{
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
context.Items["Username"] = principal.FindFirst("username")?.Value;
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
}
private static async Task RejectAsync(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
{
StatusCode = StatusCodes.Status401Unauthorized,
Success = false,
Message = message,
Data = null
});
}
private static string ComputeKeyId(RSAParameters publicParameters)
{
var modulus = publicParameters.Modulus ?? [];
var exponent = publicParameters.Exponent ?? [];
var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}";
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
return Base64UrlEncoder.Encode(hash);
}
}
}
@@ -0,0 +1,13 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/JwtOptions.cs. Binds to the same "Jwt" config
/// section already used by Program.cs's AddJwtBearer setup (Jwt:PublicKey/Audience/Issuer) — no
/// new config keys needed.
/// </summary>
public sealed record JwtOptions
{
public string PublicKey { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string Issuer { get; init; } = string.Empty;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class payment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "FK_SaleId",
table: "Orders",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "FulfilledAt",
table: "Orders",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Status",
table: "Orders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "QuantityFulfilled",
table: "OrderItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "DeliveryTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Sequence = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CustomerConfirmed = table.Column<bool>(type: "tinyint(1)", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Note = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeliveryTasks", x => x.Id);
table.ForeignKey(
name: "FK_DeliveryTasks_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DeliveryTasks_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Sales",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
SaleDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
PaymentStatus = table.Column<int>(type: "int", nullable: false),
FulfillImmediately = table.Column<bool>(type: "tinyint(1)", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sales", x => x.Id);
table.ForeignKey(
name: "FK_Sales_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Payments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
Method = table.Column<int>(type: "int", nullable: false),
CollectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CollectedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Payments", x => x.Id);
table.ForeignKey(
name: "FK_Payments_Sales_FK_SaleId",
column: x => x.FK_SaleId,
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Payments_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Orders_FK_SaleId",
table: "Orders",
column: "FK_SaleId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DeliveryTasks_FK_OrderId",
table: "DeliveryTasks",
column: "FK_OrderId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryTasks_FK_RideId",
table: "DeliveryTasks",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_Payments_FK_SaleId",
table: "Payments",
column: "FK_SaleId");
migrationBuilder.CreateIndex(
name: "IX_Payments_FK_StoreId",
table: "Payments",
column: "FK_StoreId");
migrationBuilder.CreateIndex(
name: "IX_Sales_CreatedByUserId",
table: "Sales",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_RideId",
table: "Sales",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_StoreId",
table: "Sales",
column: "FK_StoreId");
migrationBuilder.AddForeignKey(
name: "FK_Orders_Sales_FK_SaleId",
table: "Orders",
column: "FK_SaleId",
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Orders_Sales_FK_SaleId",
table: "Orders");
migrationBuilder.DropTable(
name: "DeliveryTasks");
migrationBuilder.DropTable(
name: "Payments");
migrationBuilder.DropTable(
name: "Sales");
migrationBuilder.DropIndex(
name: "IX_Orders_FK_SaleId",
table: "Orders");
migrationBuilder.DropColumn(
name: "FK_SaleId",
table: "Orders");
migrationBuilder.DropColumn(
name: "FulfilledAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "Status",
table: "Orders");
migrationBuilder.DropColumn(
name: "QuantityFulfilled",
table: "OrderItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddReturnsAndClaims : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Claims",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Details = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<int>(type: "int", nullable: false),
SettlementAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
SettledByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
SettledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Claims", x => x.Id);
table.ForeignKey(
name: "FK_Claims_Users_FK_AgencyUserId",
column: x => x.FK_AgencyUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Claims_Users_SettledByUserId",
column: x => x.SettledByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SaleReturns",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Mode = table.Column<int>(type: "int", nullable: false),
TotalCreditAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleReturns", x => x.Id);
table.ForeignKey(
name: "FK_SaleReturns_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SaleReturns_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "CreditNotes",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CreditNotes", x => x.Id);
table.ForeignKey(
name: "FK_CreditNotes_SaleReturns_FK_SaleReturnId",
column: x => x.FK_SaleReturnId,
principalTable: "SaleReturns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SaleReturnItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ReasonCode = table.Column<int>(type: "int", nullable: false),
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SaleReturnItems", x => x.Id);
table.ForeignKey(
name: "FK_SaleReturnItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SaleReturnItems_SaleReturns_FK_SaleReturnId",
column: x => x.FK_SaleReturnId,
principalTable: "SaleReturns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Claims_FK_AgencyUserId",
table: "Claims",
column: "FK_AgencyUserId");
migrationBuilder.CreateIndex(
name: "IX_Claims_SettledByUserId",
table: "Claims",
column: "SettledByUserId");
migrationBuilder.CreateIndex(
name: "IX_CreditNotes_FK_SaleReturnId",
table: "CreditNotes",
column: "FK_SaleReturnId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturnItems_FK_ItemId",
table: "SaleReturnItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturnItems_FK_SaleReturnId",
table: "SaleReturnItems",
column: "FK_SaleReturnId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturns_CreatedByUserId",
table: "SaleReturns",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_SaleReturns_FK_OrderId",
table: "SaleReturns",
column: "FK_OrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Claims");
migrationBuilder.DropTable(
name: "CreditNotes");
migrationBuilder.DropTable(
name: "SaleReturnItems");
migrationBuilder.DropTable(
name: "SaleReturns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddSupplierClaims : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SupplierClaims",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Details = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EvidenceUrl = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<int>(type: "int", nullable: false),
ResolutionType = table.Column<int>(type: "int", nullable: true),
ResolutionAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
ResolutionNotes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
RaisedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ResolvedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
ResolvedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierClaims", x => x.Id);
table.ForeignKey(
name: "FK_SupplierClaims_GoodsReceipts_FK_GoodsReceiptId",
column: x => x.FK_GoodsReceiptId,
principalTable: "GoodsReceipts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaims_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaims_Users_RaisedByUserId",
column: x => x.RaisedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaims_Users_ResolvedByUserId",
column: x => x.ResolvedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SupplierClaimItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SupplierClaimId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
IssueType = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierClaimItems", x => x.Id);
table.ForeignKey(
name: "FK_SupplierClaimItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SupplierClaimItems_SupplierClaims_FK_SupplierClaimId",
column: x => x.FK_SupplierClaimId,
principalTable: "SupplierClaims",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaimItems_FK_ItemId",
table: "SupplierClaimItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaimItems_FK_SupplierClaimId",
table: "SupplierClaimItems",
column: "FK_SupplierClaimId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_FK_GoodsReceiptId",
table: "SupplierClaims",
column: "FK_GoodsReceiptId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_FK_PurchaseOrderId",
table: "SupplierClaims",
column: "FK_PurchaseOrderId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_RaisedByUserId",
table: "SupplierClaims",
column: "RaisedByUserId");
migrationBuilder.CreateIndex(
name: "IX_SupplierClaims_ResolvedByUserId",
table: "SupplierClaims",
column: "ResolvedByUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SupplierClaimItems");
migrationBuilder.DropTable(
name: "SupplierClaims");
}
}
}
@@ -73,6 +73,74 @@ namespace Distribution.Migrations
b.ToTable("Categories"); b.ToTable("Categories");
}); });
modelBuilder.Entity("Distribution.Models.Claim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("FK_AgencyUserId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("SettledAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("SettledByUserId")
.HasColumnType("char(36)");
b.Property<decimal?>("SettlementAmount")
.HasColumnType("decimal(65,30)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_AgencyUserId");
b.HasIndex("SettledByUserId");
b.ToTable("Claims");
});
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("Amount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleReturnId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("IssuedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("FK_SaleReturnId");
b.ToTable("CreditNotes");
});
modelBuilder.Entity("Distribution.Models.DeliveryRun", b => modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -131,6 +199,48 @@ namespace Distribution.Migrations
b.ToTable("DeliveryRuns"); b.ToTable("DeliveryRuns");
}); });
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("CustomerConfirmed")
.HasColumnType("tinyint(1)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Note")
.HasColumnType("longtext");
b.Property<int>("Sequence")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_OrderId");
b.HasIndex("FK_RideId");
b.ToTable("DeliveryTasks");
});
modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b => modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -397,20 +507,32 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId") b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<DateTime?>("FulfilledAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
b.Property<DateTime>("OrderDate") b.Property<DateTime>("OrderDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount") b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("FK_SaleId")
.IsUnique();
b.HasIndex("FK_StoreId"); b.HasIndex("FK_StoreId");
b.ToTable("Orders"); b.ToTable("Orders");
@@ -436,6 +558,9 @@ namespace Distribution.Migrations
b.Property<int>("Quantity") b.Property<int>("Quantity")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int>("QuantityFulfilled")
.HasColumnType("int");
b.Property<decimal>("UnitPrice") b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
@@ -446,6 +571,45 @@ namespace Distribution.Migrations
b.ToTable("OrderItems"); b.ToTable("OrderItems");
}); });
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("Amount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CollectedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CollectedBy")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Method")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_SaleId");
b.HasIndex("FK_StoreId");
b.ToTable("Payments");
});
modelBuilder.Entity("Distribution.Models.Permission", b => modelBuilder.Entity("Distribution.Models.Permission", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -860,6 +1024,125 @@ namespace Distribution.Migrations
b.ToTable("Routes"); b.ToTable("Routes");
}); });
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_RideId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<bool>("FulfillImmediately")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.Property<DateTime>("SaleDate")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_RideId");
b.HasIndex("FK_StoreId");
b.ToTable("Sales");
});
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Mode")
.HasColumnType("int");
b.Property<decimal>("TotalCreditAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_OrderId");
b.ToTable("SaleReturns");
});
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SaleReturnId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<decimal>("Quantity")
.HasColumnType("decimal(65,30)");
b.Property<int>("ReasonCode")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_SaleReturnId");
b.ToTable("SaleReturnItems");
});
modelBuilder.Entity("Distribution.Models.SaleRoute", b => modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1231,6 +1514,98 @@ namespace Distribution.Migrations
b.ToTable("Suppliers"); b.ToTable("Suppliers");
}); });
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("EvidenceUrl")
.HasColumnType("longtext");
b.Property<Guid?>("FK_GoodsReceiptId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<Guid>("RaisedByUserId")
.HasColumnType("char(36)");
b.Property<decimal?>("ResolutionAmount")
.HasColumnType("decimal(65,30)");
b.Property<string>("ResolutionNotes")
.HasColumnType("longtext");
b.Property<int?>("ResolutionType")
.HasColumnType("int");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ResolvedByUserId")
.HasColumnType("char(36)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_GoodsReceiptId");
b.HasIndex("FK_PurchaseOrderId");
b.HasIndex("RaisedByUserId");
b.HasIndex("ResolvedByUserId");
b.ToTable("SupplierClaims");
});
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SupplierClaimId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("IssueType")
.HasColumnType("int");
b.Property<decimal>("Quantity")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_SupplierClaimId");
b.ToTable("SupplierClaimItems");
});
modelBuilder.Entity("Distribution.Models.Uom", b => modelBuilder.Entity("Distribution.Models.Uom", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1464,6 +1839,35 @@ namespace Distribution.Migrations
b.ToTable("Warehouses"); b.ToTable("Warehouses");
}); });
modelBuilder.Entity("Distribution.Models.Claim", b =>
{
b.HasOne("Distribution.Models.User", "AgencyUser")
.WithMany()
.HasForeignKey("FK_AgencyUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "SettledByUser")
.WithMany()
.HasForeignKey("SettledByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AgencyUser");
b.Navigation("SettledByUser");
});
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
{
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
.WithMany()
.HasForeignKey("FK_SaleReturnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SaleReturn");
});
modelBuilder.Entity("Distribution.Models.DeliveryRun", b => modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
{ {
b.HasOne("Distribution.Models.DriverProfile", "DriverProfile") b.HasOne("Distribution.Models.DriverProfile", "DriverProfile")
@@ -1499,6 +1903,25 @@ namespace Distribution.Migrations
b.Navigation("Vehicle"); b.Navigation("Vehicle");
}); });
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.DriverProfile", b => modelBuilder.Entity("Distribution.Models.DriverProfile", b =>
{ {
b.HasOne("Distribution.Models.User", "User") b.HasOne("Distribution.Models.User", "User")
@@ -1619,12 +2042,20 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Order", b => modelBuilder.Entity("Distribution.Models.Order", b =>
{ {
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store") b.HasOne("Distribution.Models.Store", "Store")
.WithMany() .WithMany()
.HasForeignKey("FK_StoreId") .HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Sale");
b.Navigation("Store"); b.Navigation("Store");
}); });
@@ -1637,7 +2068,7 @@ namespace Distribution.Migrations
.IsRequired(); .IsRequired();
b.HasOne("Distribution.Models.Order", "Order") b.HasOne("Distribution.Models.Order", "Order")
.WithMany() .WithMany("OrderItems")
.HasForeignKey("FK_OrderId") .HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1647,6 +2078,25 @@ namespace Distribution.Migrations
b.Navigation("Order"); b.Navigation("Order");
}); });
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.PriceList", b => modelBuilder.Entity("Distribution.Models.PriceList", b =>
{ {
b.HasOne("Distribution.Models.Region", "Region") b.HasOne("Distribution.Models.Region", "Region")
@@ -1776,6 +2226,70 @@ namespace Distribution.Migrations
b.Navigation("Region"); b.Navigation("Region");
}); });
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Ride");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Order");
});
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
.WithMany("Items")
.HasForeignKey("FK_SaleReturnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("SaleReturn");
});
modelBuilder.Entity("Distribution.Models.Scheme", b => modelBuilder.Entity("Distribution.Models.Scheme", b =>
{ {
b.HasOne("Distribution.Models.Item", "FreeItem") b.HasOne("Distribution.Models.Item", "FreeItem")
@@ -1898,6 +2412,58 @@ namespace Distribution.Migrations
b.Navigation("Category"); b.Navigation("Category");
}); });
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt")
.WithMany()
.HasForeignKey("FK_GoodsReceiptId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
.WithMany()
.HasForeignKey("FK_PurchaseOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "RaisedByUser")
.WithMany()
.HasForeignKey("RaisedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "ResolvedByUser")
.WithMany()
.HasForeignKey("ResolvedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("GoodsReceipt");
b.Navigation("PurchaseOrder");
b.Navigation("RaisedByUser");
b.Navigation("ResolvedByUser");
});
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim")
.WithMany("Items")
.HasForeignKey("FK_SupplierClaimId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("SupplierClaim");
});
modelBuilder.Entity("Distribution.Models.UomConversion", b => modelBuilder.Entity("Distribution.Models.UomConversion", b =>
{ {
b.HasOne("Distribution.Models.Uom", "FromUom") b.HasOne("Distribution.Models.Uom", "FromUom")
@@ -2032,6 +2598,11 @@ namespace Distribution.Migrations
b.Navigation("Items"); b.Navigation("Items");
}); });
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.Navigation("OrderItems");
});
modelBuilder.Entity("Distribution.Models.PriceList", b => modelBuilder.Entity("Distribution.Models.PriceList", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");
@@ -2047,12 +2618,22 @@ namespace Distribution.Migrations
b.Navigation("Items"); b.Navigation("Items");
}); });
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.Scheme", b => modelBuilder.Entity("Distribution.Models.Scheme", b =>
{ {
b.Navigation("BundleComponents"); b.Navigation("BundleComponents");
b.Navigation("Redemptions"); b.Navigation("Redemptions");
}); });
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Claim : Template
{
/// <summary>User holding the RegionalAgency role that this claim is raised against.</summary>
public Guid FK_AgencyUserId { get; set; }
[ForeignKey("FK_AgencyUserId")]
public User AgencyUser { get; set; }
public string Details { get; set; }
public ClaimStatus Status { get; set; } = ClaimStatus.Open;
public decimal? SettlementAmount { get; set; }
public Guid? SettledByUserId { get; set; }
[ForeignKey("SettledByUserId")]
public User? SettledByUser { get; set; }
public DateTime? SettledAt { get; set; }
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>Audit record of the credit amount netted off an order by a CreditDeduction-mode SaleReturn.</summary>
public class CreditNote : Template
{
public Guid FK_SaleReturnId { get; set; }
[ForeignKey("FK_SaleReturnId")]
public SaleReturn SaleReturn { get; set; }
public decimal Amount { get; set; }
public DateTime IssuedAt { get; set; } = DateTime.Now;
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class DeliveryTask : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public int Sequence { get; set; }
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
public bool CustomerConfirmed { get; set; } = false;
public DateTime? ConfirmedAt { get; set; }
public string? Note { get; set; }
}
}
+27 -28
View File
@@ -1,37 +1,13 @@
namespace Distribution.Models namespace Distribution.Models
{ {
public enum LocationType public enum LocationType
{ { Warehouse, Vehicle }
Warehouse,
Vehicle
}
public enum KycStatus public enum KycStatus { Pending,Verified, Rejected }
{
Pending,
Verified,
Rejected }
public enum MovementType public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
{
Sale,
CustomerReturn,
Transfer,
Damage,
Adjustment,
Load,
PurchaseReceipt,
PurchaseReturn
}
public enum LoadingSheetStatus public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
{
Pending,
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
public enum RideType { Delivery, Sale } public enum RideType { Delivery, Sale }
@@ -42,4 +18,27 @@ namespace Distribution.Models
public enum CancelRequestStatus { Pending, Approved, Rejected } public enum CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled } public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum SaleStatus { Pending, Completed, Cancelled }
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
public enum PaymentMethod { Cash, Credit }
public enum DeliveryTaskStatus
{ Pending, Delivered, PartiallyDelivered, Failed}
public enum ReturnMode { CreditDeduction, ItemExchange }
public enum ReturnReasonCode { Expired, Damaged, OrderError, Other }
public enum ClaimStatus { Open, Settled }
public enum SupplierClaimStatus { Open, Approved, Rejected }
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
public enum SupplierClaimResolutionType { Replacement, Credit, Refund, PriceAdjustment }
} }
+8
View File
@@ -4,10 +4,18 @@ namespace Distribution.Models
{ {
public class Order : Template public class Order : Template
{ {
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public DateTime OrderDate { get; set; } public DateTime OrderDate { get; set; }
public Guid FK_StoreId { get; set; } public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")] [ForeignKey("FK_StoreId")]
public Store Store { get; set; } public Store Store { get; set; }
public decimal TotalAmount { get; set; } public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime? FulfilledAt { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
} }
} }
+1
View File
@@ -11,6 +11,7 @@ namespace Distribution.Models
[ForeignKey("FK_ItemId")] [ForeignKey("FK_ItemId")]
public Item Item { get; set; } public Item Item { get; set; }
public int Quantity { get; set; } public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
} }
} }
+20
View File
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Payment : Template
{
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal Amount { get; set; }
public PaymentMethod Method { get; set; }
public DateTime CollectedAt { get; set; }
public Guid CollectedBy { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Sale : Template
{
public DateTime SaleDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public Guid? FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun? Ride { get; set; }
public decimal TotalAmount { get; set; }
public SaleStatus Status { get; set; } = SaleStatus.Pending;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public bool FulfillImmediately { get; set; }
public DateTime? CompletedAt { get; set; }
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>
/// A return recorded by a sales rep against an open Order during the same store visit.
/// Settled immediately on creation — no supervisor approval step.
/// </summary>
public class SaleReturn : Template
{
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public ReturnMode Mode { get; set; }
/// <summary>Amount netted off the order's TotalAmount. Zero for ItemExchange mode.</summary>
public decimal TotalCreditAmount { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public ICollection<SaleReturnItem> Items { get; set; } = [];
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SaleReturnItem : Template
{
public Guid FK_SaleReturnId { get; set; }
[ForeignKey("FK_SaleReturnId")]
public SaleReturn SaleReturn { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public decimal Quantity { get; set; }
public ReturnReasonCode ReasonCode { get; set; }
/// <summary>Snapshot of the item's unit price at return time, used to compute credit amount.</summary>
public decimal UnitPrice { get; set; }
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SupplierClaim : Template
{
public Guid FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; }
public Guid? FK_GoodsReceiptId { get; set; }
[ForeignKey("FK_GoodsReceiptId")]
public GoodsReceipt? GoodsReceipt { get; set; }
public string Details { get; set; }
public string? EvidenceUrl { get; set; }
public SupplierClaimStatus Status { get; set; } = SupplierClaimStatus.Open;
public SupplierClaimResolutionType? ResolutionType { get; set; }
public decimal? ResolutionAmount { get; set; }
public string? ResolutionNotes { get; set; }
public Guid RaisedByUserId { get; set; }
[ForeignKey("RaisedByUserId")]
public User RaisedByUser { get; set; }
public Guid? ResolvedByUserId { get; set; }
[ForeignKey("ResolvedByUserId")]
public User? ResolvedByUser { get; set; }
public DateTime? ResolvedAt { get; set; }
public ICollection<SupplierClaimItem> Items { get; set; } = [];
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class SupplierClaimItem : Template
{
public Guid FK_SupplierClaimId { get; set; }
[ForeignKey("FK_SupplierClaimId")]
public SupplierClaim SupplierClaim { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public decimal Quantity { get; set; }
public SupplierClaimIssueType IssueType { get; set; }
}
}
+55 -244
View File
@@ -1,267 +1,78 @@
# Phase Plan — Route & Store Management # Phase Plan - Route and Store Management
## Scope ## Scope
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, OrderItem**. Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order,
Item model is sourced from ERP Core — excluded from this phase. OrderItem**.
SaleRoute shape is TBD — excluded until clarified.
Item model is sourced from ERP Core - excluded from this phase.
Historical note: `SaleRoute` was marked TBD here. For now, rides stay single-route and route
assignment remains simple until further clarification.
--- ---
## Project Structure (target) ## Project Structure (target)
``` ```text
Distribution/ Distribution/
├── Models/ # All entity POCOs — unchanged |-- Models/
│ ├── Template.cs | |-- Template.cs
│ ├── Region.cs | |-- Region.cs
│ ├── Route.cs | |-- Route.cs
│ ├── Vehicle.cs | |-- Vehicle.cs
│ ├── Store.cs | |-- Store.cs
│ ├── Warehouse.cs | |-- Warehouse.cs
│ ├── SaleRoute.cs # TBD | |-- SaleRoute.cs
│ ├── Order.cs | |-- Order.cs
│ ├── OrderItem.cs | |-- OrderItem.cs
│ ├── DeliveryRun.cs | |-- DeliveryRun.cs
│ └── Item.cs # Sourced from ERP Core | `-- Item.cs
├── Data/ |-- Data/
│ └── AppDBContext.cs # DbSets + Fluent API config | `-- AppDBContext.cs
├── Infra/ |-- Infra/
│ ├── UoW/ # Already implemented | |-- UoW/
│ │ ├── IUnitOfWork.cs | | |-- IUnitOfWork.cs
│ │ └── EFUnitOfWork.cs | | `-- EFUnitOfWork.cs
│ └── Repositories/ # Shared generic base only | `-- Repositories/
│ ├── IGenericRepo.cs | |-- IGenericRepo.cs
│ └── GenericRepo.cs | `-- GenericRepo.cs
└── Features/ `-- Features/
├── Region/ |-- Region/
│ ├── RegionDTOs.cs |-- Route/
│ ├── IRegionRepository.cs |-- Vehicle/
│ ├── RegionRepository.cs |-- Store/
│ ├── RegionService.cs |-- Warehouse/
│ └── RegionController.cs `-- Order/
├── Route/
│ ├── RouteDTOs.cs
│ ├── IRouteRepository.cs
│ ├── RouteRepository.cs
│ ├── RouteService.cs
│ └── RouteController.cs
├── Vehicle/
│ ├── VehicleDTOs.cs
│ ├── IVehicleRepository.cs
│ ├── VehicleRepository.cs
│ ├── VehicleService.cs
│ └── VehicleController.cs
├── Store/
│ ├── StoreDTOs.cs
│ ├── IStoreRepository.cs
│ ├── StoreRepository.cs
│ ├── StoreService.cs
│ └── StoreController.cs
├── Warehouse/
│ ├── WarehouseDTOs.cs
│ ├── IWarehouseRepository.cs
│ ├── WarehouseRepository.cs
│ ├── WarehouseService.cs
│ └── WarehouseController.cs
└── Order/
├── OrderDTOs.cs
├── IOrderRepository.cs
├── OrderRepository.cs
├── OrderService.cs
└── OrderController.cs
``` ```
--- ---
## Hard Rules (from AGENTS.md) ## Hard Rules
- Only `uow.CommitAsync()` in services — never in repositories. - Only `uow.CommitAsync()` in services - never in repositories.
- Use `DateTime.Now` — never `DateTime.UtcNow`. - Use `DateTime.Now` - never `DateTime.UtcNow`.
- Never build anything not explicitly requested. - Never build anything not explicitly requested.
--- ---
## Step 2 — Complete AppDBContext
File: `Data/AppDBContext.cs`
Add DbSets and Fluent API configuration:
```csharp
public DbSet<Region> Regions { get; set; }
public DbSet<Route> Routes { get; set; }
public DbSet<Vehicle> Vehicles { get; set; }
public DbSet<Store> Stores { get; set; }
public DbSet<Warehouse> Warehouses { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<Item> Items { get; set; }
// OnModelCreating
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
```
---
## Step 3 — Initial Migration
```powershell
dotnet ef migrations add InitialCreate --project Distribution
dotnet ef database update --project Distribution
```
Each developer applies this locally. No migration files are shared.
---
## Step 4 — Generic Repository (Infra/Repositories)
**`IGenericRepo<T>`**
```
GetByIdAsync(Guid id)
GetAllAsync()
AddAsync(T entity)
Update(T entity)
Remove(T entity)
```
**`GenericRepo<T>`** — implements above using DbContext. No `SaveChanges` calls.
---
## Step 5 — Feature Implementation Order
Build each feature in this dependency order:
```
1. Region → no dependencies
2. Route → no dependencies
3. Vehicle → no dependencies
4. Store → no dependencies
5. Warehouse → depends on Vehicle (FK)
6. Order → depends on Store (FK)
7. OrderItem → depends on Order + Item (composite FK)
```
### Per-feature deliverables
#### DTOs (`{Feature}DTOs.cs`)
- `CreateRequest` — fields accepted on POST
- `UpdateRequest` — fields accepted on PUT
- `Response` — shape returned to client (never expose the entity directly)
#### Repository interface (`I{Feature}Repository.cs`)
Extends `IGenericRepo<T>`. Add feature-specific queries (e.g., `GetByStoreId`).
#### Repository implementation (`{Feature}Repository.cs`)
Inherits `GenericRepo<T>`. Implements feature-specific queries. No `CommitAsync` calls.
#### Service (`{Feature}Service.cs`)
- Contains all business logic.
- Calls `_uow.CommitAsync(ct)` after write operations.
- For multi-step writes: `BeginAsync → work → CommitAsync` (catch → `RollbackAsync`).
#### Controller (`{Feature}Controller.cs`)
Thin — maps HTTP → service call → returns result. No logic, no UoW calls.
---
## Step 6 — Endpoints
### Region — `/api/regions`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active regions |
| GET | `/{id}` | Get region by id |
| POST | `/` | Create region |
| PUT | `/{id}` | Update region |
| DELETE | `/{id}` | Soft delete (set IsActive = false) |
### Route — `/api/routes`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active routes |
| GET | `/{id}` | Get route by id |
| POST | `/` | Create route |
| PUT | `/{id}` | Update route |
| DELETE | `/{id}` | Soft delete |
### Vehicle — `/api/vehicles`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active vehicles |
| GET | `/{id}` | Get vehicle by id |
| POST | `/` | Create vehicle |
| PUT | `/{id}` | Update vehicle |
| DELETE | `/{id}` | Soft delete |
### Store — `/api/stores`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active stores |
| GET | `/{id}` | Get store by id |
| POST | `/` | Create store |
| PUT | `/{id}` | Update store |
| DELETE | `/{id}` | Soft delete |
### Warehouse — `/api/warehouses`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active warehouses |
| GET | `/{id}` | Get warehouse by id |
| POST | `/` | Create warehouse |
| PUT | `/{id}` | Update warehouse |
| DELETE | `/{id}` | Soft delete |
### Order — `/api/orders`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active orders |
| GET | `/{id}` | Get order with items |
| GET | `/store/{storeId}` | Get orders by store |
| POST | `/` | Create order with items |
| PUT | `/{id}` | Update order |
| DELETE | `/{id}` | Soft delete |
---
## Step 7 — DI Registration (Program.cs)
Register all feature repositories and services:
```csharp
// Generic base
builder.Services.AddScoped(typeof(IGenericRepo<>), typeof(GenericRepo<>));
// Feature repositories
builder.Services.AddScoped<IRegionRepository, RegionRepository>();
builder.Services.AddScoped<IRouteRepository, RouteRepository>();
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Feature services
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
```
---
## Deferred ## Deferred
| Item | Reason | | Item | Reason |
|---|---| |---|---|
| `SaleRoute` | Shape undefined — implement after clarification | | `SaleRoute` | Re-evaluate after ride/route design is clarified |
| `Item` model | Sourced from ERP Core — user handles integration | | `Item` model | Sourced from ERP Core |
| `DeliveryRun` | Not in scope for this phase | | `DeliveryRun` | Not in scope for this phase |
| Driver FK on DeliveryRun | Commented out — depends on HR/Driver module | | Tests | No test project yet |
| Tests | No test project yet — add after endpoints stabilize |
---
## Note
This file is historical phase planning only. Current business definitions for ride, sale, order,
and fulfillment are maintained in:
- `docs/03_ride_lifecycle.md`
- `docs/04_loading_event.md`
- `docs/05_delivery_ride.md`
- `docs/06_sale_ride.md`
+60 -4
View File
@@ -1,8 +1,10 @@
using System.Text; using System.Security.Claims;
using Distribution.Data; using Distribution.Data;
using Distribution.Features.Auth; using Distribution.Features.Auth;
using Distribution.Features.Brand; using Distribution.Features.Brand;
using Distribution.Features.Category; using Distribution.Features.Category;
using Distribution.Features.DeliveryRide;
using Distribution.Features.Payment;
using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile; using Distribution.Features.DriverProfile;
using Distribution.Features.Grn; using Distribution.Features.Grn;
@@ -11,11 +13,15 @@ using Distribution.Features.Order;
using Distribution.Features.PriceList; using Distribution.Features.PriceList;
using Distribution.Features.Po; using Distribution.Features.Po;
using Distribution.Features.PurchaseReturn; using Distribution.Features.PurchaseReturn;
using Distribution.Features.Returns;
using Distribution.Features.Claims;
using Distribution.Features.SupplierClaims;
using Distribution.Features.Region; using Distribution.Features.Region;
using Distribution.Features.LoadingEvent; using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride; using Distribution.Features.Ride;
using Distribution.Features.Role; using Distribution.Features.Role;
using Distribution.Features.Route; using Distribution.Features.Route;
using Distribution.Features.Sale;
using Distribution.Features.Scheme; using Distribution.Features.Scheme;
using Distribution.Features.Store; using Distribution.Features.Store;
using Distribution.Features.Store.Kyc; using Distribution.Features.Store.Kyc;
@@ -49,6 +55,7 @@ builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>(); builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>(); builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>(); builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>(); builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>(); builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
@@ -69,6 +76,11 @@ builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<IPoRepository, PoRepository>(); builder.Services.AddScoped<IPoRepository, PoRepository>();
builder.Services.AddScoped<IGrnRepository, GrnRepository>(); builder.Services.AddScoped<IGrnRepository, GrnRepository>();
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>(); builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<RegionService>(); builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>(); builder.Services.AddScoped<RouteService>();
@@ -76,6 +88,7 @@ builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>(); builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>(); builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>(); builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<UserService>(); builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>(); builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
@@ -96,10 +109,19 @@ builder.Services.AddScoped<SchemeService>();
builder.Services.AddScoped<PoService>(); builder.Services.AddScoped<PoService>();
builder.Services.AddScoped<GrnService>(); builder.Services.AddScoped<GrnService>();
builder.Services.AddScoped<PurchaseReturnService>(); builder.Services.AddScoped<PurchaseReturnService>();
builder.Services.AddScoped<ReturnService>();
builder.Services.AddScoped<ClaimService>();
builder.Services.AddScoped<SupplierClaimService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>(); builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
{
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>(); builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
builder.Services.AddScoped<IScopeRepository, ScopeRepository>(); builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
builder.Services.AddScoped<IPermissionService, PermissionService>(); builder.Services.AddScoped<IPermissionService, PermissionService>();
@@ -108,7 +130,8 @@ builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>(); builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
var jwtSection = builder.Configuration.GetSection("Jwt"); var jwtSection = builder.Configuration.GetSection("Jwt");
var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured.");
var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml));
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
{ {
@@ -124,10 +147,43 @@ builder.Services.AddAuthentication(options =>
ValidateAudience = true, ValidateAudience = true,
ValidAudience = jwtSection["Audience"], ValidAudience = jwtSection["Audience"],
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), IssuerSigningKey = jwtSigningKey,
ValidateLifetime = true, ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1) ClockSkew = TimeSpan.FromMinutes(1)
}; };
// AuthHex issues the token and owns its claim shape; this app's authorization (permission
// policies, role-level checks) runs against the *local* Role/Permission tables, so every
// validated token is mapped back to a local User/Role once here, not duplicated per
// endpoint. A token for a user with no matching local User record is rejected outright —
// login via AuthHex does not implicitly provision a local user.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var subClaim = context.Principal?.FindFirst("sub")?.Value
?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(subClaim, out var userId))
{
context.Fail("Token is missing a valid user id claim.");
return;
}
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
var user = await userRepo.GetByIdAsync(userId);
if (user is null || !user.IsActive)
{
context.Fail("User not found or inactive.");
return;
}
var identity = (ClaimsIdentity)context.Principal!.Identity!;
identity.AddClaim(new Claim("username", user.Username));
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
}
};
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
+8 -4
View File
@@ -10,10 +10,14 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Jwt": { "Jwt": {
"Key": "CHANGE_ME_dev_only_replace_with_a_long_random_secret_before_deploying", "PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
"Issuer": "Distribution.Api", "Audience": "AuthHexClient",
"Audience": "Distribution.Api", "Issuer": "AuthHex"
"ExpiryMinutes": 120 // "ExpiryMinutes": 120
},
"AuthHex": {
"BaseUrl": "http://localhost:6102"
}, },
"StoreManagement": { "StoreManagement": {
"UnverifiedKycOrderThreshold": 0 "UnverifiedKycOrderThreshold": 0
+1 -1
View File
@@ -1,6 +1,6 @@
# 00 — Existing Gap Fixes # 00 — Existing Gap Fixes
**Status**: Not Started **Status**: Done
**Depends On**: — **Depends On**: —
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# 01 — Item Catalog # 01 — Item Catalog
**Status**: Ready **Status**: Done
**Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done) **Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done)
--- ---
+88 -55
View File
@@ -1,41 +1,59 @@
# 03 — Ride Lifecycle # 03 - Ride Lifecycle
**Status**: Done **Status**: Done
**Depends On**: 02 — Stock Ledger **Depends On**: 02 - Stock Ledger
--- ---
## Goal ## Goal
Turn the currently-unused `DeliveryRun` model into a fully working ride — a session that turns a Turn the currently-unused `DeliveryRun` model into a fully working ride - a session that turns a
vehicle into a temporary warehouse. Covers driver profile management, ride creation, state vehicle into a temporary warehouse. Covers driver profile management, ride creation, state
transitions, cancellation workflow, and the vehicle breakdown scenario. transitions, cancellation workflow, and the vehicle breakdown scenario.
--- ---
## Current Business Understanding
- A `Ride` is one operational trip for one vehicle and one driver on one route for now.
- `RideType` distinguishes business behavior:
- `Delivery` ride: used to fulfill existing orders.
- `Sale` ride: used by sales agents in the field to create sales, and every sale creates an
order.
- `Sale` and `Order` are different:
- `Sale` is the commercial transaction created by the sales agent.
- `Order` is the fulfillment document created from every sale.
- Item lines are maintained once in `OrderItem`; `Sale` does not keep a separate item-line table.
- `Sale` statuses use sales language such as `Completed`.
- `Order` statuses use logistics language such as `Fulfilled`.
---
## Data Model Changes ## Data Model Changes
### New Entity — DriverProfile ### New Entity - DriverProfile
Linked one-to-one with `User`. Holds compliance data only — no login or auth fields. Linked one-to-one with `User`. Holds compliance data only - no login or auth fields.
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| Id | Guid | PK | | Id | Guid | PK |
| FK_UserId | Guid | FK → User (unique) | | FK_UserId | Guid | FK -> User (unique) |
| LicenseNumber | string | | | LicenseNumber | string | |
| LicenseExpiry | DateTime | | | LicenseExpiry | DateTime | |
| MedicalClearanceExpiry | DateTime | | | MedicalClearanceExpiry | DateTime | |
| IsActive | bool | Can be suspended without deleting | | IsActive | bool | Can be suspended without deleting |
| CreatedAt | DateTime | | | CreatedAt | DateTime | |
No license class tied to vehicle type — not required. No license class tied to vehicle type - not required.
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled: `LicenseExpiry > today` AND/OR `MedicalClearanceExpiry > today`. **Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are
enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled:
`LicenseExpiry > today` and/or `MedicalClearanceExpiry > today`.
--- ---
### Updated Entity — Vehicle ### Updated Entity - Vehicle
Add one nullable field: Add one nullable field:
@@ -45,7 +63,7 @@ Add one nullable field:
--- ---
### Updated Entity — DeliveryRun (becomes Ride) ### Updated Entity - DeliveryRun (becomes Ride)
Add the following fields to the existing `DeliveryRun`: Add the following fields to the existing `DeliveryRun`:
@@ -53,34 +71,43 @@ Add the following fields to the existing `DeliveryRun`:
|---|---|---| |---|---|---|
| RideType | RideType | Delivery or Sale | | RideType | RideType | Delivery or Sale |
| Status | RideStatus | State machine below | | Status | RideStatus | State machine below |
| FK_DriverProfileId | Guid | FK → DriverProfile | | FK_DriverProfileId | Guid | FK -> DriverProfile |
| FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. | | FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. |
| StartedAt | DateTime? | Set when Draft → Open | | StartedAt | DateTime? | Set when Draft -> Open |
| ActivatedAt | DateTime? | Set when Open → Active | | ActivatedAt | DateTime? | Set when Open -> Active |
| ClosedAt | DateTime? | Set when Active → Closed | | ClosedAt | DateTime? | Set when Active -> Closed |
--- ---
### New Enum — RideType ### New Enum - RideType
```csharp ```csharp
public enum RideType { Delivery, Sale } public enum RideType { Delivery, Sale }
``` ```
### New Enum — RideStatus ### New Enum - RideStatus
```csharp ```csharp
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed } public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
``` ```
Meaning of statuses:
- `Draft`: ride exists but operational work has not started.
- `Open`: ride is prepared and ready for loading/preparation work.
- `Active`: ride is live in the field.
- `CancelRequested`: active ride is waiting for cancellation approval or rejection.
- `Cancelled`: ride ended through approved cancellation.
- `Closed`: ride ended normally.
--- ---
### New Entity — RideCancelRequest ### New Entity - RideCancelRequest
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| Id | Guid | PK | | Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun | | FK_RideId | Guid | FK -> DeliveryRun |
| TypeCode | CancelRequestType | Reason category | | TypeCode | CancelRequestType | Reason category |
| Reason | string | Free text explanation | | Reason | string | Free text explanation |
| RequestedBy | Guid | UserId | | RequestedBy | Guid | UserId |
@@ -89,7 +116,7 @@ public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed
| ApprovedBy | Guid? | UserId of approver | | ApprovedBy | Guid? | UserId of approver |
| ApprovedAt | DateTime? | | | ApprovedAt | DateTime? | |
### New Enum — CancelRequestType ### New Enum - CancelRequestType
```csharp ```csharp
public enum CancelRequestType public enum CancelRequestType
@@ -101,7 +128,7 @@ public enum CancelRequestType
} }
``` ```
### New Entity — DriverComplianceConfig ### New Entity - DriverComplianceConfig
Single-row configuration table. Controls which compliance checks are enforced at ride open time. Single-row configuration table. Controls which compliance checks are enforced at ride open time.
@@ -117,7 +144,7 @@ Managed via a dedicated config endpoint. Only one row ever exists.
--- ---
### New Enum — CancelRequestStatus ### New Enum - CancelRequestStatus
```csharp ```csharp
public enum CancelRequestStatus { Pending, Approved, Rejected } public enum CancelRequestStatus { Pending, Approved, Rejected }
@@ -127,21 +154,22 @@ public enum CancelRequestStatus { Pending, Approved, Rejected }
## State Machine ## State Machine
``` ```text
Draft → Open → Active → Closed Draft -> Open -> Active -> Closed
↘ CancelRequested → Cancelled \-> CancelRequested -> Cancelled
``` ```
| Transition | Trigger | What it does | | Transition | Trigger | What it does |
|---|---|---| |---|---|---|
| Draft → Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. | | Draft -> Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
| Open → Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. | | Open -> Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
| Active → Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. | | Active -> Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
| Active → CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. | | Active -> CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
| CancelRequested → Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. | | CancelRequested -> Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
| CancelRequested → Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. | | CancelRequested -> Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride transitions to Open. No new orders after Open. **Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride
transitions to Open. No new orders after Open.
--- ---
@@ -150,7 +178,7 @@ Draft → Open → Active → Closed
Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse) Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse)
or it is null (warehouse is selected manually at ride creation time). or it is null (warehouse is selected manually at ride creation time).
Loading and unloading warehouse are always the same — stock must return to its source warehouse Loading and unloading warehouse are always the same - stock must return to its source warehouse
before a ride can close or cancel. before a ride can close or cancel.
--- ---
@@ -160,12 +188,12 @@ before a ride can close or cancel.
1. Driver submits cancel request with `TypeCode = VehicleBreakdown`. 1. Driver submits cancel request with `TypeCode = VehicleBreakdown`.
2. Ride status moves to `CancelRequested`. 2. Ride status moves to `CancelRequested`.
3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their 3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their
current vehicle stock levels and the stranded ride's open orders — supervisor decides manually current vehicle stock levels and the stranded ride's open orders. Supervisor decides manually
which ride takes over which orders. which ride takes over which orders.
4. Supervisor approves the cancel request. 4. Supervisor approves the cancel request.
5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse. 5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse.
The system surfaces the information — it does not automatically reassign orders. The system surfaces the information - it does not automatically reassign orders.
--- ---
@@ -179,14 +207,14 @@ Base route: `/api/rides`
| GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. | | GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. |
| GET | `/{id}` | Get ride detail | | GET | `/{id}` | Get ride detail |
| PUT | `/{id}/driver` | Assign or change driver profile | | PUT | `/{id}/driver` | Assign or change driver profile |
| POST | `/{id}/open` | Transition Draft → Open | | POST | `/{id}/open` | Transition Draft -> Open |
| POST | `/{id}/activate` | Transition Open → Active (called by loading event) | | POST | `/{id}/activate` | Transition Open -> Active (called by loading event) |
| POST | `/{id}/close` | Transition Active → Closed (called by reconciliation) | | POST | `/{id}/close` | Transition Active -> Closed (called by reconciliation) |
| POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) | | POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) |
| GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. | | GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. |
| POST | `/cancel-requests/{id}/approve` | Approve cancel request | | POST | `/cancel-requests/{id}/approve` | Approve cancel request |
| POST | `/cancel-requests/{id}/reject` | Reject cancel request | | POST | `/cancel-requests/{id}/reject` | Reject cancel request |
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock + stranded ride's open orders | | GET | `/breakdown-assist/{rideId}` | Lists active rides with stock plus stranded ride's open orders |
### DriverComplianceConfig ### DriverComplianceConfig
@@ -215,37 +243,42 @@ Base route: `/api/driver-profiles`
## Business Rules ## Business Rules
1. A ride cannot transition Draft → Open if `IsActive = false` on the driver profile. If `EnforceLicenseExpiry = true`, also blocks if license is expired. If `EnforceMedicalClearance = true`, also blocks if medical clearance is expired. 1. A ride cannot transition Draft -> Open if `IsActive = false` on the driver profile. If
`EnforceLicenseExpiry = true`, also block if license is expired. If
`EnforceMedicalClearance = true`, also block if medical clearance is expired.
2. A vehicle cannot be on more than one Open or Active ride at a time. 2. A vehicle cannot be on more than one Open or Active ride at a time.
3. Orders can only be assigned to a Delivery ride while it is in Draft status. 3. Orders can only be assigned to a Delivery ride while it is in Draft status.
4. Loading and unloading warehouse must be the same — enforced at close/cancel. 4. Loading and unloading warehouse must be the same - enforced at close/cancel.
5. A CancelRequested ride cannot close or activate until the request is resolved. 5. A CancelRequested ride cannot close or activate until the request is resolved.
6. All cancel requests must have a TypeCode — free text reason alone is not sufficient. 6. All cancel requests must have a TypeCode - free text reason alone is not sufficient.
7. All list endpoints return paginated responses. 7. All list endpoints return paginated responses.
8. A ride uses a single route for now.
--- ---
## Open Questions ## Open Questions
1. **Cancel request type codes** — ✅ Resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other`. 1. Cancel request type codes - resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`,
2. **Breakdown assist endpoint** — ✅ Resolved. System shows info only, supervisor decides manually. No auto-reassignment. `Other`.
3. **Driver compliance** — ✅ Resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive` is always enforced. License and medical checks are optional flags, default off. 2. Breakdown assist endpoint - resolved. System shows info only, supervisor decides manually. No
auto-reassignment.
3. Driver compliance - resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive`
is always enforced. License and medical checks are optional flags, default off.
4. Multi-route ride assignment - deferred for now. Current model stays single-route.
--- ---
## Implementation Notes ## Implementation Notes
**Build order:** **Build order:**
1. ✅ Enums — `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` → added to `Models/Enums.cs` 1. Enums - `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus`
2. ✅ Entities — `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` (new); updated `Vehicle` (+`FK_DefaultWarehouseId`), updated `DeliveryRun` (+`RideType`, `Status`, `FK_DriverProfileId`, `FK_SourceWarehouseId`, `StartedAt`, `ActivatedAt`, `ClosedAt`) 2. Entities - `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest`
3. ✅ `AppDBContext` — new DbSets + Fluent config 3. `AppDBContext` - new DbSets plus Fluent config
4. ✅ Migrations — `RideLifecycle` (schema), `RidePermissions` (10 new permissions seeded across all roles) 4. Migrations - ride lifecycle schema and permissions
5. ✅ DTOs — `DriverProfileDTOs.cs`, `DriverComplianceConfigDTOs.cs`, `RideDTOs.cs` 5. DTOs - driver profile, compliance config, ride
6. ✅ Repositories — `IDriverProfileRepository`, `IDriverComplianceConfigRepository`, `IRideRepository`, `IRideCancelRequestRepository` 6. Repositories - ride and ride cancel request support
7. ✅ Services — `DriverProfileService`, `DriverComplianceConfigService`, `RideService` 7. Services - driver profile, compliance config, ride
8. ✅ Controllers — `DriverProfileController`, `DriverComplianceConfigController`, `RideController` 8. Controllers - driver profile, compliance config, ride
9. ✅ DI registration in `Program.cs` 9. DI registration in `Program.cs`
**Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/` **Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/`
**Pending:** Tests for the three new services.
+58 -77
View File
@@ -1,7 +1,7 @@
# 04 — Loading Event # 04 - Loading Event
**Status**: Planning **Status**: Done
**Depends On**: 03 — Ride Lifecycle **Depends On**: 03 - Ride Lifecycle
--- ---
@@ -9,32 +9,36 @@
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation. Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions
`Open → Active` automatically. This is the first place the stock movement model gets exercised `Open -> Active` automatically.
end-to-end.
This loading flow serves both ride types:
- `Delivery` rides load stock to fulfill orders already linked to the ride.
- `Sale` rides load stock so the sales agent can sell in the field and create sale-linked orders.
--- ---
## Background / Context ## Background / Context
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger — the same math as A vehicle is just a location with `LocationType.Vehicle` in the stock ledger - the same math as a
a warehouse. When loading is confirmed, two stock movements are created per item: warehouse. When loading is confirmed, two stock movements are created per item:
- **Debit** source warehouse: `-qty` at `LocationType.Warehouse`
- **Credit** vehicle: `+qty` at `LocationType.Vehicle`
From this point the vehicle carries live inventory that decreases with every sale or delivery - Debit source warehouse: `-qty` at `LocationType.Warehouse`
(Steps 05/06) and must return to zero before the ride can close (Step 07). - Credit vehicle: `+qty` at `LocationType.Vehicle`
From this point the vehicle carries live inventory that decreases with every sale or delivery and
must return to zero before the ride can close.
--- ---
## Data Model Changes ## Data Model Changes
### New Entity — LoadingSheet ### New Entity - LoadingSheet
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| Id | Guid | PK | | Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun | | FK_RideId | Guid | FK -> DeliveryRun |
| FK_SourceWarehouseId | Guid | FK → Warehouse | | FK_SourceWarehouseId | Guid | FK -> Warehouse |
| Status | LoadingSheetStatus | State machine below | | Status | LoadingSheetStatus | State machine below |
| DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch | | DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch |
| DispatchConfirmedBy | Guid? | UserId of warehouse manager | | DispatchConfirmedBy | Guid? | UserId of warehouse manager |
@@ -42,18 +46,18 @@ From this point the vehicle carries live inventory that decreases with every sal
| ReceiptConfirmedBy | Guid? | UserId of driver | | ReceiptConfirmedBy | Guid? | UserId of driver |
| CreatedAt | DateTime | | | CreatedAt | DateTime | |
### New Entity — LoadingSheetItem ### New Entity - LoadingSheetItem
| Column | Type | Notes | | Column | Type | Notes |
|---|---|---| |---|---|---|
| Id | Guid | PK | | Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet | | FK_LoadingSheetId | Guid | FK -> LoadingSheet |
| FK_ItemId | Guid | FK → Item | | FK_ItemId | Guid | FK -> Item |
| QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch | | QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch |
| QuantityReceived | decimal? | Set by driver before confirm-receipt | | QuantityReceived | decimal? | Set by driver before confirm-receipt |
| QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy | | QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy |
### New Enum — LoadingSheetStatus ### New Enum - LoadingSheetStatus
```csharp ```csharp
public enum LoadingSheetStatus public enum LoadingSheetStatus
@@ -66,23 +70,23 @@ public enum LoadingSheetStatus
} }
``` ```
No changes to existing entities. Vehicle capacity check is deferred to Step 13 (Fleet Management). No changes to existing entities. Vehicle capacity check is deferred to Step 13.
--- ---
## State Machine ## State Machine
``` ```text
Pending → DispatchConfirmed → ReceiptConfirmed (all lines match) Pending -> DispatchConfirmed -> ReceiptConfirmed
↘ DiscrepancyFlagged → Resolved \-> DiscrepancyFlagged -> Resolved
``` ```
| Transition | Trigger | What it does | | Transition | Trigger | What it does |
|---|---|---| |---|---|---|
| Pending → DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. | | Pending -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
| DispatchConfirmed → ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open → Active`. | | DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open -> Active`. |
| DispatchConfirmed → DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. | | DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
| DiscrepancyFlagged → Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open → Active`. | | DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open -> Active`. |
--- ---
@@ -90,10 +94,11 @@ Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
| Ride Type | How items are added to the sheet | | Ride Type | How items are added to the sheet |
|---|---| |---|---|
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride | | Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride's route |
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` (no orders to derive from) | | Sale | Manually entered by warehouse manager via `PUT /{id}/items` based on expected field selling demand |
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before confirming dispatch. `PUT /{id}/items` is still available to override if needed. For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before
confirming dispatch. `PUT /{id}/items` is still available to override if needed.
--- ---
@@ -101,12 +106,13 @@ For Delivery rides the warehouse manager reviews the pre-populated quantities an
Created at `ReceiptConfirmed` or `Resolved`. One pair per line item: Created at `ReceiptConfirmed` or `Resolved`. One pair per line item:
- **Debit** warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId` - Debit warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- **Credit** vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId` - Credit vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a discrepancy was resolved. `QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a
discrepancy was resolved.
Movement `Type` = `Load` (new `StockMovementType` value to add to enums). Movement `Type` = `Load`.
--- ---
@@ -119,23 +125,25 @@ Base route: `/api/loading-sheets`
| POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. | | POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. |
| GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. | | GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail with all line items | | GET | `/{id}` | Any | Get sheet detail with all line items |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities (required for Sale rides, optional for Delivery) | | PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending → DispatchConfirmed | | POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending -> DispatchConfirmed |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line | | PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line |
| POST | `/{id}/confirm-receipt` | Driver | → ReceiptConfirmed or DiscrepancyFlagged | | POST | `/{id}/confirm-receipt` | Driver | -> ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line → Resolved + ride activation | | POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line -> Resolved plus ride activation |
--- ---
## Business Rules ## Business Rules
1. A loading sheet can only be created for a ride in `Open` status. 1. A loading sheet can only be created for a ride in `Open` status.
2. A ride can only have one active loading sheet (not `Resolved` or `ReceiptConfirmed`) at a time. 2. A ride can only have one active loading sheet at a time.
3. `QuantityDispatched != QuantityReceived` on any line → `DiscrepancyFlagged`. No partial activation. 3. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial activation.
4. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch. 4. Stock movements are created only once - at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. No bulk "accept driver" or "accept warehouse" shortcut. 5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value.
6. All list endpoints return paginated responses. 6. All list endpoints return paginated responses.
7. Vehicle capacity check is not enforced in this step — deferred to Step 13. 7. Vehicle capacity check is not enforced in this step.
8. Immediate handover from a Sale ride does not remove the need for an `Order`; every sale still
creates an order, and that order is fulfilled immediately when stock is handed over at sale time.
--- ---
@@ -145,54 +153,27 @@ Base route: `/api/loading-sheets`
|---|---| |---|---|
| `loading.view` | All roles | | `loading.view` | All roles |
| `loading.dispatch` | Warehouse manager and above | | `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver (AreaManager and above for testing) | | `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above | | `loading.resolve` | Supervisor and above |
--- ---
## Fraud / Control Considerations ## Fraud / Control Considerations
- Discrepancies are immediately visible and block activation — they cannot be silently absorbed. - Discrepancies are immediately visible and block activation.
- `QuantityResolved` is permanently stored per line for audit purposes, even after the sheet is resolved. - `QuantityResolved` is permanently stored per line for audit purposes.
- See [09_fraud_controls.md](./09_fraud_controls.md) for the audit trail layer that will sit on top of this.
--- ---
## Open Questions ## Open Questions
- ✅ Vehicle capacity check — deferred to Step 13 (Fleet Management). 1. Vehicle capacity check - deferred to Step 13.
- ✅ Discrepancy resolution — supervisor enters `QuantityResolved` per line item. 2. Discrepancy resolution - supervisor enters `QuantityResolved` per line item.
- ✅ Ride activation trigger — `confirm-receipt` and `resolve` auto-trigger `Open → Active` internally. 3. Ride activation trigger - `confirm-receipt` and `resolve` auto-trigger `Open -> Active`.
- ✅ Item population — Delivery rides auto-populated from orders; Sale rides manually entered. 4. Delivery item population - from assigned orders on the ride's route.
--- ---
## Implementation Notes ## Implementation Notes
**Build order:** Build after the ride model and before full delivery/sale execution.
1. Enum — add `LoadingSheetStatus` to `Models/Enums.cs`; add `Load` to `StockMovementType`
2. Entities — `LoadingSheet`, `LoadingSheetItem` (new)
3. `AppDBContext` — new DbSets + Fluent config
4. Migration — `LoadingEvent` (schema) + `LoadingPermissions` (4 new permissions seeded)
5. DTOs — `LoadingSheetDTOs.cs`
6. Repository — `ILoadingSheetRepository`, `LoadingSheetRepository`
7. Service — `LoadingSheetService`
8. Controller — `LoadingSheetController`
9. DI registration in `Program.cs`
**Feature folder:** `Features/LoadingEvent/`
**What was built:**
1. ✅ Enums — added `LoadingSheetStatus` and `Load` to `MovementType` in `Models/Enums.cs`
2. ✅ Entities — `LoadingSheet`, `LoadingSheetItem` created in `Models/`
3. ✅ `AppDBContext` — `LoadingSheets`, `LoadingSheetItems` DbSets added; Fluent config for FK restrictions and ride/warehouse relationships
4. ✅ DTOs — `LoadingSheetDTOs.cs` in `Features/LoadingEvent/`
5. ✅ Repository — `ILoadingSheetRepository`, `LoadingSheetRepository` in `Features/LoadingEvent/`
6. ✅ Service — `LoadingSheetService` in `Features/LoadingEvent/`
7. ✅ Controller — `LoadingSheetController` in `Features/LoadingEvent/`
8. ✅ DI registration in `Program.cs`
9. ⚠️ Permissions added to seed (`loading.view`, `loading.dispatch`, `loading.receive`, `loading.resolve`) — **authorization layer is being replaced, skip this**
**Pending (run manually):**
- `dotnet ef migrations add LoadingEvent` — blocked by a pre-existing duplicate Permission seed ID in `AppDBContextModelSnapshot.cs` (ID `20000000-0000-0000-0000-000000000040` is assigned to both `categories.delete` and `driver_profiles.create`). This is a snapshot consistency issue from a previous session and needs to be resolved before migration generation will succeed.
- `dotnet ef database update` after migration is generated.
+107 -38
View File
@@ -1,79 +1,148 @@
# 05 — Delivery Ride Execution # 05 — Delivery Ride Execution
**Status**: Not Started **Status**: Done
**Depends On**: 04 — Loading Event **Depends On**: 04 — Loading Event
--- ---
## Goal ## Goal
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half Fulfill pending orders against an active Delivery ride. The driver visits each store on the route,
of [mainPlan.md §1](./mainPlan.md#1-order-management): delivers the ordered items, gets customer confirmation, and the system creates the stock movement,
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist updates the order, and completes the sale.
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
--- ---
## Background / Context ## Background / Context
Today `Order` has no status and no link to a ride. This step is where the order lifecycle - A Delivery ride goes `Active` after the loading event is confirmed.
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the - Orders are linked to a ride implicitly through route: `Ride → Route → Stores → Pending Orders`.
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)). - `QuantityFulfilled` on `OrderItem` tracks what was actually delivered (added in pre-planning).
- No separate backorder entity — partially fulfilled orders stay `PartiallyFulfilled` and get picked
up by the next delivery ride on the same route.
- Customer confirmation in v1 is a boolean flag (`CustomerConfirmed`). Full signature/OTP deferred
to Step 09 (Fraud Controls).
--- ---
## Data Model Changes ## Data Model Changes
### Delivery Task (per order on ride) ### New Entity — DeliveryTask
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`, Extends `Template` (adds `IsActive`, `CreatedAt` — consistent with all other entities).
`Failed`).
### Delivery Outcome | Column | Type | Notes |
|---|---|---|
| Id | Guid | PK (from Template) |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_OrderId | Guid | FK → Order |
| Sequence | int | Visit order on the route |
| Status | DeliveryTaskStatus | State machine below |
| CustomerConfirmed | bool | Set to true at confirm time |
| ConfirmedAt | DateTime? | Timestamp of customer confirmation |
| Note | string? | Driver note on failure or partial delivery |
| IsActive | bool | From Template (soft delete) |
| CreatedAt | DateTime | From Template |
Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder` ### New Enum — DeliveryTaskStatus
for the shortfall.
### Customer Confirmation ```csharp
public enum DeliveryTaskStatus
{
Pending,
Delivered,
PartiallyDelivered,
Failed
}
```
Signature or OTP captured against the delivery task before it can be marked complete. ### No new columns on OrderItem
`QuantityFulfilled` already added in pre-planning. No further model changes needed.
---
## State Machine
```
Pending → Delivered (all lines fully delivered + customer confirmed)
Pending → PartiallyDelivered (some lines short + customer confirmed)
Pending → Failed (driver could not deliver, no stock movement)
```
---
## Stock Movements
Created on `Delivered` or `PartiallyDelivered` after customer confirmation. One movement per item:
- **Debit** vehicle: `-QuantityFulfilled` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
- `VehicleId` is resolved from `DeliveryTask.FK_RideId → DeliveryRun.FK_VehicleId`
- `MovementType.Sale`
- `ReferenceId = OrderId`
No movement on `Failed`.
---
## Order and Sale Status Transitions (triggered internally)
| Condition | Order | Sale |
|---|---|---|
| All OrderItem lines: QuantityFulfilled == Quantity | → Fulfilled | → Completed |
| Any OrderItem line: QuantityFulfilled < Quantity | → PartiallyFulfilled | stays Pending |
| Task Failed | stays Pending | stays Pending |
--- ---
## API Endpoints ## API Endpoints
| Function reference | Endpoint intent | Base routes: `/api/rides/{rideId}/tasks` and `/api/delivery-tasks`
|---|---|
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` | | Method | Route | Who | Description |
| `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/generate` | Driver/System | Auto-create tasks from all Pending/PartiallyFulfilled orders for stores on ride's route |
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP | | GET | `/api/rides/{rideId}/tasks` | Driver | List all tasks for this ride with current status |
| GET | `/api/delivery-tasks/{id}` | Driver | Task detail with order items and quantities |
| PUT | `/api/delivery-tasks/{id}/quantities` | Driver | Set QuantityFulfilled per order item before confirming |
| POST | `/api/delivery-tasks/{id}/confirm` | Driver | Customer confirmed — creates stock movements, transitions order/sale |
| POST | `/api/delivery-tasks/{id}/fail` | Driver | Mark task as failed with a note |
--- ---
## Business Rules ## Business Rules
- A delivery task cannot be marked `Delivered` without a customer confirmation. 1. Delivery tasks can only be created for a ride in `Active` status and of type `Delivery`.
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the 2. One task per order per ride — cannot create a second task for the same order on the same ride.
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape). 3. `QuantityFulfilled` must be set on all order items before calling confirm.
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle 4. `QuantityFulfilled` cannot exceed `Quantity` on any line.
location per [02_stock_ledger.md](./02_stock_ledger.md). 5. `CustomerConfirmed` must be true before the task can move to `Delivered` or `PartiallyDelivered`.
6. Stock movement is created only once — at confirm. Never on fail.
7. A failed task does not change the order or sale status — the order stays available for the next ride.
8. Partial delivery: the unfulfilled quantity (`Quantity - QuantityFulfilled`) remains on the same
order. The next delivery ride on the same route will pick it up automatically via `generate`.
--- ---
## Fraud / Control Considerations ## Implementation Build Order
- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not 1. Enum — add `DeliveryTaskStatus` to `Models/Enums.cs`
editable after submission). 2. Entity — `DeliveryTask` in `Models/`
3. `AppDBContext` — new DbSet + Fluent config (Restrict on Ride, Order FKs)
4. Migration
5. DTOs — `DeliveryTaskDTOs.cs` in `Features/DeliveryRide/`
6. Repository — `IDeliveryTaskRepository`, `DeliveryTaskRepository`
7. Service — `DeliveryTaskService`
8. Controller — `DeliveryTaskController`
9. DI registration in `Program.cs`
**Feature folder:** `Features/DeliveryRide/`
--- ---
## Open Questions ## Open Questions — Resolved
- Signature capture (image) or OTP (SMS code) — one, or both, in v1? - ✅ Customer confirmation — boolean flag in v1, full signature/OTP in Step 09
- ✅ Backorder — no separate entity; same order stays PartiallyFulfilled for next ride
--- - ✅ Task generation — on-demand via `generate` endpoint, not automatic on ride activation
- ✅ Failed task — order stays Pending, available for next ride, no stock movement
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+95 -43
View File
@@ -1,80 +1,132 @@
# 06 — Sale Ride Execution # 06 — Sale Ride Execution
**Status**: Not Started **Status**: Done
**Depends On**: 04 — Loading Event **Depends On**: 04 — Loading Event
--- ---
## Goal ## Goal
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is Allow a sales agent on an active Sale ride to create sales in the field. Every sale creates an
generated immediately, and payment is captured at point of sale. Covers `createInvoice`, order. Immediate handover deducts vehicle stock and closes the order immediately. Non-immediate
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting), handover leaves the order pending for a future delivery ride.
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
(§11) if promotions are live by the time this ships.
--- ---
## Background / Context ## Background / Context
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row — - `SaleService.CreateAsync` already exists and handles both `FulfillImmediately` paths.
the field invoice *is* the order, created and settled in one step. - What Step 06 adds: vehicle stock validation and deduction on immediate handover, and the
Payment recording endpoint.
- `Sale.PaymentStatus` and the `Payment` model were added in pre-planning.
- Pricing is already resolved via `PriceListService` inside `SaleService`.
--- ---
## Data Model Changes ## Data Model Changes
### Field Invoice No new entities. Everything needed was added in pre-planning:
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`, | What | Where | Status |
`OnCredit`). |---|---|---|
| `Payment` entity | `Models/Payment.cs` | ✅ Done |
| `PaymentStatus` enum | `Models/Enums.cs` | ✅ Done |
| `PaymentMethod` enum | `Models/Enums.cs` | ✅ Done |
| `Sale.PaymentStatus` | `Models/Sale.cs` | ✅ Done |
| `OrderItem.QuantityFulfilled` | `Models/OrderItem.cs` | ✅ Done |
### Field Invoice Line Item Migration needed for: `Payment` table, `Sale.PaymentStatus` column, `OrderItem.QuantityFulfilled` column.
`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 ## Changes to Existing SaleService
| Function reference | Endpoint intent | `SaleService.CreateAsync` needs two additions when `FulfillImmediately = true` and `RideId` is set:
|---|---|
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here | 1. **Stock check** — for each item, verify vehicle has enough stock (`StockBalance` at
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` | `LocationType.Vehicle` with `LocationId = VehicleId`). Reject the whole sale if any item is short.
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits | 2. **Stock deduction** — debit vehicle balance per item, create one `StockMovement` per item:
- `MovementType.Sale`
- `FromLocationId = VehicleId`, `FromLocationType = Vehicle`
- `ReferenceId = OrderId`
`VehicleId` is resolved from `Sale.FK_RideId → DeliveryRun.FK_VehicleId`.
**SaleService constructor additions required:**
- `IStockRepository` — for stock balance check and debit
- `IRideRepository` — to look up the ride and resolve `FK_VehicleId`
When `FulfillImmediately = false`, no stock movement — order stays Pending for a delivery ride.
---
## New Feature — Payment
**Feature folder:** `Features/Payment/`
### API Endpoints
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
### Payment Creation Logic
On `POST /api/sales/{id}/payments`:
1. Validate sale exists and is not `Cancelled`.
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
- `sum == 0` → `Pending`
- `sum < Sale.TotalAmount` → `PartiallyPaid`
- `sum >= Sale.TotalAmount` → `Paid`
4. Update `Sale.PaymentStatus` in the same transaction.
### DTOs
```csharp
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(Guid Id, Guid SaleId, Guid StoreId, decimal Amount,
PaymentMethod Method, DateTime CollectedAt, Guid CollectedBy);
```
--- ---
## Business Rules ## Business Rules
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against 1. Driver cannot sell more than vehicle stock for immediate handover — entire sale rejected if any
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the item is short (no partial sale creation).
invoice is finalized. 2. Every sale creates an order, even when all items are handed over immediately.
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location, 3. `FulfillImmediately = true`:
immediately (not batched at ride close). - Vehicle stock debited immediately per item.
- Discount applied per line must not exceed the agent's role-level limit (§8). - Order → `Fulfilled`, Sale → `Completed` at creation time (already in SaleService).
- `QuantityFulfilled = Quantity` on all order items (already in SaleService).
4. `FulfillImmediately = false`:
- No stock movement at sale time.
- Order → `Pending`, Sale → `Pending` at creation time.
5. Payment can be recorded at any time — sale does not need to be `Completed` first.
6. Overpayment is allowed (`sum > TotalAmount`) — `PaymentStatus` stays `Paid`.
7. A `Cancelled` sale cannot receive new payments.
--- ---
## Fraud / Control Considerations ## Implementation Build Order
- Cash collected must reconcile against invoiced totals at ride close — see 1. Migration — `Payment` table, `Sale.PaymentStatus`, `OrderItem.QuantityFulfilled`
[07_ride_reconciliation.md](./07_ride_reconciliation.md). 2. Extend `SaleService.CreateAsync` — stock check + stock movement on `FulfillImmediately`
3. DTOs — `PaymentDTOs.cs` in `Features/Payment/`
4. Repository — `IPaymentRepository`, `PaymentRepository`
5. Service — `PaymentService`
6. Controller — `PaymentController`
7. DI registration in `Program.cs`
**Feature folder:** `Features/Payment/` (new), `Features/Sale/` (extended)
--- ---
## Open Questions ## Open Questions — Resolved
- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one - ✅ Payment timing — can be recorded at any time, not locked to sale completion
sequence? - ✅ Overpayment — allowed, status stays Paid
- ✅ Stock check scope — entire sale rejected if any item is short, no partial creation
--- - ✅ Non-immediate handover — no stock movement at sale time, order picked up by delivery ride
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+9
View File
@@ -122,6 +122,15 @@ GoodsReceiptItem
``` ```
### PurchaseReturn ### PurchaseReturn
> **Not the same as `SaleReturn`** ([18_returns_claims.md](./18_returns_claims.md)), despite the
> similar name. `PurchaseReturn` moves goods **Distributor → Supplier** (debits the receiving
> warehouse); `SaleReturn` moves goods **Shop → Distributor** (credits a delivery vehicle). Also
> distinct from `SupplierClaim` ([18_returns_claims.md](./18_returns_claims.md)), which is for
> supplier disputes that don't involve physically returning goods (short-shipment, price dispute)
> and — unlike this entity — requires approval. See that doc's "Four distinct entities" table for
> the full comparison.
``` ```
PurchaseReturn PurchaseReturn
├── Id (Guid) — PK, from Template ├── Id (Guid) — PK, from Template
+424 -29
View File
@@ -1,74 +1,469 @@
# 18 — Returns & Claims Management # 18 — Returns & Claims Management
**Status**: Not Started **Status**: Done — migrations applied to the database. Inspection/classification for customer
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution returns is deferred (see Open Questions).
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for
`SupplierClaim`)
--- ---
## Goal ## Goal
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md) a sales rep records a return during a store visit and a claim workflow against a Regional Agency,
both reference this step's credit note and approval shapes — this is where those are actually plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve
defined. physically returning goods.
### Four distinct entities — don't conflate them
`SaleReturn` and `PurchaseReturn` are both named "...Return" but move goods in opposite
directions, and neither is the same as the two `Claim` entities, which never move stock at all:
| Entity | Direction | Where it lives | Approval? | Stock effect |
|---|---|---|---|---|
| `SaleReturn` | Shop → Distributor | This step (`Features/Returns`) | No — settles immediately | Credits the ride's **vehicle** |
| `Claim` | vs. Regional Agency | This step (`Features/Claims`) | Settle only (`claims.settle`) | None |
| `PurchaseReturn` | Distributor → Supplier | [15_procurement.md](./15_procurement.md) (`Features/PurchaseReturn`) | No — settles immediately | Debits the receiving **warehouse** |
| `SupplierClaim` | vs. Supplier | This step (`Features/SupplierClaims`) | Yes (`suppliers.claims.approve`) | None — financial/administrative record only |
`PurchaseReturn` is for physically shipping goods back to a supplier; `SupplierClaim` is for
disputes that don't (or can't yet) involve moving goods back — a short-shipment, damage, or a
price dispute.
--- ---
## Background / Context ## Background / Context
No return, credit note, or claim entity exists today. `createBackorder` (§1, No return, credit note, or claim entity existed before this step. `createBackorder` (§1,
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands. [05_delivery_ride.md](./05_delivery_ride.md)) needed a credit note target once this step landed.
The original spec (mainPlan §12) framed customer returns as an async, supervisor-approved
workflow — `createReturnRequest` → `approveReturn` → stock credited only on approval. **That model
was dropped during planning.** In practice a sales rep records a return on the spot, during the
same store visit as a new order, and it must settle immediately (stock back in the vehicle, the
new order's amount adjusted) — there is no point in the field workflow where a return sits
"pending" a remote supervisor's sign-off. See Business Rules for what this changes.
The existing `PurchaseReturn` ([15_procurement.md](./15_procurement.md)) only covers physically
returning received goods to a supplier and debiting stock — it has no place for a claim that
doesn't (or can't yet) involve moving goods back: a short-shipment (500 ordered, 480 received),
goods damaged in transit, or a price dispute. `SupplierClaim` was added alongside `PurchaseReturn`
for this, not as a rework of it.
### Connections to existing modules
- **Order Management**: a `SaleReturn` is always filed against a specific open `Order`
(`Features/Order`) and immediately nets its effect onto that order's `TotalAmount` /
`OrderItem.Quantity` — no `Invoice` entity exists yet (Finance, step 19, not built), so there is
nothing else for a credit note to net against.
- **Stock Ledger** ([02_stock_ledger.md](./02_stock_ledger.md)): `SaleReturn` credits
`StockBalance` at the active ride's vehicle (`MovementType.CustomerReturn`), the same enum value
`StockService.RecordCustomerReturnAsync` already defined but that no sales-side flow was wiring
up until this step. `SupplierClaim` writes no stock movement at all (see Business Rules).
- **Ride Execution** ([05_delivery_ride.md](./05_delivery_ride.md)): a `SaleReturn` can only be
recorded against an order created during an active ride (`Sale.FK_RideId` must be set), since
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`).
- **Region Management** ([10_region_management.md](./10_region_management.md)): there is no
separate Agency entity in the codebase — "Regional Agency" is a `Role`
(`Data/Seed/RoleAndPermissionSeed.cs`), so `Claim` references the `User` holding that role.
- **Procurement** ([15_procurement.md](./15_procurement.md)): `SupplierClaim.FK_PurchaseOrderId`
is required, `FK_GoodsReceiptId` optional; a short-shipment is already excluded from stock at
receipt time (`GoodsReceiptItem.RejectedQuantity`), so `SupplierClaim` never needs to touch
`StockBalance` itself.
--- ---
## Data Model Changes ## Data Model Changes
### Return Request Six new tables.
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`, ### SaleReturn
`Rejected`), `RequestedByUserId`. ```
SaleReturn
├── Id (Guid) — PK, from Template
├── FK_OrderId (Guid) — required, the order this return is filed against
├── Mode (enum) — CreditDeduction, ItemExchange — chosen by the rep per return
├── TotalCreditAmount (decimal)— sum(Quantity * UnitPrice) for CreditDeduction mode; 0 for ItemExchange
├── CreatedByUserId (Guid) — required, the rep who recorded it
├── IsActive, CreatedAt — from Template
```
### Credit Note ### SaleReturnItem
```
SaleReturnItem
├── Id (Guid) — PK, from Template
├── FK_SaleReturnId (Guid) — required
├── FK_ItemId (Guid) — required
├── Quantity (decimal) — returned quantity, credited back to the ride's vehicle
├── ReasonCode (enum) — Expired, Damaged, OrderError, Other
├── UnitPrice (decimal) — snapshot at return time (from the matching OrderItem if one
│ exists on this order, else Item.SalePrice)
```
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`. ### CreditNote
```
CreditNote
├── Id (Guid) — PK, from Template
├── FK_SaleReturnId (Guid) — required
├── Amount (decimal) — amount netted off the order's TotalAmount
├── IssuedAt (DateTime)
├── IsActive, CreatedAt — from Template
```
Audit-trail only — written for `CreditDeduction`-mode returns, not itself an approval gate or an
invoice-linked document (no `Invoice` entity exists yet; see Background/Context).
### Claim ### Claim
```
Claim
├── Id (Guid) — PK, from Template
├── FK_AgencyUserId (Guid) — required, the User holding the RegionalAgency role this claim is against
├── Details (string) — required
├── Status (enum) — Open, Settled
├── SettlementAmount (decimal?)— set once settled
├── SettledByUserId (Guid?), SettledAt (DateTime?)
├── IsActive, CreatedAt — from Template
```
Independent of `SaleReturn`/stock — its own open/settle workflow.
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled). ### SupplierClaim
```
SupplierClaim
├── Id (Guid) — PK, from Template
├── FK_PurchaseOrderId (Guid) — required
├── FK_GoodsReceiptId (Guid?) — optional; set when the claim stems from a specific receipt
│ (e.g. short/damaged at receipt)
├── Details (string) — description of the issue
├── EvidenceUrl (string?) — link/path to a photo or document; no file-upload storage
│ exists yet, so this is a plain string field for now
├── Status (enum) — Open, Approved, Rejected
├── ResolutionType (enum?) — Replacement, Credit, Refund, PriceAdjustment — set on approve
├── ResolutionAmount (decimal?) — for Credit / Refund / PriceAdjustment
├── ResolutionNotes (string?)
├── RaisedByUserId (Guid)
├── ResolvedByUserId (Guid?), ResolvedAt (DateTime?)
├── IsActive, CreatedAt — from Template
```
### SupplierClaimItem
```
SupplierClaimItem
├── Id (Guid) — PK, from Template
├── FK_SupplierClaimId (Guid) — required
├── FK_ItemId (Guid) — required
├── Quantity (decimal)
├── IssueType (enum) — Damaged, Short, Expired, WrongItem, Other
```
---
## Migrations
Two migrations, applied:
1. **AddReturnsAndClaims** (`20260825073453_AddReturnsAndClaims`) — created `SaleReturns`,
`SaleReturnItems`, `CreditNotes`, `Claims`, with FKs to `Orders`/`Items`/`Users` and indexes on
every FK column.
2. **AddSupplierClaims** (`20260825084218_AddSupplierClaims`) — created `SupplierClaims`,
`SupplierClaimItems`, with FKs to `PurchaseOrders`/`GoodsReceipts`/`Items`/`Users` and indexes on
every FK column.
Both applied via `dotnet ef database update`.
--- ---
## API Endpoints ## API Endpoints
| Function reference | Endpoint intent | > Return routes use bare `[Authorize]` — matches the Supplier/PriceList/Procurement convention (no
> per-action policy). `claims.settle` and `suppliers.claims.approve` are the two enforced policies,
> since those are the two steps with real approval weight. See Permissions to Add to Seed.
### Returns (`Features/Returns`)
| Function reference | Endpoint |
|---|---| |---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` | | `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles |
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` | | `getSaleReturn(returnId)` | `GET /api/returns/{id}` |
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval | | `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` |
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` | ### Claims (`Features/Claims`)
| Function reference | Endpoint |
|---|---|
| `createClaim(agencyUserId, details)` | `POST /api/claims` |
| `getClaim(claimId)` | `GET /api/claims/{id}` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` — `[Authorize(Policy = "claims.settle")]` |
### Supplier Claims (`Features/SupplierClaims`)
| Function reference | Endpoint |
|---|---|
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | `POST /api/purchase-orders/{id}/claims` |
| `getSupplierClaim(claimId)` | `GET /api/supplier-claims/{id}` |
| `listSupplierClaims(poId)` | `GET /api/purchase-orders/{id}/claims` |
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | `POST /api/supplier-claims/{id}/approve` — `[Authorize(Policy = "suppliers.claims.approve")]` |
| `rejectSupplierClaim(claimId, notes?)` | `POST /api/supplier-claims/{id}/reject` — `[Authorize(Policy = "suppliers.claims.approve")]` |
`approve`/`reject` combine the "inspect the claim" and "decide the outcome" steps into one call —
unlike the customer-side flow there's no separate physical-inspection step to wait on here, since
the evidence (photos/notes, and `GoodsReceiptItem.RejectedQuantity` if applicable) is already
attached at claim-creation time.
---
## Request / Response Shapes
### POST `/api/orders/{id}/returns`
```json
{
"mode": "CreditDeduction",
"items": [{ "itemId": "...", "quantity": 5, "reasonCode": "Expired" }]
}
```
`mode` is `CreditDeduction` or `ItemExchange`, `reasonCode` is `Expired` | `Damaged` |
`OrderError` | `Other`.
### POST `/api/claims`
```json
{ "agencyUserId": "...", "details": "Short-shipped 20 units on last consignment" }
```
### POST `/api/claims/{id}/settle`
```json
{ "settlementAmount": 1500.00 }
```
### POST `/api/purchase-orders/{id}/claims`
```json
{
"goodsReceiptId": "...",
"details": "20 units arrived damaged, 480 of 500 received",
"evidenceUrl": "https://...",
"items": [{ "itemId": "...", "quantity": 20, "issueType": "Damaged" }]
}
```
`goodsReceiptId` and `evidenceUrl` are optional. `issueType` is `Damaged` | `Short` | `Expired` |
`WrongItem` | `Other`.
### POST `/api/supplier-claims/{id}/approve`
```json
{ "resolutionType": "Credit", "resolutionAmount": 850.00, "resolutionNotes": "Agreed with supplier" }
```
`resolutionType` is `Replacement` | `Credit` | `Refund` | `PriceAdjustment`. `resolutionAmount` is
required unless `resolutionType` is `Replacement`.
### POST `/api/supplier-claims/{id}/reject`
```json
{ "resolutionNotes": "Discrepancy within accepted tolerance" }
```
--- ---
## Business Rules ## Business Rules
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule 1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This
already stated in [09_fraud_controls.md](./09_fraud_controls.md). deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly Background/Context) since a rep records the return on the spot during a live store visit.
the `ReturnWithoutSale` anomaly flagged automatically in 2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or
[09_fraud_controls.md](./09_fraud_controls.md). `PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since
- Approving a return creates a `Return` stock movement crediting the relevant location, per stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`,
[02_stock_ledger.md](./02_stock_ledger.md). `MovementType.CustomerReturn`).
3. `createSaleReturn` rejects (409 Conflict) any line item the store has never had on **any** of
its past orders — checked across the store's full order history, not just the order the return
is filed against. This lets an expired or damaged item bought weeks ago still be returned; it
only blocks an item that store never bought at all — the real fraud case originally scoped as
`ReturnWithoutSale` in [09_fraud_controls.md](./09_fraud_controls.md), which is not otherwise
built.
4. `Mode = CreditDeduction`: the returned lines' value (`Quantity * UnitPrice`) is subtracted from
`Order.TotalAmount` (floored at 0), and a `CreditNote` audit row is written.
5. `Mode = ItemExchange`: the returned quantity is subtracted directly from the matching
`OrderItem.Quantity` on the same order (a physical swap of the same item), `TotalAmount`
adjusted by the offset amount. No `CreditNote` is written. A returned item with no matching line
on the order is credited to stock but does not offset anything on the order.
6. Stock credit, the `SaleReturn`/`SaleReturnItem` insert, and the order-total/credit-note update
all run inside one `IUnitOfWork` transaction — a failure anywhere rolls back the whole return.
7. `settleClaim` can only run once per claim — rejects (409) if already `Settled`. Claims never
touch `StockBalance` or any `Order`.
8. `createSupplierClaim` requires the referenced `PurchaseOrder` to exist and be active;
`FK_GoodsReceiptId`, if given, must belong to that PO (rejects with 409 otherwise).
9. Only `Open` supplier claims can be approved or rejected; both are terminal (no re-opening) —
enforced by `ApproveAsync`/`RejectAsync` throwing if `Status != Open`.
10. `approveSupplierClaim` requires `ResolutionType`; `ResolutionAmount` is required for `Credit`,
`Refund`, and `PriceAdjustment` (409 if missing), and is discarded (`null`) for `Replacement`
even if supplied.
11. Supplier claim approval/rejection is a real sign-off step (unlike the customer-side
`SaleReturn`, which settles immediately) — gated behind
`[Authorize(Policy = "suppliers.claims.approve")]`, following the Ride cancel-request pattern
(`RideController.cs:120-146`).
12. No automatic `StockBalance`/`StockMovement` write from any `SupplierClaim` action.
`ResolutionType = Replacement` records that new stock is expected; the actual stock credit
happens through an ordinary follow-up `GoodsReceipt` once it arrives (see Open Questions).
`Credit`/`Refund`/`PriceAdjustment` have no `Invoice`/Finance ledger to post against yet
(step 19, not built) — `ResolutionAmount` is captured as an audit field only, the same
limitation `CreditNote` has on the customer side.
---
## Permissions to Add to Seed
**Status: `claims.settle` and `suppliers.claims.approve` enforced; return-creation routes
intentionally left as bare `[Authorize]`**, matching the Supplier/PriceList/Procurement convention
(steps 14/15) rather than introducing enforcement piecemeal. Both enforced policies use the same
dynamic policy-provider pattern as `rides.cancel.approve` (`PermissionPolicyProvider`) — no
explicit policy registration needed, just a seeded `Permission` row and a `RolePermission` grant.
No role assignments seeded yet; `suppliers.claims.approve` likely AreaManager (30) and above,
matching the Supplier/Procurement tier.
--- ---
## Open Questions ## Open Questions
- Should return-creation routes eventually get a real permission policy (`returns.create`) once
the Supplier/PriceList/Procurement group does, or do they stay bare `[Authorize]` indefinitely
since a rep needs to record a return without waiting on a scoped grant?
- `ItemExchange` mode clamps the offset to `min(returnedQty, orderItem.Quantity)` — if a rep
returns more of an item than is on the current order, the excess is credited to stock but not
reflected anywhere on the order. Worth a `CreditNote`-style fallback for the excess once this
edge case actually comes up.
- No `Invoice` entity exists yet (Finance, step 19) — `CreditNote` and `SupplierClaim`'s
`ResolutionAmount` are standalone audit fields for now, not linked to any invoice/ledger
document. Revisit once Finance & Accounting is built.
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim - 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? also be raised against an internal warehouse/supplier discrepancy? Currently only
`FK_AgencyUserId` (a User with the RegionalAgency role) is supported.
- `SupplierClaim.ResolutionType = Replacement` has no link back to the eventual replacement
`GoodsReceipt` — a nullable `FK_SupplierClaimId` on `GoodsReceipt` would close that loop but
isn't built yet.
- **Inspection & Classification (deferred)**: the fuller process — inspect returned goods,
classify each line as Sellable / Damaged / Expired / Quarantine / Disposal, route sellable stock
back to available inventory while damaged/expired stock moves to a separate stock category, and
only then decide refund vs. replacement vs. credit vs. reject — is explicitly out of scope for
now. The current `SaleReturn` flow settles immediately with no inspection step (see
Background/Context for why). When it's picked up, it will need: an `InspectionResult` per
`SaleReturnItem` recorded after the item is physically back at a warehouse (not at the point of
return); a stock-location/category concept for quarantined or condemned stock (today
`StockBalance` only distinguishes `LocationType.Warehouse`/`Vehicle`); a real cash-refund concept
once Finance/Payments (step 19) exists to reverse a `Payment`; and a decision on whether
inspection reopens the immediate-settlement model into a provisional-then-confirmed one — a
bigger behavioral change than adding a field, needing its own planning pass first.
--- ---
## Implementation Notes ## Implementation Notes
> Fill this section in during planning discussion before writing any code. ### File structure (as built)
```
Features/
Returns/
ReturnController.cs — /api/orders/{id}/returns (POST/GET), /api/returns/{id} (GET)
ReturnDTOs.cs — CreateSaleReturnRequest, ReturnItemRequest, SaleReturnResponse(+Item)
ReturnService.cs — CreateAsync (validate → credit stock → net order → commit),
GetByIdAsync, GetByOrderIdAsync
IReturnRepository.cs / ReturnRepository.cs — includes StoreHasPriorSaleAsync
Claims/
ClaimController.cs — /api/claims (POST/GET), /api/claims/{id}/settle (POST)
ClaimDTOs.cs — CreateClaimRequest, SettleClaimRequest, ClaimResponse
ClaimService.cs — CreateAsync, SettleAsync, GetByIdAsync, GetByAgencyUserIdAsync
IClaimRepository.cs / ClaimRepository.cs
SupplierClaims/
SupplierClaimController.cs — /api/purchase-orders/{id}/claims (POST/GET),
/api/supplier-claims/{id} (GET),
/api/supplier-claims/{id}/approve|reject (POST)
SupplierClaimDTOs.cs — CreateSupplierClaimRequest, ApproveSupplierClaimRequest,
RejectSupplierClaimRequest, SupplierClaimResponse(+Item)
SupplierClaimService.cs — CreateAsync, ApproveAsync, RejectAsync, GetByIdAsync,
GetByPurchaseOrderIdAsync
ISupplierClaimRepository.cs / SupplierClaimRepository.cs
Models/
SaleReturn.cs, SaleReturnItem.cs, CreditNote.cs, Claim.cs (new)
SupplierClaim.cs, SupplierClaimItem.cs (new)
Enums.cs (extended — ReturnMode, ReturnReasonCode, ClaimStatus, SupplierClaimStatus,
SupplierClaimIssueType, SupplierClaimResolutionType added)
Data/AppDBContext.cs
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
(SupplierClaim.FK_GoodsReceiptId, ResolvedByUserId configured as optional)
Migrations/
20260825073453_AddReturnsAndClaims (applied)
20260825084218_AddSupplierClaims (applied)
Program.cs
IReturnRepository/ReturnRepository, IClaimRepository/ClaimRepository,
ISupplierClaimRepository/SupplierClaimRepository registered;
ReturnService, ClaimService, SupplierClaimService registered
```
### How the models connect
```
Order (existing) ◄── FK_OrderId ── SaleReturn ── Mode: CreditDeduction | ItemExchange
│
├─ SaleReturnItem (FK_ItemId, Quantity, ReasonCode, UnitPrice)
│ └─► StockBalance/StockMovement (credited: vehicle of
│ the order's ride, MovementType.CustomerReturn)
│
└─ CreditNote (only when Mode = CreditDeduction;
Amount = sum(Quantity * UnitPrice))
Claim ── FK_AgencyUserId ──► User (holding the RegionalAgency role, seeded in
Data/Seed/RoleAndPermissionSeed.cs) — no separate Agency entity
PurchaseOrder (existing, step 15) ◄── FK_PurchaseOrderId ── SupplierClaim
│
GoodsReceipt (existing, step 15) ◄── FK_GoodsReceiptId (optional) ┘
│
├─ SupplierClaimItem (FK_ItemId,
│ Quantity, IssueType)
└─ ResolutionType: Replacement |
Credit | Refund | PriceAdjustment
(no stock/ledger write — see
Business Rules)
```
### Return flow (as implemented)
```
ReturnService.CreateAsync(orderId, request, createdByUserId):
order = load Order, must be Pending/PartiallyFulfilled else throw
sale = load order's Sale, must have FK_RideId set else throw
ride = load Sale's Ride → vehicleId = ride.FK_VehicleId
orderItems = load order's OrderItems (for price snapshot + ItemExchange offset)
for each (itemId, qty, reasonCode) in request.Items:
guard StoreHasPriorSaleAsync(order.FK_StoreId, itemId) else throw 409
unitPrice = matching OrderItem.UnitPrice if present, else Item.SalePrice
begin transaction:
for each line: credit StockBalance(itemId, vehicleId, Vehicle) += qty
add StockMovement(Type = CustomerReturn, ToLocation = vehicle, ReferenceId = return.Id)
insert SaleReturn + SaleReturnItems
if Mode == CreditDeduction:
total = sum(qty * unitPrice); SaleReturn.TotalCreditAmount = total
Order.TotalAmount = max(0, Order.TotalAmount - total)
insert CreditNote(Amount = total)
else (ItemExchange):
for each line with a matching OrderItem:
offset = min(qty, orderItem.Quantity); orderItem.Quantity -= offset
Order.TotalAmount = max(0, Order.TotalAmount - offset * orderItem.UnitPrice)
commit
```
### Claim flow (as implemented)
```
ClaimService.CreateAsync(request): insert Claim(Status = Open)
ClaimService.SettleAsync(claimId, settlementAmount, approverId):
guard claim exists and Status != Settled else throw
Claim.Status = Settled; SettlementAmount = settlementAmount
Claim.SettledByUserId = approverId; SettledAt = now
```
### Supplier claim flow (as implemented)
```
SupplierClaimService.CreateAsync(purchaseOrderId, request, raisedByUserId):
po = load PurchaseOrder, must exist and be active else throw
guard Details non-empty, Items non-empty
if GoodsReceiptId given: guard it exists and belongs to this PO else throw
insert SupplierClaim(Status = Open) + SupplierClaimItems
SupplierClaimService.ApproveAsync(id, request, approverId):
guard claim exists and Status == Open else throw
guard ResolutionAmount present unless ResolutionType == Replacement else throw
Status = Approved; ResolutionType/Amount/Notes set; ResolvedByUserId = approverId; ResolvedAt = now
SupplierClaimService.RejectAsync(id, request, approverId):
guard claim exists and Status == Open else throw
Status = Rejected; ResolutionNotes set; ResolvedByUserId = approverId; ResolvedAt = now
```
+102
View File
@@ -0,0 +1,102 @@
# Authentication — Own-User Auth via External Identity Service (AuthHex)
**Status**: Done
**Depends On**: — (cross-cutting infrastructure, not a numbered feature step)
---
## Goal
Own-user (staff/API) authentication is delegated to an external identity service ("AuthHex"),
which owns credentials and signs RS256 JWTs. This app validates those tokens and maps them onto
its own `User`/`Role` tables so the existing permission-policy authorization stays unchanged.
---
## Background / Context
Previously this app verified passwords and minted its own HMAC-signed JWT locally. That's been
replaced — AuthHex now owns credentials and signing; this app only holds AuthHex's **public** key
to validate tokens, and never sees a plaintext password for this flow.
Authorization is untouched: permission policies, role levels, scope checks, and the seeded role
set all still work exactly as before, because every validated token is mapped back onto a local
`User`/`Role` row before any authorization check runs.
---
## Configuration Changes
```jsonc
"Jwt": {
"PublicKey": "<RSAKeyValue>...</RSAKeyValue>",
"Audience": "AuthHexClient",
"Issuer": "AuthHex"
},
"AuthHex": { "BaseUrl": "http://localhost:6102" }
```
`Jwt:Key` (old symmetric secret) is removed. `Jwt:PublicKey` is AuthHex's RSA public key in XML
format (not PEM).
---
## API Endpoints
| Method | Route | Notes |
|---|---|---|
| POST | /api/auth/login | Proxies to AuthHex `loginUser` |
| POST | /api/auth/register | Proxies to AuthHex `registerUser` |
| GET | /api/auth/me | `[Authorize]`, proxies to AuthHex `getUserDetails` |
| POST | /api/auth/logout | `[Authorize]`, proxies to AuthHex `LogoutUser` |
All four just forward to `POST {AuthHex:BaseUrl}/api/user` and pass AuthHex's response back
as-is. No cookie — token returned in the response body, sent back as `Authorization: Bearer`.
---
## Business Rules
1. No local password check or token issuance — both delegated to AuthHex.
2. `AddJwtBearer` validates issuer/audience/signature/lifetime against AuthHex's public key.
3. `OnTokenValidated` maps the token's `sub` claim to a local `User` (`IUserRepository`); fails
the token if no matching active user exists.
4. On success, `username`/`role_id`/`role_level` claims are attached from the **local** `User`/
`Role` row — authorization checks downstream are unaffected by anything AuthHex's own token
claims contain.
5. Only one validation mechanism runs (`AddJwtBearer`) — two ported alternatives exist but are not
registered (see Implementation Notes).
6. No refresh tokens — once the AuthHex-issued token expires, the client must log in again.
---
## Permissions to Add to Seed
None — this only changes authentication. Permission policies and role seed data are untouched.
---
## Open Questions
- Add a refresh-token flow if AuthHex doesn't offer one and re-login UX proves disruptive.
- Keep or delete the ported `AuthMiddleware`/`JwtMiddleware` if never actually needed.
- No retry/circuit-breaker if AuthHex is unreachable.
---
## Implementation Notes
```
Features/Auth/ — AuthController, AuthService, AuthDTOs (thin AuthHex proxies)
Infra/Authorization/
AuthHex/ — IAuthHexClient, AuthHexClient, AuthHexResponse
Jwt/RsaXmlKeyReader.cs — portable XML→RSA key parser (RSA.FromXmlString is Windows-only)
Middleware/ — AuthMiddleware, JwtMiddleware, JwtOptions, ApiResponse
(ported from OSMCore for reference — NOT registered)
Program.cs — AddHttpClient<IAuthHexClient>, AddJwtBearer + OnTokenValidated
(maps token → local User/Role → role_id/role_level claims)
```
**Gotcha:** don't run more than one of `AddJwtBearer` / `AuthMiddleware` /
`JwtMiddleware.CustomMiddlewareAttribute` at once — each re-validates the same token
independently.
+91 -37
View File
@@ -11,26 +11,32 @@
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks | | Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
| Store / Warehouse CRUD | Done | Full API surface with permission checks | | Store / Warehouse CRUD | Done | Full API surface with permission checks |
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) | | Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) | | Order CRUD | Done | Scope-aware (user only sees their assigned stores); an `Order` is now created only as a side effect of `Sale` creation, not standalone — see §1 Order Management notes below |
| Sale & Payment | Done | `Features/Sale` creates a `Sale`+linked `Order` together (pricing, KYC threshold, immediate-handover stock check); `Features/Payment` records collections against a `Sale` and drives its `PaymentStatus` |
| Unit of Work + Repositories | Done | Transaction support throughout | | Unit of Work + Repositories | Done | Transaction support throughout |
| Soft Deletes | Done | IsActive pattern on all entities | | Soft Deletes | Done | IsActive pattern on all entities |
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | | Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) | | Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) | | Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) |
| Returns & Claims Management | Done | Sale returns, claims, supplier claims; see [18_returns_claims.md](./18_returns_claims.md) |
## Known Gaps in Existing System (Pre-Work) ## Known Gaps in Existing System (Pre-Work) — Resolved
These are not new features — they are fixable holes in what's already built. Step 00 closed these. Kept here for history; all are fixed in the current codebase.
They must be resolved before new feature steps begin because later steps depend on correct foundations.
| Gap | Impact | Resolution |
|---|---|---|
| Route DTOs missing RegionId | Can't link routes to regions via API | Fixed — `CreateRouteRequest`/`UpdateRouteRequest`/`RouteResponse` all carry `RegionId` (`Features/Route/RouteDTOs.cs`). |
| Store DTOs missing RouteId | Can't link stores to routes via API | Fixed — all Store DTOs carry `RouteId` (`Features/Store/StoreDTOs.cs`). |
| Role endpoints have no [Authorize] | Anyone can read roles without authentication | Fixed — `[Authorize]` on `RoleController` (`Features/Role/RoleController.cs`). |
| Item model is empty (no properties) | OrderItems reference items that have no data | Fixed — `Item` has `Sku`/`Name`/`Description`/category/brand/UOM/pricing fields (`Models/Item.cs`). |
### Still open (not a step-00 blocker, tracked separately)
| Gap | Impact | | Gap | Impact |
|---|---| |---|---|
| Route DTOs missing RegionId | Can't link routes to regions via API | | JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment — `appsettings.json` still has a literal DB password and placeholder JWT key. |
| Store DTOs missing RouteId | Can't link stores to routes via API | | No input validation on any DTO | Bad data can enter the system silently. |
| 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 |
--- ---
@@ -43,8 +49,8 @@ They must be resolved before new feature steps begin because later steps depend
| 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) | | 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
| 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) | | 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
| 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) | | 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) |
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) | | 05 | Delivery Ride Execution | Done | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) | | 06 | Sale Ride Execution | Done | 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) | | 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) | | 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) | | 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
@@ -56,7 +62,7 @@ They must be resolved before new feature steps begin because later steps depend
| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) | | 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) |
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) | | 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) | | 17 | Promotions & Trade Scheme Management | Done | 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) | | 18 | Returns & Claims Management | Done | 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) | | 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) | | 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) | | 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
@@ -81,18 +87,27 @@ here — see `SRS_User_Management_Module.pdf`.
### 1. Order Management ### 1. Order Management
> **Design change since this section was first written**: `Order` is no longer created directly
> from `storeId/items/salesRepId`. Order creation was folded into `Features/Sale` — every `Order`
> is now a 1:1 side effect of `SaleService.CreateAsync` (see §6 Sale Ride Execution). `POST
> /api/orders` (`OrderService.CreateAsync`) is now just a lookup — it throws
> `"Orders are created automatically from sales"` if called for a sale that doesn't already have
> one, rather than creating a standalone order. All the validation described below (stock check,
> KYC threshold, pricing) now happens in `SaleService.CreateAsync`
> (`Features/Sale/SaleService.cs`), not `OrderService`.
| Function | What it does | Status | | Function | What it does | Status |
|---|---|---| |---|---|---|
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, applies the unverified-KYC order threshold from config, and now checks stock availability (see `checkStockAvailability` below). It still has no credit-limit validation, approval flow, or order state machine. | | `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Superseded — see design-change note above. The equivalent entry point is `SaleService.CreateAsync` (`createSale`, §6), which creates the `Sale` and its linked `Order` together. `OrderService.CreateAsync` itself only fetches the existing order for a given `SaleId`. |
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. | | `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or sale/order-time credit-limit enforcement yet. |
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Implemented — `OrderService.CreateAsync` rejects an order if any line's requested quantity exceeds total `StockBalance` for that item summed across every location (added alongside [15_procurement.md](./15_procurement.md)). Not location-specific yet (no fulfilling-warehouse concept on `Store`/`Order`), and does not reserve or decrement stock — that stays deferred to loading/delivery (§05-06). | | `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Partially implemented — `SaleService.CreateAsync` checks `StockBalance` at the ride's vehicle, but **only** when `FulfillImmediately = true` and a `RideId` is supplied (immediate van-sale handover); a deferred/pending sale does no stock check at create time at all. Not warehouse-wide, not reserved — a failed check simply returns `null` (400) rather than a typed error. |
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. | | `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `SaleService.CreateAsync` resolves the price-list half via `ResolveUnitPriceAsync` → `PriceListService.ResolvePriceAsync` (region-scoped via the store's route → `Item.SalePrice` fallback, or a caller-supplied `UnitPrice` validated against `[MinPrice, Price]`). The scheme-discount half is a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — not auto-invoked from `createSale`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after the order exists. |
| `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). | | `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. | | `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. | | `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Implemented, with a smaller state machine than described — `Models/Enums.cs` defines `OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }`; `OrderService.UpdateAsync` (`PUT /api/orders/{id}`) sets `Order.Status` directly (any status accepted, no transition guard) and mirrors it onto the linked `Sale.Status`/`CompletedAt`. No `approved/picked/dispatched/invoiced` states exist. |
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. | | `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. | | `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. | | `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Partially implemented — `OrderService.DeleteAsync` (`DELETE /api/orders/{id}`) soft-deletes the order and cascades `Sale.Status = Cancelled`, but takes no reason code and releases no reserved stock (nothing is reserved at create time to release). |
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. | | `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. | | `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
@@ -129,8 +144,11 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status | | Function | What it does | Status |
|---|---|---| |---|---|---|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. | | `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{id}/kyc-documents`. |
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{id}/kyc-documents`. |
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. | | `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. | | `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. | | `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. | | `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. | | `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. |
@@ -177,9 +195,18 @@ here — see `SRS_User_Management_Module.pdf`.
|---|---|---| |---|---|---|
| `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). | | `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. | | `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
| `listPriceLists(regionId?)` | Lists price lists, optionally filtered by region. | Implemented — `GET /api/price-lists`. |
| `getPriceList(priceListId)` | Fetches a price list and its items. | Implemented — `GET /api/price-lists/{id}`. |
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). | | `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). |
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. | | `updatePriceList(priceListId, name, region, effectiveDates)` | Updates price-list metadata. | Implemented — `PUT /api/price-lists/{id}`. |
| `deletePriceList(priceListId)` | Soft-deletes a price list. | Implemented — `DELETE /api/price-lists/{id}`. |
| `updateProductPricing(sku, priceListId, price, minPrice?)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
| `removeProductPricing(sku, priceListId)` | Removes a SKU override from a price list. | Implemented — `DELETE /api/price-lists/{id}/items/{sku}`. |
| `listSuppliers()` | Lists suppliers. | Implemented — `GET /api/suppliers`. |
| `getSupplier(supplierId)` | Fetches a supplier by ID. | Implemented — `GET /api/suppliers/{id}`. |
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. | | `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. |
| `updateSupplier(supplierId, supplierData)` | Updates supplier master data. | Implemented — `PUT /api/suppliers/{id}`. `PerformanceScore` remains read-only here. |
| `deleteSupplier(supplierId)` | Soft-deletes a supplier. | Implemented — `DELETE /api/suppliers/{id}`. Blocked if an active PurchaseOrder references the supplier. |
### 8. Enterprise-wide Inventory & Stock ### 8. Enterprise-wide Inventory & Stock
@@ -197,9 +224,21 @@ here — see `SRS_User_Management_Module.pdf`.
|---|---|---| |---|---|---|
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. | | `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. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. | | `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. |
| `getPurchaseOrder(poId)` | Fetches a purchase order. | Implemented — `GET /api/purchase-orders/{id}`. |
| `listPurchaseOrders(filters)` | Lists purchase orders. | Implemented — `GET /api/purchase-orders`. |
| `updatePurchaseOrder(poId, items[])` | Updates an open purchase order. | Implemented — `PUT /api/purchase-orders/{id}`. |
| `closePurchaseOrder(poId)` | Closes a received purchase order. | Implemented — `POST /api/purchase-orders/{id}/close`. |
| `cancelPurchaseOrder(poId, reason)` | Cancels a purchase order before receipts exist. | Implemented — `POST /api/purchase-orders/{id}/cancel`. |
| `cancelPurchaseOrderLine(poId, poItemId, reason)` | Cancels a single PO line. | Implemented — `POST /api/purchase-orders/{id}/items/{itemId}/cancel`. |
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. | | `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. |
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. | | `resolveInvoiceVariance(poId, variance, resolution)` | Records how a mismatch should be handled. | Implemented — `POST /api/purchase-orders/{id}/resolve-variance`. |
| `getGoodsReceipt(grnId)` | Fetches a goods receipt. | Implemented — `GET /api/goods-receipts/{id}`. |
| `listGoodsReceipts(poId)` | Lists goods receipts for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/goods-receipts`. |
| `createGoodsReceipt(poId, warehouseId, items[])` | Records inbound stock against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/goods-receipts`. |
| `getPurchaseReturn(returnId)` | Fetches a purchase return. | Implemented — `GET /api/purchase-returns/{id}`. |
| `listPurchaseReturns(poId)` | Lists purchase returns for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/returns`. |
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. | | `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. |
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
### 10. Sales Force Automation (SFA) / CRM ### 10. Sales Force Automation (SFA) / CRM
@@ -225,19 +264,25 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status | | 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. | | `createSaleReturn(orderId, mode, items[])` | Records a customer return against a delivered order. | Implemented — `POST /api/orders/{id}/returns`, settles immediately and credits the ride's vehicle. No approval step. |
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. | | `getSaleReturn(returnId)` | Fetches a sale return by ID. | Implemented — `GET /api/returns/{id}`. |
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. | | `listSaleReturns(orderId)` | Lists sale returns for an order. | Implemented — `GET /api/orders/{id}/returns`. |
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. | | `createClaim(agencyUserId, details)` | Raises a claim against a Regional Agency user. | Implemented — `POST /api/claims`. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. | | `getClaim(claimId)` | Fetches a claim by ID. | Implemented — `GET /api/claims/{id}`. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Implemented — `POST /api/claims/{id}/settle`, requires `claims.settle`. |
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | Raises a supplier claim against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/claims`. |
| `getSupplierClaim(claimId)` | Fetches a supplier claim by ID. | Implemented — `GET /api/supplier-claims/{id}`. |
| `listSupplierClaims(poId)` | Lists supplier claims for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/claims`. |
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | Approves a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/approve`, requires `suppliers.claims.approve`. |
| `rejectSupplierClaim(claimId, notes?)` | Rejects a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/reject`, requires `suppliers.claims.approve`. |
### 13. Finance & Accounting ### 13. Finance & Accounting
| Function | What it does | Status | | Function | What it does | Status |
|---|---|---| |---|---|---|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. | | `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. | | `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. | | `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | 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. | | `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
@@ -303,13 +348,17 @@ Introduce the concept of stock per location. Every warehouse (fixed or vehicle)
of each item. Stock is never edited directly — all changes happen through movement transactions 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. (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. This is the single most important architectural decision for the whole system.
For now, the shop is treated as a business destination, not a tracked stock location in this
system. That means warehouse-to-vehicle and vehicle-to-warehouse movements update tracked stock
balances, while vehicle-to-shop handover reduces vehicle stock and updates sale/order fulfillment
state without creating a shop stock balance.
**Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction. **Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction.
### 03 — Ride Lifecycle ### 03 — Ride Lifecycle
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine: 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. 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, 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. which route, which vehicle, and when it started and ended. For now, one ride uses one route.
**Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens. **Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens.
### 04 — Loading Event ### 04 — Loading Event
@@ -324,14 +373,18 @@ On a delivery ride, the driver fulfills pre-confirmed orders. Each order becomes
The driver marks each as delivered, partially delivered, or failed. Partially delivered orders 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) 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. is required to mark a delivery complete. Stock movements are created per delivery event.
Vehicle-to-shop handover reduces vehicle stock only; the shop does not yet exist as a tracked stock
location in the ledger.
**Gate**: A delivery ride can execute all orders on its route and produce a delivery report. **Gate**: A delivery ride can execute all orders on its route and produce a delivery report.
### 06 — Sale Ride Execution ### 06 — Sale Ride Execution
On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist. On a sale ride, the sales agent visits customers and creates sales in the field. Every sale creates
The driver selects items, quantities, and prices (within approved discount limits), and the invoice an order. If items are handed over immediately, the order is fulfilled immediately and the sale is
is generated immediately. Payment is collected (cash or credit) at the point of sale. completed immediately. If items are not handed over immediately, the order stays pending for later
The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded. fulfillment. Payment is collected based on the final sale flow. The vehicle's stock ledger decreases
**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale. only for quantities actually handed over from the vehicle. The shop is not yet a tracked stock
location in the ledger.
**Gate**: A sale ride can create sales in the field, create linked orders, and reduce vehicle stock for immediate handover quantities.
### 07 — Ride Close & Reconciliation ### 07 — Ride Close & Reconciliation
When a ride ends, the system performs a full reconciliation. The equation must balance: When a ride ends, the system performs a full reconciliation. The equation must balance:
@@ -351,8 +404,9 @@ and approval chains for exceptions. Supervisors can grant one-time overrides log
A dedicated audit log records every sensitive event (who, what, when, from where). 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, 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. 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 Return-related processing is handled in §12 as an immediate-settlement flow; this step focuses on
surfaced to area managers on a scheduled basis. audit logging and anomaly detection around those events. 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. **Gate**: Every sensitive event is logged immutably. Automated flags surface to the right level of management.
--- ---