Compare commits

..

16 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna bb33928661 auth clean up 2026-08-25 16:11:37 +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
76 changed files with 13938 additions and 703 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<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { 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<LoadingSheet> LoadingSheets { 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<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { 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)
{
@@ -79,6 +88,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
@@ -102,6 +115,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.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>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
@@ -238,6 +276,103 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<PurchaseReturnItem>()
.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);
}
}
+17 -4
View File
@@ -2,14 +2,27 @@ using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Auth;
/// <summary>
/// Proxies all authentication traffic from the Next.js frontend to the
/// external Auth Service. The frontend must never call the Auth Service
/// directly — all auth goes through this controller.
///
/// TODO: Implement endpoints once Auth Service contract is agreed.
/// </summary>
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service) : ControllerBase
public class AuthController(IAuthServiceClient authServiceClient) : ControllerBase
{
// TODO: replace stub endpoints below with real proxy calls once
// the Auth Service API contract is finalised.
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
public Task<IActionResult> Login()
{
var result = await service.LoginAsync(request);
return result is null ? Unauthorized() : Ok(result);
// TODO: accept login request shape from Auth Service contract,
// forward to authServiceClient, relay response (token / session) back.
return Task.FromResult<IActionResult>(StatusCode(501, new { error = "Auth integration not yet implemented." }));
}
// TODO: add further endpoints as needed (refresh, logout, etc.)
}
+3 -3
View File
@@ -1,5 +1,5 @@
namespace Distribution.Features.Auth;
public record LoginRequest(string Username, string Password);
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
// TODO: Define request/response shapes once the Auth Service contract is agreed.
// These will be the DTOs passed between the Next.js frontend and this proxy controller,
// which should mirror (or adapt) whatever the Auth Service expects/returns.
+4 -22
View File
@@ -1,24 +1,6 @@
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Identity;
namespace Distribution.Features.Auth;
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
{
private readonly PasswordHasher<Models.User> _hasher = new();
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
{
var user = await userRepo.GetByUsernameAsync(request.Username);
if (user is null || !user.IsActive)
return null;
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (result == PasswordVerificationResult.Failed)
return null;
var token = tokenService.GenerateToken(user);
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
}
}
// TODO: This service previously contained local password validation logic.
// It has been replaced by the IAuthServiceClient connector which proxies
// to the external Auth Service. Remove this file or repurpose it for any
// local orchestration logic once the Auth Service contract is defined.
@@ -0,0 +1,20 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -0,0 +1,16 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken 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)
{
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)
.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();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
@@ -4,10 +4,12 @@ public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
+12 -4
View File
@@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
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}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")]
+8 -5
View File
@@ -1,18 +1,21 @@
using Distribution.Models;
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, decimal UnitPrice);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetAllAsync() =>
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) =>
await context.Orders.AddAsync(entity);
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> 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.PriceList;
using Distribution.Features.Store;
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
@@ -15,16 +15,16 @@ public class OrderService(
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
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.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -35,42 +35,6 @@ public class OrderService(
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()
{
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)
{
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);
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 existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var order = new Models.Order
{
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);
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
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);
if (order is null || !order.IsActive) 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);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
@@ -178,6 +119,7 @@ public class OrderService(
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
@@ -187,10 +129,19 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
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);
}
}
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");
});
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 =>
{
b.Property<Guid>("Id")
@@ -131,6 +199,48 @@ namespace Distribution.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -397,20 +507,32 @@ namespace Distribution.Migrations
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<DateTime?>("FulfilledAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("OrderDate")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_SaleId")
.IsUnique();
b.HasIndex("FK_StoreId");
b.ToTable("Orders");
@@ -436,6 +558,9 @@ namespace Distribution.Migrations
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("QuantityFulfilled")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
@@ -446,6 +571,45 @@ namespace Distribution.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -860,6 +1024,125 @@ namespace Distribution.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -1231,6 +1514,98 @@ namespace Distribution.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -1464,6 +1839,35 @@ namespace Distribution.Migrations
b.ToTable("Warehouses");
});
modelBuilder.Entity("Distribution.Models.Claim", b =>
{
b.HasOne("Distribution.Models.User", "AgencyUser")
.WithMany()
.HasForeignKey("FK_AgencyUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "SettledByUser")
.WithMany()
.HasForeignKey("SettledByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AgencyUser");
b.Navigation("SettledByUser");
});
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
{
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
.WithMany()
.HasForeignKey("FK_SaleReturnId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SaleReturn");
});
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
{
b.HasOne("Distribution.Models.DriverProfile", "DriverProfile")
@@ -1499,6 +1903,25 @@ namespace Distribution.Migrations
b.Navigation("Vehicle");
});
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.DriverProfile", b =>
{
b.HasOne("Distribution.Models.User", "User")
@@ -1619,12 +2042,20 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
@@ -1637,7 +2068,7 @@ namespace Distribution.Migrations
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.WithMany("OrderItems")
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -1647,6 +2078,25 @@ namespace Distribution.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
@@ -1776,6 +2226,70 @@ namespace Distribution.Migrations
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 =>
{
b.HasOne("Distribution.Models.Item", "FreeItem")
@@ -1898,6 +2412,58 @@ namespace Distribution.Migrations
b.Navigation("Category");
});
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt")
.WithMany()
.HasForeignKey("FK_GoodsReceiptId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
.WithMany()
.HasForeignKey("FK_PurchaseOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "RaisedByUser")
.WithMany()
.HasForeignKey("RaisedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.User", "ResolvedByUser")
.WithMany()
.HasForeignKey("ResolvedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("GoodsReceipt");
b.Navigation("PurchaseOrder");
b.Navigation("RaisedByUser");
b.Navigation("ResolvedByUser");
});
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
{
b.HasOne("Distribution.Models.Item", "Item")
.WithMany()
.HasForeignKey("FK_ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim")
.WithMany("Items")
.HasForeignKey("FK_SupplierClaimId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Item");
b.Navigation("SupplierClaim");
});
modelBuilder.Entity("Distribution.Models.UomConversion", b =>
{
b.HasOne("Distribution.Models.Uom", "FromUom")
@@ -2032,6 +2598,11 @@ namespace Distribution.Migrations
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.Navigation("OrderItems");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.Navigation("Items");
@@ -2047,12 +2618,22 @@ namespace Distribution.Migrations
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.Navigation("BundleComponents");
b.Navigation("Redemptions");
});
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
+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
{
public enum LocationType
{
Warehouse,
Vehicle
}
{ Warehouse, Vehicle }
public enum KycStatus
{
Pending,
Verified,
Rejected }
public enum KycStatus { Pending,Verified, Rejected }
public enum MovementType
{
Sale,
CustomerReturn,
Transfer,
Damage,
Adjustment,
Load,
PurchaseReceipt,
PurchaseReturn
}
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
public enum LoadingSheetStatus
{
Pending,
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
public enum RideType { Delivery, Sale }
@@ -42,4 +18,27 @@ namespace Distribution.Models
public enum CancelRequestStatus { Pending, Approved, Rejected }
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 Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public DateTime OrderDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { 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")]
public Item Item { get; set; }
public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
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
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, OrderItem**.
Item model is sourced from ERP Core — excluded from this phase.
SaleRoute shape is TBD — excluded until clarified.
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order,
OrderItem**.
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)
```
```text
Distribution/
├── Models/ # All entity POCOs — unchanged
│ ├── Template.cs
│ ├── Region.cs
│ ├── Route.cs
│ ├── Vehicle.cs
│ ├── Store.cs
│ ├── Warehouse.cs
│ ├── SaleRoute.cs # TBD
│ ├── Order.cs
│ ├── OrderItem.cs
│ ├── DeliveryRun.cs
│ └── Item.cs # Sourced from ERP Core
├── Data/
│ └── AppDBContext.cs # DbSets + Fluent API config
├── Infra/
│ ├── UoW/ # Already implemented
│ │ ├── IUnitOfWork.cs
│ │ └── EFUnitOfWork.cs
│ └── Repositories/ # Shared generic base only
│ ├── IGenericRepo.cs
│ └── GenericRepo.cs
└── Features/
├── Region/
│ ├── RegionDTOs.cs
│ ├── IRegionRepository.cs
│ ├── RegionRepository.cs
│ ├── RegionService.cs
│ └── RegionController.cs
├── 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
|-- Models/
| |-- Template.cs
| |-- Region.cs
| |-- Route.cs
| |-- Vehicle.cs
| |-- Store.cs
| |-- Warehouse.cs
| |-- SaleRoute.cs
| |-- Order.cs
| |-- OrderItem.cs
| |-- DeliveryRun.cs
| `-- Item.cs
|-- Data/
| `-- AppDBContext.cs
|-- Infra/
| |-- UoW/
| | |-- IUnitOfWork.cs
| | `-- EFUnitOfWork.cs
| `-- Repositories/
| |-- IGenericRepo.cs
| `-- GenericRepo.cs
`-- Features/
|-- Region/
|-- Route/
|-- Vehicle/
|-- Store/
|-- Warehouse/
`-- Order/
```
---
## Hard Rules (from AGENTS.md)
## Hard Rules
- Only `uow.CommitAsync()` in services — never in repositories.
- Use `DateTime.Now` — never `DateTime.UtcNow`.
- Only `uow.CommitAsync()` in services - never in repositories.
- Use `DateTime.Now` - never `DateTime.UtcNow`.
- 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
| Item | Reason |
|---|---|
| `SaleRoute` | Shape undefined — implement after clarification |
| `Item` model | Sourced from ERP Core — user handles integration |
| `SaleRoute` | Re-evaluate after ride/route design is clarified |
| `Item` model | Sourced from ERP Core |
| `DeliveryRun` | Not in scope for this phase |
| Driver FK on DeliveryRun | Commented out — depends on HR/Driver module |
| Tests | No test project yet — add after endpoints stabilize |
| Tests | No test project yet |
---
## 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`
+67 -22
View File
@@ -3,6 +3,8 @@ using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.DeliveryRide;
using Distribution.Features.Payment;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
@@ -11,11 +13,15 @@ using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
using Distribution.Features.PurchaseReturn;
using Distribution.Features.Returns;
using Distribution.Features.Claims;
using Distribution.Features.SupplierClaims;
using Distribution.Features.Region;
using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
using Distribution.Features.Role;
using Distribution.Features.Route;
using Distribution.Features.Sale;
using Distribution.Features.Scheme;
using Distribution.Features.Store;
using Distribution.Features.Store.Kyc;
@@ -49,6 +55,7 @@ builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
@@ -69,6 +76,11 @@ builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<IPoRepository, PoRepository>();
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
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<RouteService>();
@@ -76,9 +88,9 @@ builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<BrandService>();
builder.Services.AddScoped<UomService>();
@@ -96,10 +108,14 @@ builder.Services.AddScoped<SchemeService>();
builder.Services.AddScoped<PoService>();
builder.Services.AddScoped<GrnService>();
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.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
@@ -107,28 +123,57 @@ builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
var jwtSection = builder.Configuration.GetSection("Jwt");
var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
// ── Auth Service connector ───────────────────────────────────────────────────
// TODO: replace placeholder BaseAddress with real Auth Service URL once decided.
// The AuthServiceOptions:BaseUrl value in appsettings.json drives this.
builder.Services.Configure<AuthServiceOptions>(
builder.Configuration.GetSection(AuthServiceOptions.SectionName));
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"];
builder.Services.AddHttpClient<AuthServiceClient>(client =>
{
// TODO: set BaseAddress once Auth Service URL is known.
if (!string.IsNullOrWhiteSpace(authServiceBaseUrl))
client.BaseAddress = new Uri(authServiceBaseUrl);
});
builder.Services.AddScoped<IAuthServiceClient, AuthServiceClient>();
// ── Authentication ───────────────────────────────────────────────────────────
// TODO: replace this JWT configuration with the token validation strategy
// dictated by the Auth Service (symmetric key, JWKS/OIDC, etc.) once decided.
var jwtSection = builder.Configuration.GetSection("Jwt");
var jwtKey = jwtSection["Key"];
if (!string.IsNullOrWhiteSpace(jwtKey))
{
builder.Services.AddAuthentication(options =>
{
ValidateIssuer = true,
ValidIssuer = jwtSection["Issuer"],
ValidateAudience = true,
ValidAudience = jwtSection["Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtSection["Issuer"],
ValidateAudience = true,
ValidAudience = jwtSection["Audience"],
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
}
else
{
// Auth Service integration not yet configured — all [Authorize] checks will
// fail until the token validation strategy is wired up.
builder.Services.AddAuthentication();
}
builder.Services.AddAuthorization();
+3
View File
@@ -17,5 +17,8 @@
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
},
"AuthService": {
"BaseUrl": ""
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# 00 — Existing Gap Fixes
**Status**: Not Started
**Status**: Done
**Depends On**: —
---
+1 -1
View File
@@ -1,6 +1,6 @@
# 01 — Item Catalog
**Status**: Ready
**Status**: 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
**Depends On**: 02 — Stock Ledger
**Depends On**: 02 - Stock Ledger
---
## 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
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
### 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 |
|---|---|---|
| Id | Guid | PK |
| FK_UserId | Guid | FK → User (unique) |
| FK_UserId | Guid | FK -> User (unique) |
| LicenseNumber | string | |
| LicenseExpiry | DateTime | |
| MedicalClearanceExpiry | DateTime | |
| IsActive | bool | Can be suspended without deleting |
| 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:
@@ -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`:
@@ -53,34 +71,43 @@ Add the following fields to the existing `DeliveryRun`:
|---|---|---|
| RideType | RideType | Delivery or Sale |
| 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. |
| StartedAt | DateTime? | Set when Draft → Open |
| ActivatedAt | DateTime? | Set when Open → Active |
| ClosedAt | DateTime? | Set when Active → Closed |
| StartedAt | DateTime? | Set when Draft -> Open |
| ActivatedAt | DateTime? | Set when Open -> Active |
| ClosedAt | DateTime? | Set when Active -> Closed |
---
### New Enum — RideType
### New Enum - RideType
```csharp
public enum RideType { Delivery, Sale }
```
### New Enum — RideStatus
### New Enum - RideStatus
```csharp
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 |
|---|---|---|
| Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_RideId | Guid | FK -> DeliveryRun |
| TypeCode | CancelRequestType | Reason category |
| Reason | string | Free text explanation |
| RequestedBy | Guid | UserId |
@@ -89,7 +116,7 @@ public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed
| ApprovedBy | Guid? | UserId of approver |
| ApprovedAt | DateTime? | |
### New Enum — CancelRequestType
### New Enum - CancelRequestType
```csharp
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.
@@ -117,7 +144,7 @@ Managed via a dedicated config endpoint. Only one row ever exists.
---
### New Enum — CancelRequestStatus
### New Enum - CancelRequestStatus
```csharp
public enum CancelRequestStatus { Pending, Approved, Rejected }
@@ -127,21 +154,22 @@ public enum CancelRequestStatus { Pending, Approved, Rejected }
## State Machine
```
Draft → Open → Active → Closed
↘ CancelRequested → Cancelled
```text
Draft -> Open -> Active -> Closed
\-> CancelRequested -> Cancelled
```
| 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`. |
| 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 → 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 → Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
| 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`. |
| 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`. |
| 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`. |
**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)
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.
---
@@ -160,12 +188,12 @@ before a ride can close or cancel.
1. Driver submits cancel request with `TypeCode = VehicleBreakdown`.
2. Ride status moves to `CancelRequested`.
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.
4. Supervisor approves the cancel request.
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 | `/{id}` | Get ride detail |
| PUT | `/{id}/driver` | Assign or change driver profile |
| POST | `/{id}/open` | Transition Draft → Open |
| POST | `/{id}/activate` | Transition Open → Active (called by loading event) |
| POST | `/{id}/close` | Transition Active → Closed (called by reconciliation) |
| POST | `/{id}/open` | Transition Draft -> Open |
| POST | `/{id}/activate` | Transition Open -> Active (called by loading event) |
| POST | `/{id}/close` | Transition Active -> Closed (called by reconciliation) |
| POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) |
| GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. |
| POST | `/cancel-requests/{id}/approve` | Approve 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
@@ -215,37 +243,42 @@ Base route: `/api/driver-profiles`
## 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.
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.
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.
8. A ride uses a single route for now.
---
## Open Questions
1. **Cancel request type codes** — ✅ Resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other`.
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.
1. Cancel request type codes - resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`,
`Other`.
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
**Build order:**
1. ✅ Enums — `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` → added to `Models/Enums.cs`
2. ✅ Entities — `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` (new); updated `Vehicle` (+`FK_DefaultWarehouseId`), updated `DeliveryRun` (+`RideType`, `Status`, `FK_DriverProfileId`, `FK_SourceWarehouseId`, `StartedAt`, `ActivatedAt`, `ClosedAt`)
3. ✅ `AppDBContext` — new DbSets + Fluent config
4. ✅ Migrations — `RideLifecycle` (schema), `RidePermissions` (10 new permissions seeded across all roles)
5. ✅ DTOs — `DriverProfileDTOs.cs`, `DriverComplianceConfigDTOs.cs`, `RideDTOs.cs`
6. ✅ Repositories — `IDriverProfileRepository`, `IDriverComplianceConfigRepository`, `IRideRepository`, `IRideCancelRequestRepository`
7. ✅ Services — `DriverProfileService`, `DriverComplianceConfigService`, `RideService`
8. ✅ Controllers — `DriverProfileController`, `DriverComplianceConfigController`, `RideController`
9. ✅ DI registration in `Program.cs`
1. Enums - `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus`
2. Entities - `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest`
3. `AppDBContext` - new DbSets plus Fluent config
4. Migrations - ride lifecycle schema and permissions
5. DTOs - driver profile, compliance config, ride
6. Repositories - ride and ride cancel request support
7. Services - driver profile, compliance config, ride
8. Controllers - driver profile, compliance config, ride
9. DI registration in `Program.cs`
**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
**Depends On**: 03 — Ride Lifecycle
**Status**: Done
**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.
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
end-to-end.
`Open -> Active` automatically.
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
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger — the same math as
a 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`
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger - the same math as a
warehouse. When loading is confirmed, two stock movements are created per item:
From this point the vehicle carries live inventory that decreases with every sale or delivery
(Steps 05/06) and must return to zero before the ride can close (Step 07).
- 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 and
must return to zero before the ride can close.
---
## Data Model Changes
### New Entity — LoadingSheet
### New Entity - LoadingSheet
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_SourceWarehouseId | Guid | FK → Warehouse |
| FK_RideId | Guid | FK -> DeliveryRun |
| FK_SourceWarehouseId | Guid | FK -> Warehouse |
| Status | LoadingSheetStatus | State machine below |
| DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch |
| 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 |
| CreatedAt | DateTime | |
### New Entity — LoadingSheetItem
### New Entity - LoadingSheetItem
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| FK_ItemId | Guid | FK → Item |
| FK_LoadingSheetId | Guid | FK -> LoadingSheet |
| FK_ItemId | Guid | FK -> Item |
| QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch |
| QuantityReceived | decimal? | Set by driver before confirm-receipt |
| QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy |
### New Enum — LoadingSheetStatus
### New Enum - LoadingSheetStatus
```csharp
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
```
Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
↘ DiscrepancyFlagged → Resolved
```text
Pending -> DispatchConfirmed -> ReceiptConfirmed
\-> DiscrepancyFlagged -> Resolved
```
| Transition | Trigger | What it does |
|---|---|---|
| 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 → 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`. |
| 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 -> 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`. |
---
@@ -90,10 +94,11 @@ Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
| 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 |
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` (no orders to derive from) |
| 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` 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:
- **Debit** warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- **Credit** vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
- Debit warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- 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. |
| GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. |
| 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) |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending → DispatchConfirmed |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending -> DispatchConfirmed |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line |
| POST | `/{id}/confirm-receipt` | Driver | → ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line → Resolved + ride activation |
| POST | `/{id}/confirm-receipt` | Driver | -> ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line -> Resolved plus ride activation |
---
## Business Rules
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.
3. `QuantityDispatched != QuantityReceived` on any line → `DiscrepancyFlagged`. No partial activation.
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.
2. A ride can only have one active loading sheet at a time.
3. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial activation.
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.
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.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver (AreaManager and above for testing) |
| `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above |
---
## Fraud / Control Considerations
- Discrepancies are immediately visible and block activation — they cannot be silently absorbed.
- `QuantityResolved` is permanently stored per line for audit purposes, even after the sheet is resolved.
- See [09_fraud_controls.md](./09_fraud_controls.md) for the audit trail layer that will sit on top of this.
- Discrepancies are immediately visible and block activation.
- `QuantityResolved` is permanently stored per line for audit purposes.
---
## Open Questions
- ✅ Vehicle capacity check — deferred to Step 13 (Fleet Management).
- ✅ Discrepancy resolution — supervisor enters `QuantityResolved` per line item.
- ✅ Ride activation trigger — `confirm-receipt` and `resolve` auto-trigger `Open → Active` internally.
- ✅ Item population — Delivery rides auto-populated from orders; Sale rides manually entered.
1. Vehicle capacity check - deferred to Step 13.
2. Discrepancy resolution - supervisor enters `QuantityResolved` per line item.
3. Ride activation trigger - `confirm-receipt` and `resolve` auto-trigger `Open -> Active`.
4. Delivery item population - from assigned orders on the ride's route.
---
## Implementation Notes
**Build order:**
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.
Build after the ride model and before full delivery/sale execution.
+107 -38
View File
@@ -1,79 +1,148 @@
# 05 — Delivery Ride Execution
**Status**: Not Started
**Status**: Done
**Depends On**: 04 — Loading Event
---
## Goal
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half
of [mainPlan.md §1](./mainPlan.md#1-order-management):
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
Fulfill pending orders against an active Delivery ride. The driver visits each store on the route,
delivers the ordered items, gets customer confirmation, and the system creates the stock movement,
updates the order, and completes the sale.
---
## Background / Context
Today `Order` has no status and no link to a ride. This step is where the order lifecycle
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)).
- A Delivery ride goes `Active` after the loading event is confirmed.
- Orders are linked to a ride implicitly through route: `Ride → Route → Stores → Pending Orders`.
- `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
### Delivery Task (per order on ride)
### New Entity — DeliveryTask
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`,
`Failed`).
Extends `Template` (adds `IsActive`, `CreatedAt` — consistent with all other entities).
### 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`
for the shortfall.
### New Enum — DeliveryTaskStatus
### 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
| Function reference | Endpoint intent |
|---|---|
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` |
| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) |
| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` |
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP |
Base routes: `/api/rides/{rideId}/tasks` and `/api/delivery-tasks`
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/rides/{rideId}/tasks/generate` | Driver/System | Auto-create tasks from all Pending/PartiallyFulfilled orders for stores on ride's route |
| 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
- A delivery task cannot be marked `Delivered` without a customer confirmation.
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape).
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle
location per [02_stock_ledger.md](./02_stock_ledger.md).
1. Delivery tasks can only be created for a ride in `Active` status and of type `Delivery`.
2. One task per order per ride — cannot create a second task for the same order on the same ride.
3. `QuantityFulfilled` must be set on all order items before calling confirm.
4. `QuantityFulfilled` cannot exceed `Quantity` on any line.
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
editable after submission).
1. Enum — add `DeliveryTaskStatus` to `Models/Enums.cs`
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?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- ✅ 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
+95 -43
View File
@@ -1,80 +1,132 @@
# 06 — Sale Ride Execution
**Status**: Not Started
**Status**: Done
**Depends On**: 04 — Loading Event
---
## Goal
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is
generated immediately, and payment is captured at point of sale. Covers `createInvoice`,
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting),
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
(§11) if promotions are live by the time this ships.
Allow a sales agent on an active Sale ride to create sales in the field. Every sale creates an
order. Immediate handover deducts vehicle stock and closes the order immediately. Non-immediate
handover leaves the order pending for a future delivery ride.
---
## Background / Context
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row —
the field invoice *is* the order, created and settled in one step.
- `SaleService.CreateAsync` already exists and handles both `FulfillImmediately` paths.
- 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
### Field Invoice
No new entities. Everything needed was added in pre-planning:
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`,
`OnCredit`).
| What | Where | Status |
|---|---|---|
| `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
`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`.
Migration needed for: `Payment` table, `Sale.PaymentStatus` column, `OrderItem.QuantityFulfilled` column.
---
## API Endpoints
## Changes to Existing SaleService
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here |
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` |
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits |
`SaleService.CreateAsync` needs two additions when `FulfillImmediately = true` and `RideId` is set:
1. **Stock check** — for each item, verify vehicle has enough stock (`StockBalance` at
`LocationType.Vehicle` with `LocationId = VehicleId`). Reject the whole sale if any item is short.
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
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the
invoice is finalized.
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location,
immediately (not batched at ride close).
- Discount applied per line must not exceed the agent's role-level limit (§8).
1. Driver cannot sell more than vehicle stock for immediate handover — entire sale rejected if any
item is short (no partial sale creation).
2. Every sale creates an order, even when all items are handed over immediately.
3. `FulfillImmediately = true`:
- Vehicle stock debited immediately per item.
- 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
[07_ride_reconciliation.md](./07_ride_reconciliation.md).
1. Migration — `Payment` table, `Sale.PaymentStatus`, `OrderItem.QuantityFulfilled`
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
sequence?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- ✅ Payment timing — can be recorded at any time, not locked to sale completion
- ✅ 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
+9
View File
@@ -122,6 +122,15 @@ GoodsReceiptItem
```
### 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
├── Id (Guid) — PK, from Template
+424 -29
View File
@@ -1,74 +1,469 @@
# 18 — Returns & Claims Management
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
**Status**: Done — migrations applied to the database. Inspection/classification for customer
returns is deferred (see Open Questions).
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for
`SupplierClaim`)
---
## Goal
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
both reference this step's credit note and approval shapes — this is where those are actually
defined.
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where
a sales rep records a return during a store visit and a claim workflow against a Regional Agency,
plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve
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
No return, credit note, or claim entity exists today. `createBackorder` (§1,
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
No return, credit note, or claim entity existed before this step. `createBackorder` (§1,
[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
### Return Request
Six new tables.
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
`Rejected`), `RequestedByUserId`.
### SaleReturn
```
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
├── 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
| 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` |
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
| `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles |
| `getSaleReturn(returnId)` | `GET /api/returns/{id}` |
| `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` |
### 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
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
the `ReturnWithoutSale` anomaly flagged automatically in
[09_fraud_controls.md](./09_fraud_controls.md).
- Approving a return creates a `Return` stock movement crediting the relevant location, per
[02_stock_ledger.md](./02_stock_ledger.md).
1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This
deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see
Background/Context) since a rep records the return on the spot during a live store visit.
2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or
`PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`,
`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
- 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
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
> 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
```
+91 -37
View File
@@ -11,26 +11,32 @@
| Region / Route / Vehicle 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) |
| 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 |
| 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) |
| 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) |
| 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.
They must be resolved before new feature steps begin because later steps depend on correct foundations.
Step 00 closed these. Kept here for history; all are fixed in the current codebase.
| 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 |
|---|---|
| Route DTOs missing RegionId | Can't link routes to regions via API |
| Store DTOs missing RouteId | Can't link stores to routes via API |
| Role endpoints have no [Authorize] | Anyone can read roles without authentication |
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment |
| No input validation on any DTO | Bad data can enter the system silently |
| Item model is empty (no properties) | OrderItems reference items that have no data |
| 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. |
| No input validation on any DTO | Bad data can enter the system silently. |
---
@@ -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) |
| 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) |
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) |
| 05 | Delivery Ride Execution | Done | 04 | [05_delivery_ride.md](./05_delivery_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) |
| 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) |
@@ -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) |
| 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) |
| 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) |
| 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) |
@@ -81,18 +87,27 @@ here — see `SRS_User_Management_Module.pdf`.
### 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 |
|---|---|---|
| `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. |
| `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. |
| `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). |
| `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. |
| `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` 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. | 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 — `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). |
| `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. |
| `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. |
| `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 |
|---|---|---|
| `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`. |
| `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. |
| `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. |
@@ -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). |
| `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). |
| `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. |
| `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
@@ -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. |
| `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. |
| `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. |
| `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
@@ -225,19 +264,25 @@ here — see `SRS_User_Management_Module.pdf`.
| Function | What it does | Status |
|---|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | Initiates a return against a delivered order. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) for the fraud-control angle on returns. |
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. |
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. |
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. |
| `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. |
| `getSaleReturn(returnId)` | Fetches a sale return by ID. | Implemented — `GET /api/returns/{id}`. |
| `listSaleReturns(orderId)` | Lists sale returns for an order. | Implemented — `GET /api/orders/{id}/returns`. |
| `createClaim(agencyUserId, details)` | Raises a claim against a Regional Agency user. | Implemented — `POST /api/claims`. |
| `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
| Function | What it does | Status |
|---|---|---|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. |
| `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. | 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 — no GL/ledger entity exists. |
| `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. |
@@ -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
(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.
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.
### 03 — Ride Lifecycle
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine:
Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time.
Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving,
which route, which vehicle, and when it started and ended.
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.
### 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
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.
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.
### 06 — Sale Ride Execution
On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist.
The driver selects items, quantities, and prices (within approved discount limits), and the invoice
is generated immediately. Payment is collected (cash or credit) at the point of sale.
The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded.
**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale.
On a sale ride, the sales agent visits customers and creates sales in the field. Every sale creates
an order. If items are handed over immediately, the order is fulfilled immediately and the sale is
completed immediately. If items are not handed over immediately, the order stays pending for later
fulfillment. Payment is collected based on the final sale flow. The vehicle's stock ledger decreases
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
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).
Automated checks flag: returns without a matching prior sale, sales outside the geofenced route,
agents with consistently high variance rates, and cash collected below invoiced amounts.
Return approval requires supervisor sign-off before stock is credited. Anomaly reports are
surfaced to area managers on a scheduled basis.
Return-related processing is handled in §12 as an immediate-settlement flow; this step focuses on
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.
---
File diff suppressed because it is too large Load Diff