Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08ad17f15b | |||
| 97a1db1343 | |||
| 916a2b8f0a | |||
| 3caacf44d0 | |||
| fe9ccb4de7 | |||
| f244a04dc0 | |||
| aa543d0d0d | |||
| 87146e4662 | |||
| cefefcbc49 | |||
| fcca03d0b2 | |||
| 6d8b6b3e59 | |||
| 3837facb02 | |||
| a833586e83 | |||
| ade41b4a60 | |||
| 3680fc9035 | |||
| f6211a8edd | |||
| 83bf715716 |
+1
Submodule .claude/worktrees/agent-a203fc082069b1faf added at ade41b4a60
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<UomConversion> UomConversions { get; set; }
|
public DbSet<UomConversion> UomConversions { get; set; }
|
||||||
public DbSet<Item> Items { get; set; }
|
public DbSet<Item> Items { get; set; }
|
||||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
||||||
|
public DbSet<Sale> Sales { get; set; }
|
||||||
public DbSet<Order> Orders { get; set; }
|
public DbSet<Order> Orders { get; set; }
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
public DbSet<OrderItem> OrderItems { get; set; }
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
||||||
@@ -41,12 +42,20 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
||||||
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
||||||
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
|
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
|
||||||
|
public DbSet<Payment> Payments { get; set; }
|
||||||
|
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
|
||||||
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
|
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
|
||||||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||||
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||||
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||||
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||||
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||||
|
public DbSet<SaleReturn> SaleReturns { get; set; }
|
||||||
|
public DbSet<SaleReturnItem> SaleReturnItems { get; set; }
|
||||||
|
public DbSet<CreditNote> CreditNotes { get; set; }
|
||||||
|
public DbSet<Claim> Claims { get; set; }
|
||||||
|
public DbSet<SupplierClaim> SupplierClaims { get; set; }
|
||||||
|
public DbSet<SupplierClaimItem> SupplierClaimItems { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -79,6 +88,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.HasIndex(o => o.FK_SaleId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<DriverProfile>()
|
modelBuilder.Entity<DriverProfile>()
|
||||||
.HasIndex(dp => dp.FK_UserId)
|
.HasIndex(dp => dp.FK_UserId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -102,6 +115,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(r => r.FK_DriverProfileId)
|
.HasForeignKey(r => r.FK_DriverProfileId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Sale>()
|
||||||
|
.HasOne(s => s.Store)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_StoreId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Sale>()
|
||||||
|
.HasOne(s => s.CreatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.CreatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Sale>()
|
||||||
|
.HasOne(s => s.Ride)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.FK_RideId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Order>()
|
||||||
|
.HasOne(o => o.Sale)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(o => o.FK_SaleId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<SchemeRedemption>()
|
modelBuilder.Entity<SchemeRedemption>()
|
||||||
.HasOne(r => r.Scheme)
|
.HasOne(r => r.Scheme)
|
||||||
.WithMany(s => s.Redemptions)
|
.WithMany(s => s.Redemptions)
|
||||||
@@ -238,6 +276,103 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
modelBuilder.Entity<PurchaseReturnItem>()
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
.HasIndex(i => i.FK_PurchaseReturnId);
|
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturn>()
|
||||||
|
.HasOne(r => r.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FK_OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturn>()
|
||||||
|
.HasOne(r => r.CreatedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.CreatedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
|
.HasOne(i => i.SaleReturn)
|
||||||
|
.WithMany(r => r.Items)
|
||||||
|
.HasForeignKey(i => i.FK_SaleReturnId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SaleReturnItem>()
|
||||||
|
.HasIndex(i => i.FK_SaleReturnId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CreditNote>()
|
||||||
|
.HasOne(n => n.SaleReturn)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(n => n.FK_SaleReturnId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Claim>()
|
||||||
|
.HasOne(c => c.AgencyUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_AgencyUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Claim>()
|
||||||
|
.HasOne(c => c.SettledByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.SettledByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.PurchaseOrder)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.GoodsReceipt)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.FK_GoodsReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.RaisedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.RaisedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaim>()
|
||||||
|
.HasOne(c => c.ResolvedByUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.ResolvedByUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
|
.HasOne(i => i.SupplierClaim)
|
||||||
|
.WithMany(c => c.Items)
|
||||||
|
.HasForeignKey(i => i.FK_SupplierClaimId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SupplierClaimItem>()
|
||||||
|
.HasIndex(i => i.FK_SupplierClaimId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
|
.HasOne(t => t.Ride)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_RideId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DeliveryTask>()
|
||||||
|
.HasOne(t => t.Order)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.FK_OrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController(AuthService service) : ControllerBase
|
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
|
private static IActionResult ToActionResult(AuthHexResponse result) =>
|
||||||
|
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login(LoginRequest request)
|
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
|
||||||
|
ToActionResult(await service.LoginAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
|
||||||
|
ToActionResult(await service.RegisterAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpGet("me")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Me(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.LoginAsync(request);
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
return result is null ? Unauthorized() : Ok(result);
|
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Logout(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return ToActionResult(await service.LogoutAsync(token, ct));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ namespace Distribution.Features.Auth;
|
|||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public record LoginResponse(string Token, Guid UserId, string Username, string RoleName);
|
public record RegisterRequest(string Username, string Password, string? FullName);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
using Distribution.Features.User;
|
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Auth;
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService)
|
/// <summary>
|
||||||
|
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
|
||||||
|
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
|
||||||
|
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
|
||||||
|
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
|
||||||
|
/// </summary>
|
||||||
|
public class AuthService(IAuthHexClient authHex)
|
||||||
{
|
{
|
||||||
private readonly PasswordHasher<Models.User> _hasher = new();
|
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
|
||||||
|
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
|
|
||||||
public async Task<LoginResponse?> LoginAsync(LoginRequest request)
|
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
|
||||||
{
|
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
|
||||||
var user = await userRepo.GetByUsernameAsync(request.Username);
|
|
||||||
if (user is null || !user.IsActive)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
public Task<AuthHexResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default) =>
|
||||||
if (result == PasswordVerificationResult.Failed)
|
authHex.CallAsync("getUserDetails", new { userId }, ct: ct);
|
||||||
return null;
|
|
||||||
|
|
||||||
var token = tokenService.GenerateToken(user);
|
public Task<AuthHexResponse> LogoutAsync(string token, CancellationToken ct = default) =>
|
||||||
return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty);
|
authHex.CallAsync("LogoutUser", new { token }, ct: ct);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/claims/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/claims")]
|
||||||
|
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/claims/{id:guid}/settle")]
|
||||||
|
[Authorize(Policy = "claims.settle")]
|
||||||
|
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.SettleAsync(id, request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public record CreateClaimRequest(Guid AgencyUserId, string Details);
|
||||||
|
|
||||||
|
public record SettleClaimRequest(decimal SettlementAmount);
|
||||||
|
|
||||||
|
public record ClaimResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid AgencyUserId,
|
||||||
|
string Details,
|
||||||
|
ClaimStatus Status,
|
||||||
|
decimal? SettlementAmount,
|
||||||
|
DateTime? SettledAt,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public class ClaimRepository(AppDBContext context) : IClaimRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.Claim?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Claims.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||||
|
await context.Claims
|
||||||
|
.Where(c => c.FK_AgencyUserId == agencyUserId && c.IsActive)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Claim entity) =>
|
||||||
|
await context.Claims.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Claim entity) =>
|
||||||
|
context.Claims.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public class ClaimService(IClaimRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static ClaimResponse Map(Claim c) =>
|
||||||
|
new(c.Id, c.FK_AgencyUserId, c.Details, c.Status, c.SettlementAmount, c.SettledAt, c.IsActive, c.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<ClaimResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var c = await repo.GetByIdAsync(id);
|
||||||
|
if (c is null || !c.IsActive) return null;
|
||||||
|
return Map(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ClaimResponse>> GetByAgencyUserIdAsync(Guid agencyUserId) =>
|
||||||
|
(await repo.GetByAgencyUserIdAsync(agencyUserId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<ClaimResponse> CreateAsync(CreateClaimRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Details))
|
||||||
|
throw new InvalidOperationException("Claim details are required.");
|
||||||
|
|
||||||
|
var claim = new Claim
|
||||||
|
{
|
||||||
|
FK_AgencyUserId = request.AgencyUserId,
|
||||||
|
Details = request.Details
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClaimResponse> SettleAsync(Guid id, SettleClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var claim = await repo.GetByIdAsync(id);
|
||||||
|
if (claim is null || !claim.IsActive)
|
||||||
|
throw new KeyNotFoundException("Claim not found.");
|
||||||
|
if (claim.Status == ClaimStatus.Settled)
|
||||||
|
throw new InvalidOperationException("Claim is already settled.");
|
||||||
|
if (request.SettlementAmount < 0)
|
||||||
|
throw new InvalidOperationException("Settlement amount cannot be negative.");
|
||||||
|
|
||||||
|
claim.Status = ClaimStatus.Settled;
|
||||||
|
claim.SettlementAmount = request.SettlementAmount;
|
||||||
|
claim.SettledByUserId = approverId;
|
||||||
|
claim.SettledAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
|
public interface IClaimRepository
|
||||||
|
{
|
||||||
|
Task<Models.Claim?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Claim>> GetByAgencyUserIdAsync(Guid agencyUserId);
|
||||||
|
Task AddAsync(Models.Claim entity);
|
||||||
|
void Update(Models.Claim entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api")]
|
||||||
|
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||||
|
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.GenerateAsync(rideId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||||
|
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||||
|
{
|
||||||
|
var result = await service.GetByRideIdAsync(rideId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("delivery-tasks/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
||||||
|
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.SetQuantitiesAsync(id, request, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
||||||
|
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmAsync(id, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
||||||
|
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.FailAsync(id, request.Note, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||||
|
|
||||||
|
public record DeliveryTaskResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid FK_RideId,
|
||||||
|
Guid FK_OrderId,
|
||||||
|
Guid StoreId,
|
||||||
|
string StoreName,
|
||||||
|
int Sequence,
|
||||||
|
DeliveryTaskStatus Status,
|
||||||
|
bool CustomerConfirmed,
|
||||||
|
DateTime? ConfirmedAt,
|
||||||
|
string? Note,
|
||||||
|
List<DeliveryTaskItemResponse> Items,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
|
||||||
|
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
|
||||||
|
|
||||||
|
public record FailTaskRequest(string Note);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
|
||||||
|
{
|
||||||
|
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.DeliveryTasks
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.Store)
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.OrderItems)
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
||||||
|
await context.DeliveryTasks
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.Store)
|
||||||
|
.Include(t => t.Order)
|
||||||
|
.ThenInclude(o => o.OrderItems)
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.Where(t => t.FK_RideId == rideId)
|
||||||
|
.OrderBy(t => t.Sequence)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
|
||||||
|
await context.DeliveryTasks
|
||||||
|
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
|
||||||
|
|
||||||
|
public async Task AddAsync(DeliveryTask task) =>
|
||||||
|
await context.DeliveryTasks.AddAsync(task);
|
||||||
|
|
||||||
|
public void Update(DeliveryTask task) =>
|
||||||
|
context.DeliveryTasks.Update(task);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.Ride;
|
||||||
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public class DeliveryTaskService(
|
||||||
|
IDeliveryTaskRepository repo,
|
||||||
|
IRideRepository rideRepo,
|
||||||
|
IOrderRepository orderRepo,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||||
|
new(t.Id,
|
||||||
|
t.FK_RideId,
|
||||||
|
t.FK_OrderId,
|
||||||
|
t.Order.FK_StoreId,
|
||||||
|
t.Order.Store?.StoreName ?? string.Empty,
|
||||||
|
t.Sequence,
|
||||||
|
t.Status,
|
||||||
|
t.CustomerConfirmed,
|
||||||
|
t.ConfirmedAt,
|
||||||
|
t.Note,
|
||||||
|
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
|
||||||
|
i.FK_ItemId,
|
||||||
|
i.Item?.Name ?? string.Empty,
|
||||||
|
i.Quantity,
|
||||||
|
i.QuantityFulfilled,
|
||||||
|
i.UnitPrice)).ToList(),
|
||||||
|
t.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
if (ride.Status != RideStatus.Active)
|
||||||
|
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
|
||||||
|
|
||||||
|
if (ride.RideType != RideType.Delivery)
|
||||||
|
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
|
||||||
|
|
||||||
|
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
|
||||||
|
|
||||||
|
var created = new List<DeliveryTask>();
|
||||||
|
int sequence = 1;
|
||||||
|
|
||||||
|
foreach (var order in orders)
|
||||||
|
{
|
||||||
|
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var task = new DeliveryTask
|
||||||
|
{
|
||||||
|
FK_RideId = rideId,
|
||||||
|
FK_OrderId = order.Id,
|
||||||
|
Sequence = sequence++,
|
||||||
|
Status = DeliveryTaskStatus.Pending
|
||||||
|
};
|
||||||
|
created.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created.Count == 0)
|
||||||
|
return new List<DeliveryTaskResponse>();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var task in created)
|
||||||
|
await repo.AddAsync(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByRideIdAsync(rideId);
|
||||||
|
return saved.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
||||||
|
{
|
||||||
|
var tasks = await repo.GetByRideIdAsync(rideId);
|
||||||
|
return tasks.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(id);
|
||||||
|
return task is null ? null : ToResponse(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
|
|
||||||
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Quantities can only be set for a Pending task");
|
||||||
|
|
||||||
|
foreach (var input in request.Items)
|
||||||
|
{
|
||||||
|
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
|
||||||
|
|
||||||
|
if (input.QuantityFulfilled < 0)
|
||||||
|
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
||||||
|
|
||||||
|
if (input.QuantityFulfilled > orderItem.Quantity)
|
||||||
|
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
||||||
|
|
||||||
|
orderItem.QuantityFulfilled = input.QuantityFulfilled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
|
|
||||||
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
||||||
|
|
||||||
|
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
|
||||||
|
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var vehicleId = ride.FK_VehicleId;
|
||||||
|
|
||||||
|
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
|
||||||
|
|
||||||
|
task.CustomerConfirmed = true;
|
||||||
|
task.ConfirmedAt = DateTime.Now;
|
||||||
|
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
||||||
|
|
||||||
|
var order = task.Order;
|
||||||
|
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
|
||||||
|
if (fullyDelivered)
|
||||||
|
order.FulfilledAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
|
||||||
|
{
|
||||||
|
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
|
||||||
|
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
|
||||||
|
|
||||||
|
if (vehicleBalance.Quantity < item.QuantityFulfilled)
|
||||||
|
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
|
||||||
|
|
||||||
|
vehicleBalance.Quantity -= item.QuantityFulfilled;
|
||||||
|
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(vehicleBalance);
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = item.FK_ItemId,
|
||||||
|
FromLocationId = vehicleId,
|
||||||
|
FromLocationType = LocationType.Vehicle,
|
||||||
|
Quantity = item.QuantityFulfilled,
|
||||||
|
Type = MovementType.Sale,
|
||||||
|
ReferenceId = task.FK_OrderId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(task);
|
||||||
|
orderRepo.Update(order);
|
||||||
|
|
||||||
|
if (fullyDelivered)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||||
|
?? throw new KeyNotFoundException("Sale not found");
|
||||||
|
sale.Status = SaleStatus.Completed;
|
||||||
|
sale.CompletedAt = DateTime.Now;
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(taskId);
|
||||||
|
return ToResponse(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await repo.GetByIdAsync(taskId)
|
||||||
|
?? throw new KeyNotFoundException("Delivery task not found");
|
||||||
|
|
||||||
|
if (task.Status != DeliveryTaskStatus.Pending)
|
||||||
|
throw new InvalidOperationException("Only a Pending task can be failed");
|
||||||
|
|
||||||
|
task.Status = DeliveryTaskStatus.Failed;
|
||||||
|
task.Note = note;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(task);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
|
public interface IDeliveryTaskRepository
|
||||||
|
{
|
||||||
|
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
||||||
|
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
||||||
|
Task AddAsync(DeliveryTask task);
|
||||||
|
void Update(DeliveryTask task);
|
||||||
|
}
|
||||||
@@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
|||||||
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
||||||
{
|
{
|
||||||
var groups = await context.OrderItems
|
var groups = await context.OrderItems
|
||||||
.Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId)
|
.Where(oi => oi.IsActive
|
||||||
|
&& oi.Order.IsActive
|
||||||
|
&& oi.Order.Store.FK_RouteId == routeId
|
||||||
|
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
|
||||||
.GroupBy(oi => oi.FK_ItemId)
|
.GroupBy(oi => oi.FK_ItemId)
|
||||||
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
|
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ public interface IOrderRepository
|
|||||||
{
|
{
|
||||||
Task<Models.Order?> GetByIdAsync(Guid id);
|
Task<Models.Order?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.Order>> GetAllAsync();
|
Task<List<Models.Order>> GetAllAsync();
|
||||||
|
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
|
||||||
Task AddAsync(Models.Order entity);
|
Task AddAsync(Models.Order entity);
|
||||||
void Update(Models.Order entity);
|
void Update(Models.Order entity);
|
||||||
void Remove(Models.Order entity);
|
void Remove(Models.Order entity);
|
||||||
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
||||||
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
||||||
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
||||||
|
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
[Authorize(Policy = "orders.create")]
|
[Authorize(Policy = "orders.create")]
|
||||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
try
|
||||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
{
|
||||||
|
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}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.update")]
|
[Authorize(Policy = "orders.update")]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
try
|
||||||
return result is null ? NotFound() : Ok(result);
|
{
|
||||||
|
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}")]
|
[HttpDelete("{id:guid}")]
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
public record CreateOrderRequest(Guid SaleId);
|
||||||
|
|
||||||
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
|
public record UpdateOrderRequest(OrderStatus Status);
|
||||||
|
|
||||||
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
|
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||||
|
|
||||||
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
|
|
||||||
|
|
||||||
public record OrderResponse(
|
public record OrderResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
Guid SaleId,
|
||||||
DateTime OrderDate,
|
DateTime OrderDate,
|
||||||
Guid StoreId,
|
Guid StoreId,
|
||||||
decimal TotalAmount,
|
decimal TotalAmount,
|
||||||
|
OrderStatus Status,
|
||||||
|
DateTime? FulfilledAt,
|
||||||
List<OrderItemResponse> Items,
|
List<OrderItemResponse> Items,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
public async Task<List<Models.Order>> GetAllAsync() =>
|
public async Task<List<Models.Order>> GetAllAsync() =>
|
||||||
await context.Orders.ToListAsync();
|
await context.Orders.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
|
||||||
|
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
|
||||||
|
|
||||||
public async Task AddAsync(Models.Order entity) =>
|
public async Task AddAsync(Models.Order entity) =>
|
||||||
await context.Orders.AddAsync(entity);
|
await context.Orders.AddAsync(entity);
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
|
|
||||||
public void Remove(Models.Order entity)
|
public void Remove(Models.Order entity)
|
||||||
{
|
{
|
||||||
|
entity.Status = Models.OrderStatus.Cancelled;
|
||||||
entity.IsActive = false;
|
entity.IsActive = false;
|
||||||
context.Orders.Update(entity);
|
context.Orders.Update(entity);
|
||||||
}
|
}
|
||||||
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
|||||||
|
|
||||||
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
||||||
await context.OrderItems.AddRangeAsync(items);
|
await context.OrderItems.AddRangeAsync(items);
|
||||||
|
|
||||||
|
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
|
||||||
|
await context.Orders
|
||||||
|
.Include(o => o.Store)
|
||||||
|
.Include(o => o.OrderItems)
|
||||||
|
.ThenInclude(i => i.Item)
|
||||||
|
.Where(o => o.IsActive
|
||||||
|
&& o.Store.FK_RouteId == routeId
|
||||||
|
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Features.Sale;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
@@ -15,16 +15,16 @@ public class OrderService(
|
|||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
IStockRepository stockRepo,
|
IStockRepository stockRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IScopeAccessService scopeAccess,
|
IScopeAccessService scopeAccess)
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
{
|
||||||
private const int GlobalScopeRoleLevel = 50;
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
|
|
||||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||||
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
|
||||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
|
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
||||||
o.IsActive, o.CreatedAt);
|
o.IsActive, o.CreatedAt);
|
||||||
|
|
||||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||||
@@ -35,42 +35,6 @@ public class OrderService(
|
|||||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
|
|
||||||
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
|
|
||||||
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
|
|
||||||
/// can be resolved, or if the requested price is outside the allowed band.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
|
|
||||||
{
|
|
||||||
var item = await itemRepo.GetByIdAsync(itemId);
|
|
||||||
if (item is null) return null;
|
|
||||||
var regionId = store.Route?.FK_RegionId;
|
|
||||||
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
|
|
||||||
if (resolution is null) return null;
|
|
||||||
|
|
||||||
if (!requestedUnitPrice.HasValue) return resolution.Price;
|
|
||||||
|
|
||||||
var min = resolution.MinPrice ?? resolution.Price;
|
|
||||||
var max = resolution.Price;
|
|
||||||
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
|
|
||||||
|
|
||||||
return requestedUnitPrice.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
|
|
||||||
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
|
|
||||||
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
|
|
||||||
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
|
|
||||||
{
|
|
||||||
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
|
|
||||||
var totalAvailable = balances.Sum(b => b.Quantity);
|
|
||||||
return quantity <= totalAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<OrderResponse>> GetAllAsync()
|
public async Task<List<OrderResponse>> GetAllAsync()
|
||||||
{
|
{
|
||||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
||||||
@@ -112,52 +76,15 @@ public class OrderService(
|
|||||||
|
|
||||||
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
var sale = await saleRepo.GetByIdAsync(request.SaleId);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||||
|
|
||||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
|
||||||
if (store is null || !store.IsActive) return null;
|
?? throw new InvalidOperationException("Orders are created automatically from sales");
|
||||||
if (store.KycStatus != KycStatus.Verified)
|
|
||||||
{
|
|
||||||
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
|
||||||
if (request.TotalAmount > threshold) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var order = new Models.Order
|
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
|
||||||
{
|
return ToResponse(existingOrder, items);
|
||||||
FK_StoreId = request.StoreId,
|
|
||||||
OrderDate = request.OrderDate,
|
|
||||||
TotalAmount = request.TotalAmount
|
|
||||||
};
|
|
||||||
var orderItems = new List<Models.OrderItem>();
|
|
||||||
foreach (var i in request.Items)
|
|
||||||
{
|
|
||||||
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
|
|
||||||
|
|
||||||
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
|
||||||
if (unitPrice is null) return null;
|
|
||||||
|
|
||||||
orderItems.Add(new Models.OrderItem
|
|
||||||
{
|
|
||||||
FK_OrderId = order.Id,
|
|
||||||
FK_ItemId = i.ItemId,
|
|
||||||
Quantity = i.Quantity,
|
|
||||||
UnitPrice = unitPrice.Value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await repo.AddAsync(order);
|
|
||||||
await repo.AddItemsAsync(orderItems);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await uow.RollbackAsync(ct);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
return ToResponse(order, orderItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
||||||
@@ -165,12 +92,26 @@ public class OrderService(
|
|||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return null;
|
if (order is null || !order.IsActive) return null;
|
||||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
||||||
order.OrderDate = request.OrderDate;
|
|
||||||
order.TotalAmount = request.TotalAmount;
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||||
|
?? throw new InvalidOperationException("Sale not found for this order");
|
||||||
|
|
||||||
|
order.Status = request.Status;
|
||||||
|
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||||
|
|
||||||
|
sale.Status = request.Status switch
|
||||||
|
{
|
||||||
|
OrderStatus.Fulfilled => SaleStatus.Completed,
|
||||||
|
OrderStatus.Cancelled => SaleStatus.Cancelled,
|
||||||
|
_ => SaleStatus.Pending
|
||||||
|
};
|
||||||
|
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Update(order);
|
repo.Update(order);
|
||||||
|
saleRepo.Update(sale);
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -178,6 +119,7 @@ public class OrderService(
|
|||||||
await uow.RollbackAsync(ct);
|
await uow.RollbackAsync(ct);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = await repo.GetItemsByOrderIdAsync(id);
|
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||||
return ToResponse(order, items);
|
return ToResponse(order, items);
|
||||||
}
|
}
|
||||||
@@ -187,10 +129,19 @@ public class OrderService(
|
|||||||
var order = await repo.GetByIdAsync(id);
|
var order = await repo.GetByIdAsync(id);
|
||||||
if (order is null || !order.IsActive) return false;
|
if (order is null || !order.IsActive) return false;
|
||||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
|
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
|
||||||
|
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Remove(order);
|
repo.Remove(order);
|
||||||
|
if (sale is not null)
|
||||||
|
{
|
||||||
|
sale.Status = SaleStatus.Cancelled;
|
||||||
|
sale.CompletedAt = null;
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
}
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public interface IPaymentRepository
|
||||||
|
{
|
||||||
|
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||||
|
Task AddAsync(Models.Payment payment);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/sales/{saleId:guid}/payments")]
|
||||||
|
public class PaymentController(PaymentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetBySale(Guid saleId)
|
||||||
|
{
|
||||||
|
var result = await service.GetBySaleIdAsync(saleId);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(saleId, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||||
|
|
||||||
|
public record PaymentResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid SaleId,
|
||||||
|
Guid StoreId,
|
||||||
|
decimal Amount,
|
||||||
|
PaymentMethod Method,
|
||||||
|
DateTime CollectedAt,
|
||||||
|
Guid CollectedBy,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||||
|
{
|
||||||
|
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||||
|
.OrderBy(p => p.CollectedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Payment payment) =>
|
||||||
|
await context.Payments.AddAsync(payment);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
public class PaymentService(
|
||||||
|
IPaymentRepository repo,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser)
|
||||||
|
{
|
||||||
|
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||||
|
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
|
||||||
|
var payments = await repo.GetBySaleIdAsync(saleId);
|
||||||
|
return payments.Select(ToResponse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (sale.Status == SaleStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
|
||||||
|
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
|
||||||
|
var payment = new Models.Payment
|
||||||
|
{
|
||||||
|
FK_SaleId = saleId,
|
||||||
|
FK_StoreId = sale.FK_StoreId,
|
||||||
|
Amount = request.Amount,
|
||||||
|
Method = request.Method,
|
||||||
|
CollectedAt = DateTime.Now,
|
||||||
|
CollectedBy = userId
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(payment);
|
||||||
|
|
||||||
|
var allPayments = await repo.GetBySaleIdAsync(saleId);
|
||||||
|
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||||
|
|
||||||
|
sale.PaymentStatus = total == 0
|
||||||
|
? PaymentStatus.Pending
|
||||||
|
: total < sale.TotalAmount
|
||||||
|
? PaymentStatus.PartiallyPaid
|
||||||
|
: PaymentStatus.Paid;
|
||||||
|
|
||||||
|
saleRepo.Update(sale);
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(payment);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public interface IReturnRepository
|
||||||
|
{
|
||||||
|
Task<Models.SaleReturn?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId);
|
||||||
|
Task AddAsync(Models.SaleReturn entity);
|
||||||
|
Task AddCreditNoteAsync(Models.CreditNote entity);
|
||||||
|
|
||||||
|
/// <summary>Whether this store has ever had this item on any of its (active) orders.</summary>
|
||||||
|
Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/returns/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> GetByOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(id, request, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public record ReturnItemRequest(Guid ItemId, decimal Quantity, ReturnReasonCode ReasonCode);
|
||||||
|
|
||||||
|
public record CreateSaleReturnRequest(ReturnMode Mode, List<ReturnItemRequest> Items);
|
||||||
|
|
||||||
|
public record SaleReturnItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
decimal Quantity,
|
||||||
|
ReturnReasonCode ReasonCode,
|
||||||
|
decimal UnitPrice);
|
||||||
|
|
||||||
|
public record SaleReturnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid OrderId,
|
||||||
|
ReturnMode Mode,
|
||||||
|
decimal TotalCreditAmount,
|
||||||
|
decimal OrderTotalAmountAfter,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<SaleReturnItemResponse> Items);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public class ReturnRepository(AppDBContext context) : IReturnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.SaleReturn?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.SaleReturns
|
||||||
|
.Include(r => r.Order)
|
||||||
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.SaleReturn>> GetByOrderIdAsync(Guid orderId) =>
|
||||||
|
await context.SaleReturns
|
||||||
|
.Include(r => r.Order)
|
||||||
|
.Include(r => r.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(r => r.FK_OrderId == orderId && r.IsActive)
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.SaleReturn entity) =>
|
||||||
|
await context.SaleReturns.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task AddCreditNoteAsync(Models.CreditNote entity) =>
|
||||||
|
await context.CreditNotes.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<bool> StoreHasPriorSaleAsync(Guid storeId, Guid itemId) =>
|
||||||
|
await context.OrderItems
|
||||||
|
.Include(oi => oi.Order)
|
||||||
|
.AnyAsync(oi => oi.Order.FK_StoreId == storeId && oi.Order.IsActive && oi.FK_ItemId == itemId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.Ride;
|
||||||
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
|
public class ReturnService(
|
||||||
|
IReturnRepository repo,
|
||||||
|
IOrderRepository orderRepo,
|
||||||
|
ISaleRepository saleRepo,
|
||||||
|
IRideRepository rideRepo,
|
||||||
|
IItemRepository itemRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.ReasonCode, i.UnitPrice);
|
||||||
|
|
||||||
|
private static SaleReturnResponse Map(SaleReturn r) =>
|
||||||
|
new(r.Id, r.FK_OrderId, r.Mode, r.TotalCreditAmount, r.Order.TotalAmount, r.IsActive, r.CreatedAt,
|
||||||
|
r.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<SaleReturnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var r = await repo.GetByIdAsync(id);
|
||||||
|
if (r is null || !r.IsActive) return null;
|
||||||
|
return Map(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SaleReturnResponse>> GetByOrderIdAsync(Guid orderId) =>
|
||||||
|
(await repo.GetByOrderIdAsync(orderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a return recognized during the same store visit as an open order and settles it
|
||||||
|
/// immediately: stock is credited back to the rep's vehicle, and either the order total is
|
||||||
|
/// reduced (CreditDeduction) or the matching order line quantities are reduced (ItemExchange).
|
||||||
|
/// A return is only allowed for an item the store has previously ordered.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SaleReturnResponse> CreateAsync(Guid orderId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var order = await orderRepo.GetByIdAsync(orderId);
|
||||||
|
if (order is null || !order.IsActive)
|
||||||
|
throw new InvalidOperationException("Order not found.");
|
||||||
|
if (order.Status is OrderStatus.Cancelled or OrderStatus.Fulfilled)
|
||||||
|
throw new InvalidOperationException("Cannot record a return against a closed order.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A return must have at least one line item.");
|
||||||
|
|
||||||
|
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||||
|
?? throw new InvalidOperationException("Sale not found for this order.");
|
||||||
|
if (sale.FK_RideId is null)
|
||||||
|
throw new InvalidOperationException("A return can only be recorded for an order created during an active ride.");
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sale.FK_RideId.Value)
|
||||||
|
?? throw new InvalidOperationException("Ride not found.");
|
||||||
|
var vehicleId = ride.FK_VehicleId;
|
||||||
|
|
||||||
|
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
||||||
|
var orderItemsByItemId = orderItems.ToDictionary(oi => oi.FK_ItemId);
|
||||||
|
|
||||||
|
var saleReturn = new SaleReturn
|
||||||
|
{
|
||||||
|
FK_OrderId = orderId,
|
||||||
|
Mode = request.Mode,
|
||||||
|
CreatedByUserId = createdByUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||||
|
|
||||||
|
var item = await itemRepo.GetByIdAsync(line.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {line.ItemId} not found.");
|
||||||
|
|
||||||
|
if (!await repo.StoreHasPriorSaleAsync(order.FK_StoreId, line.ItemId))
|
||||||
|
throw new InvalidOperationException($"No prior sale of item '{item.Name}' found for this store.");
|
||||||
|
|
||||||
|
var unitPrice = orderItemsByItemId.TryGetValue(line.ItemId, out var matchingOrderItem)
|
||||||
|
? matchingOrderItem.UnitPrice
|
||||||
|
: item.SalePrice ?? 0;
|
||||||
|
|
||||||
|
saleReturn.Items.Add(new SaleReturnItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
ReasonCode = line.ReasonCode,
|
||||||
|
UnitPrice = unitPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var line in saleReturn.Items)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, vehicleId);
|
||||||
|
if (balance is null)
|
||||||
|
{
|
||||||
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
LocationId = vehicleId,
|
||||||
|
LocationType = LocationType.Vehicle,
|
||||||
|
Quantity = line.Quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
balance.Quantity += line.Quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
ToLocationId = vehicleId,
|
||||||
|
ToLocationType = LocationType.Vehicle,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
Type = MovementType.CustomerReturn,
|
||||||
|
ReasonCode = line.ReasonCode.ToString(),
|
||||||
|
ReferenceId = saleReturn.Id,
|
||||||
|
CreatedBy = createdByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.AddAsync(saleReturn);
|
||||||
|
|
||||||
|
if (request.Mode == ReturnMode.CreditDeduction)
|
||||||
|
{
|
||||||
|
var total = saleReturn.Items.Sum(i => i.Quantity * i.UnitPrice);
|
||||||
|
saleReturn.TotalCreditAmount = total;
|
||||||
|
order.TotalAmount = Math.Max(0, order.TotalAmount - total);
|
||||||
|
orderRepo.Update(order);
|
||||||
|
|
||||||
|
await repo.AddCreditNoteAsync(new CreditNote
|
||||||
|
{
|
||||||
|
FK_SaleReturnId = saleReturn.Id,
|
||||||
|
Amount = total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else // ItemExchange
|
||||||
|
{
|
||||||
|
foreach (var line in saleReturn.Items)
|
||||||
|
{
|
||||||
|
if (!orderItemsByItemId.TryGetValue(line.FK_ItemId, out var orderItem))
|
||||||
|
continue; // no matching line on this order — nothing to offset
|
||||||
|
|
||||||
|
var offsetQty = Math.Min(line.Quantity, orderItem.Quantity);
|
||||||
|
orderItem.Quantity -= (int)offsetQty;
|
||||||
|
order.TotalAmount = Math.Max(0, order.TotalAmount - offsetQty * orderItem.UnitPrice);
|
||||||
|
}
|
||||||
|
orderRepo.Update(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(saleReturn.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Sale;
|
||||||
|
|
||||||
|
public interface ISaleRepository
|
||||||
|
{
|
||||||
|
Task<Models.Sale?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Sale>> GetAllAsync();
|
||||||
|
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
||||||
|
Task AddAsync(Models.Sale sale);
|
||||||
|
void Update(Models.Sale sale);
|
||||||
|
void Remove(Models.Sale sale);
|
||||||
|
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Sale;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/sales")]
|
||||||
|
[Authorize]
|
||||||
|
public class SaleController(SaleService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Policy = "orders.view")]
|
||||||
|
public async Task<IActionResult> GetAll() =>
|
||||||
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize(Policy = "orders.view")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("store/{storeId:guid}")]
|
||||||
|
[Authorize(Policy = "orders.view")]
|
||||||
|
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||||
|
{
|
||||||
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
|
return result is null ? Forbid() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = "orders.create")]
|
||||||
|
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, ct);
|
||||||
|
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.PriceList;
|
||||||
|
using Distribution.Features.Ride;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.Store;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Sale;
|
||||||
|
|
||||||
|
public class SaleService(
|
||||||
|
ISaleRepository repo,
|
||||||
|
IOrderRepository orderRepo,
|
||||||
|
IStoreRepository storeRepo,
|
||||||
|
IItemRepository itemRepo,
|
||||||
|
PriceListService priceListService,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IRideRepository rideRepo,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
ICurrentUserService currentUser,
|
||||||
|
IScopeAccessService scopeAccess,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private const int GlobalScopeRoleLevel = 50;
|
||||||
|
|
||||||
|
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||||
|
|
||||||
|
private async Task<bool> HasStoreScopeAsync(Guid storeId)
|
||||||
|
{
|
||||||
|
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
|
||||||
|
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
|
||||||
|
{
|
||||||
|
var item = await itemRepo.GetByIdAsync(itemId);
|
||||||
|
if (item is null) return null;
|
||||||
|
|
||||||
|
var regionId = store.Route?.FK_RegionId;
|
||||||
|
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
|
||||||
|
if (resolution is null) return null;
|
||||||
|
|
||||||
|
if (!requestedUnitPrice.HasValue) return resolution.Price;
|
||||||
|
|
||||||
|
var min = resolution.MinPrice ?? resolution.Price;
|
||||||
|
var max = resolution.Price;
|
||||||
|
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
|
||||||
|
|
||||||
|
return requestedUnitPrice.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
|
||||||
|
new(
|
||||||
|
sale.Id,
|
||||||
|
sale.SaleDate,
|
||||||
|
sale.FK_StoreId,
|
||||||
|
sale.CreatedByUserId,
|
||||||
|
sale.FK_RideId,
|
||||||
|
sale.TotalAmount,
|
||||||
|
sale.Status,
|
||||||
|
sale.PaymentStatus,
|
||||||
|
sale.FulfillImmediately,
|
||||||
|
sale.CompletedAt,
|
||||||
|
order.Id,
|
||||||
|
order.Status,
|
||||||
|
order.FulfilledAt,
|
||||||
|
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
||||||
|
sale.IsActive,
|
||||||
|
sale.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<List<SaleResponse>> GetAllAsync()
|
||||||
|
{
|
||||||
|
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
|
||||||
|
if (!HasGlobalScope && currentUser.UserId is not null)
|
||||||
|
{
|
||||||
|
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
|
||||||
|
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<SaleResponse>();
|
||||||
|
foreach (var sale in sales)
|
||||||
|
{
|
||||||
|
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
|
||||||
|
if (order is null) continue;
|
||||||
|
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||||
|
result.Add(ToResponse(sale, order, items));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SaleResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var sale = await repo.GetByIdAsync(id);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||||
|
|
||||||
|
var order = await repo.GetOrderBySaleIdAsync(id);
|
||||||
|
if (order is null) return null;
|
||||||
|
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||||
|
return ToResponse(sale, order, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
|
||||||
|
{
|
||||||
|
if (!await HasStoreScopeAsync(storeId)) return null;
|
||||||
|
|
||||||
|
var sales = await repo.GetByStoreIdAsync(storeId);
|
||||||
|
var result = new List<SaleResponse>();
|
||||||
|
foreach (var sale in sales)
|
||||||
|
{
|
||||||
|
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
|
||||||
|
if (order is null) continue;
|
||||||
|
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||||
|
result.Add(ToResponse(sale, order, items));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||||
|
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||||
|
if (store is null || !store.IsActive) return null;
|
||||||
|
|
||||||
|
if (store.KycStatus != KycStatus.Verified)
|
||||||
|
{
|
||||||
|
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
||||||
|
if (request.TotalAmount > threshold) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
|
||||||
|
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
|
||||||
|
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
|
||||||
|
|
||||||
|
var sale = new Models.Sale
|
||||||
|
{
|
||||||
|
FK_StoreId = request.StoreId,
|
||||||
|
SaleDate = request.SaleDate,
|
||||||
|
CreatedByUserId = userId,
|
||||||
|
FK_RideId = request.RideId,
|
||||||
|
TotalAmount = request.TotalAmount,
|
||||||
|
Status = saleStatus,
|
||||||
|
FulfillImmediately = request.FulfillImmediately,
|
||||||
|
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
|
||||||
|
};
|
||||||
|
|
||||||
|
var orderItems = new List<Models.OrderItem>();
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
{
|
||||||
|
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
|
||||||
|
if (unitPrice is null) return null;
|
||||||
|
|
||||||
|
orderItems.Add(new Models.OrderItem
|
||||||
|
{
|
||||||
|
FK_ItemId = item.ItemId,
|
||||||
|
Quantity = item.Quantity,
|
||||||
|
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
|
||||||
|
UnitPrice = unitPrice.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var order = new Models.Order
|
||||||
|
{
|
||||||
|
FK_SaleId = sale.Id,
|
||||||
|
FK_StoreId = request.StoreId,
|
||||||
|
OrderDate = request.SaleDate,
|
||||||
|
TotalAmount = request.TotalAmount,
|
||||||
|
Status = orderStatus,
|
||||||
|
FulfilledAt = fulfilledAt
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var orderItem in orderItems)
|
||||||
|
orderItem.FK_OrderId = order.Id;
|
||||||
|
|
||||||
|
Guid? vehicleId = null;
|
||||||
|
if (request.FulfillImmediately && request.RideId.HasValue)
|
||||||
|
{
|
||||||
|
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
|
||||||
|
if (ride is null) return null;
|
||||||
|
vehicleId = ride.FK_VehicleId;
|
||||||
|
|
||||||
|
foreach (var item in orderItems)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
|
||||||
|
if (balance is null || balance.Quantity < item.Quantity)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(sale);
|
||||||
|
await orderRepo.AddAsync(order);
|
||||||
|
await orderRepo.AddItemsAsync(orderItems);
|
||||||
|
|
||||||
|
if (vehicleId.HasValue)
|
||||||
|
{
|
||||||
|
foreach (var item in orderItems)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
|
||||||
|
balance!.Quantity -= item.Quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = item.FK_ItemId,
|
||||||
|
FromLocationId = vehicleId.Value,
|
||||||
|
FromLocationType = LocationType.Vehicle,
|
||||||
|
Quantity = item.Quantity,
|
||||||
|
Type = MovementType.Sale,
|
||||||
|
ReferenceId = order.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToResponse(sale, order, orderItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Distribution.Features.SupplierClaims;
|
||||||
|
|
||||||
|
public interface ISupplierClaimRepository
|
||||||
|
{
|
||||||
|
Task<Models.SupplierClaim?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
|
Task AddAsync(Models.SupplierClaim entity);
|
||||||
|
void Update(Models.SupplierClaim entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.SupplierClaims;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/supplier-claims/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/purchase-orders/{id:guid}/claims")]
|
||||||
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/purchase-orders/{id:guid}/claims")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(id, request, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
||||||
|
[Authorize(Policy = "suppliers.claims.approve")]
|
||||||
|
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ApproveAsync(id, request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
||||||
|
[Authorize(Policy = "suppliers.claims.approve")]
|
||||||
|
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.RejectAsync(id, request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.SupplierClaims;
|
||||||
|
|
||||||
|
public record SupplierClaimItemRequest(Guid ItemId, decimal Quantity, SupplierClaimIssueType IssueType);
|
||||||
|
|
||||||
|
public record CreateSupplierClaimRequest(
|
||||||
|
Guid? GoodsReceiptId,
|
||||||
|
string Details,
|
||||||
|
string? EvidenceUrl,
|
||||||
|
List<SupplierClaimItemRequest> Items);
|
||||||
|
|
||||||
|
public record ApproveSupplierClaimRequest(
|
||||||
|
SupplierClaimResolutionType ResolutionType,
|
||||||
|
decimal? ResolutionAmount,
|
||||||
|
string? ResolutionNotes);
|
||||||
|
|
||||||
|
public record RejectSupplierClaimRequest(string? ResolutionNotes);
|
||||||
|
|
||||||
|
public record SupplierClaimItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
decimal Quantity,
|
||||||
|
SupplierClaimIssueType IssueType);
|
||||||
|
|
||||||
|
public record SupplierClaimResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
Guid? GoodsReceiptId,
|
||||||
|
string Details,
|
||||||
|
string? EvidenceUrl,
|
||||||
|
SupplierClaimStatus Status,
|
||||||
|
SupplierClaimResolutionType? ResolutionType,
|
||||||
|
decimal? ResolutionAmount,
|
||||||
|
string? ResolutionNotes,
|
||||||
|
DateTime? ResolvedAt,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<SupplierClaimItemResponse> Items);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.SupplierClaims;
|
||||||
|
|
||||||
|
public class SupplierClaimRepository(AppDBContext context) : ISupplierClaimRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.SupplierClaim?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.SupplierClaims
|
||||||
|
.Include(c => c.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.SupplierClaim>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.SupplierClaims
|
||||||
|
.Include(c => c.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(c => c.FK_PurchaseOrderId == purchaseOrderId && c.IsActive)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.SupplierClaim entity) =>
|
||||||
|
await context.SupplierClaims.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.SupplierClaim entity) =>
|
||||||
|
context.SupplierClaims.Update(entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.SupplierClaims;
|
||||||
|
|
||||||
|
public class SupplierClaimService(
|
||||||
|
ISupplierClaimRepository repo,
|
||||||
|
IPoRepository poRepo,
|
||||||
|
IGrnRepository grnRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static SupplierClaimItemResponse MapItem(SupplierClaimItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.IssueType);
|
||||||
|
|
||||||
|
private static SupplierClaimResponse Map(SupplierClaim c) =>
|
||||||
|
new(c.Id, c.FK_PurchaseOrderId, c.FK_GoodsReceiptId, c.Details, c.EvidenceUrl, c.Status,
|
||||||
|
c.ResolutionType, c.ResolutionAmount, c.ResolutionNotes, c.ResolvedAt, c.IsActive, c.CreatedAt,
|
||||||
|
c.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<SupplierClaimResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var c = await repo.GetByIdAsync(id);
|
||||||
|
if (c is null || !c.IsActive) return null;
|
||||||
|
return Map(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SupplierClaimResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<SupplierClaimResponse> CreateAsync(Guid purchaseOrderId, CreateSupplierClaimRequest request, Guid raisedByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||||
|
if (po is null || !po.IsActive)
|
||||||
|
throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Details))
|
||||||
|
throw new InvalidOperationException("Claim details are required.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A supplier claim must have at least one line item.");
|
||||||
|
|
||||||
|
if (request.GoodsReceiptId is not null)
|
||||||
|
{
|
||||||
|
var grn = await grnRepo.GetByIdAsync(request.GoodsReceiptId.Value)
|
||||||
|
?? throw new InvalidOperationException("Goods receipt not found.");
|
||||||
|
if (grn.FK_PurchaseOrderId != purchaseOrderId)
|
||||||
|
throw new InvalidOperationException("Goods receipt does not belong to this purchase order.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var claim = new SupplierClaim
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = purchaseOrderId,
|
||||||
|
FK_GoodsReceiptId = request.GoodsReceiptId,
|
||||||
|
Details = request.Details,
|
||||||
|
EvidenceUrl = request.EvidenceUrl,
|
||||||
|
RaisedByUserId = raisedByUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Claim quantity must be greater than zero.");
|
||||||
|
|
||||||
|
claim.Items.Add(new SupplierClaimItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
IssueType = line.IssueType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(claim.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SupplierClaimResponse> ApproveAsync(Guid id, ApproveSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var claim = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Supplier claim not found.");
|
||||||
|
if (claim.Status != SupplierClaimStatus.Open)
|
||||||
|
throw new InvalidOperationException("Only an open claim can be approved.");
|
||||||
|
|
||||||
|
if (request.ResolutionType != SupplierClaimResolutionType.Replacement && request.ResolutionAmount is null)
|
||||||
|
throw new InvalidOperationException("ResolutionAmount is required for Credit, Refund, and PriceAdjustment.");
|
||||||
|
|
||||||
|
claim.Status = SupplierClaimStatus.Approved;
|
||||||
|
claim.ResolutionType = request.ResolutionType;
|
||||||
|
claim.ResolutionAmount = request.ResolutionType == SupplierClaimResolutionType.Replacement ? null : request.ResolutionAmount;
|
||||||
|
claim.ResolutionNotes = request.ResolutionNotes;
|
||||||
|
claim.ResolvedByUserId = approverId;
|
||||||
|
claim.ResolvedAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SupplierClaimResponse> RejectAsync(Guid id, RejectSupplierClaimRequest request, Guid approverId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var claim = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Supplier claim not found.");
|
||||||
|
if (claim.Status != SupplierClaimStatus.Open)
|
||||||
|
throw new InvalidOperationException("Only an open claim can be rejected.");
|
||||||
|
|
||||||
|
claim.Status = SupplierClaimStatus.Rejected;
|
||||||
|
claim.ResolutionNotes = request.ResolutionNotes;
|
||||||
|
claim.ResolvedByUserId = approverId;
|
||||||
|
claim.ResolvedAt = DateTime.Now;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Update(claim);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(claim);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
|
||||||
|
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
|
||||||
|
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
|
||||||
|
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthHexClient(HttpClient http) : IAuthHexClient
|
||||||
|
{
|
||||||
|
public async Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var body = new { functionName, payload, reference };
|
||||||
|
var response = await http.PostAsJsonAsync("/api/user", body, ct);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<AuthHexResponse>(cancellationToken: ct);
|
||||||
|
return result ?? new AuthHexResponse((int)response.StatusCode, false, "Empty response from AuthHex.", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
|
||||||
|
public record AuthHexResponse(
|
||||||
|
[property: JsonPropertyName("statusCode")] int StatusCode,
|
||||||
|
[property: JsonPropertyName("success")] bool Success,
|
||||||
|
[property: JsonPropertyName("message")] string? Message,
|
||||||
|
[property: JsonPropertyName("data")] JsonElement? Data);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
|
public interface IAuthHexClient
|
||||||
|
{
|
||||||
|
Task<AuthHexResponse> CallAsync(string functionName, object? payload, string? reference = null, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Distribution.Infra.Authorization;
|
|
||||||
|
|
||||||
public interface IJwtTokenService
|
|
||||||
{
|
|
||||||
string GenerateToken(Models.User user);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
|
||||||
|
|
||||||
public class JwtTokenService : IJwtTokenService
|
|
||||||
{
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
|
|
||||||
public JwtTokenService(IConfiguration configuration)
|
|
||||||
{
|
|
||||||
_configuration = configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GenerateToken(Models.User user)
|
|
||||||
{
|
|
||||||
var jwtSection = _configuration.GetSection("Jwt");
|
|
||||||
var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
|
||||||
var issuer = jwtSection["Issuer"];
|
|
||||||
var audience = jwtSection["Audience"];
|
|
||||||
var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120;
|
|
||||||
|
|
||||||
var claims = new List<Claim>
|
|
||||||
{
|
|
||||||
new("sub", user.Id.ToString()),
|
|
||||||
new("username", user.Username),
|
|
||||||
new("role", user.Role?.Name ?? string.Empty),
|
|
||||||
new("role_id", user.FK_RoleId.ToString()),
|
|
||||||
new("role_level", (user.Role?.Level ?? 0).ToString())
|
|
||||||
};
|
|
||||||
|
|
||||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
|
|
||||||
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
|
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
|
||||||
issuer: issuer,
|
|
||||||
audience: audience,
|
|
||||||
claims: claims,
|
|
||||||
expires: DateTime.Now.AddMinutes(expiryMinutes),
|
|
||||||
signingCredentials: credentials);
|
|
||||||
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an XML-format RSA public key (`<RSAKeyValue>...</RSAKeyValue>`, as issued by
|
||||||
|
/// AuthHex) into an <see cref="RSA"/> instance. Written by hand instead of relying on
|
||||||
|
/// RSA.FromXmlString, which is Windows-only in .NET (throws PlatformNotSupportedException on
|
||||||
|
/// Linux/macOS) — this keeps token validation portable across hosts.
|
||||||
|
/// </summary>
|
||||||
|
public static class RsaXmlKeyReader
|
||||||
|
{
|
||||||
|
public static RSA ReadPublicKey(string xml)
|
||||||
|
{
|
||||||
|
var root = XElement.Parse(xml);
|
||||||
|
var modulus = root.Element("Modulus")?.Value
|
||||||
|
?? throw new InvalidOperationException("RSA XML key is missing <Modulus>.");
|
||||||
|
var exponent = root.Element("Exponent")?.Value
|
||||||
|
?? throw new InvalidOperationException("RSA XML key is missing <Exponent>.");
|
||||||
|
|
||||||
|
var parameters = new RSAParameters
|
||||||
|
{
|
||||||
|
Modulus = Convert.FromBase64String(modulus),
|
||||||
|
Exponent = Convert.FromBase64String(exponent)
|
||||||
|
};
|
||||||
|
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportParameters(parameters);
|
||||||
|
return rsa;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Distribution.Infra.Authorization.Middleware;
|
||||||
|
|
||||||
|
/// <summary>Stand-in for OSMCore's OMSCore.Common.Models.IOs.ApiResponse<T>, which has no equivalent in this project.</summary>
|
||||||
|
public sealed record ApiResponse<T>
|
||||||
|
{
|
||||||
|
public int StatusCode { get; init; }
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public T? Data { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Distribution.Features.User;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Distribution.Infra.Authorization.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ported from OSMCore's System/Authentication/AuthMiddleware.cs (global path-skip-based
|
||||||
|
/// middleware). NOT registered in Program.cs — this project's actual token validation is the
|
||||||
|
/// standard AddJwtBearer handler (see Program.cs), same as OSMCore's own recommendation. Kept here
|
||||||
|
/// unregistered, exactly like OSMCore's own copy, for reference/reuse if a project needs a
|
||||||
|
/// middleware-based (rather than AddJwtBearer-based) validation entry point.
|
||||||
|
///
|
||||||
|
/// Two adaptations from the OSMCore original, both required for this to actually work against
|
||||||
|
/// this project's authorization system rather than just compiling:
|
||||||
|
/// 1. RSA key parsing goes through <see cref="RsaXmlKeyReader"/> instead of RSA.FromXmlString,
|
||||||
|
/// which is Windows-only in modern .NET (see docs/authentication_flow.md §2.2 Gotcha).
|
||||||
|
/// 2. After validating the token, the user id is mapped onto a local User/Role row and
|
||||||
|
/// role_id/role_level/username claims are attached — the same step Program.cs's
|
||||||
|
/// OnTokenValidated performs for the AddJwtBearer path. Without this, a request authenticated
|
||||||
|
/// through this middleware would have no role_id claim, and every
|
||||||
|
/// [Authorize(Policy = "resource.action")] check (PermissionAuthorizationHandler) would fail.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<AuthMiddleware> logger)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (ShouldSkipAuthentication(context.Request.Path))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetToken(context, out var token))
|
||||||
|
{
|
||||||
|
await RejectAsync(context, "Authentication token is missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = ValidateToken(token);
|
||||||
|
var userRepo = context.RequestServices.GetRequiredService<IUserRepository>();
|
||||||
|
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
|
||||||
|
{
|
||||||
|
await RejectAsync(context, "User not found or inactive.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.User = principal;
|
||||||
|
PopulateContextItems(context, principal);
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException)
|
||||||
|
{
|
||||||
|
logger.LogInformation(ex, "JWT validation failed for request path {Path}", context.Request.Path);
|
||||||
|
await RejectAsync(context, "Invalid or expired token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaimsPrincipal ValidateToken(string token)
|
||||||
|
{
|
||||||
|
var jwtOptions = configuration.GetSection("Jwt").Get<JwtOptions>()
|
||||||
|
?? throw new InvalidOperationException("Jwt configuration is missing.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtOptions.PublicKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Jwt:PublicKey configuration is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var rsa = RsaXmlKeyReader.ReadPublicKey(jwtOptions.PublicKey);
|
||||||
|
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var parameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = jwtOptions.Issuer,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = jwtOptions.Audience,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new RsaSecurityKey(rsa),
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
return handler.ValidateToken(token, parameters, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
|
||||||
|
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
|
||||||
|
{
|
||||||
|
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!Guid.TryParse(subClaim, out var userId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var user = await userRepo.GetByIdAsync(userId);
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var identity = (ClaimsIdentity)principal.Identity!;
|
||||||
|
identity.AddClaim(new Claim("username", user.Username));
|
||||||
|
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
|
||||||
|
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetToken(HttpContext context, out string token)
|
||||||
|
{
|
||||||
|
token = string.Empty;
|
||||||
|
|
||||||
|
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
|
||||||
|
if (TryGetBearerToken(headerToken, out token))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cookieToken = context.Request.Cookies["access_token"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(cookieToken))
|
||||||
|
{
|
||||||
|
token = cookieToken.Trim();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
|
||||||
|
{
|
||||||
|
token = string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string bearerPrefix = "Bearer ";
|
||||||
|
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
token = authorizationHeader.Trim();
|
||||||
|
return !string.IsNullOrWhiteSpace(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
token = authorizationHeader[bearerPrefix.Length..].Trim();
|
||||||
|
return !string.IsNullOrWhiteSpace(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapted from OSMCore's path list: this project has no Shopify-style webhook route, so that
|
||||||
|
/// skip is dropped; /api/auth is narrowed to just login/register (this project's /api/auth/me
|
||||||
|
/// and /api/auth/logout are meant to require auth, unlike a blanket "/api/auth" skip).
|
||||||
|
/// </summary>
|
||||||
|
private static bool ShouldSkipAuthentication(PathString path)
|
||||||
|
=> path.Equals("/health", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Equals("/api/auth/register", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWithSegments("/swagger", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
context.Items["Username"] = principal.FindFirst("username")?.Value;
|
||||||
|
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
|
||||||
|
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RejectAsync(HttpContext context, string message)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status401Unauthorized,
|
||||||
|
Success = false,
|
||||||
|
Message = message,
|
||||||
|
Data = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Distribution.Features.User;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Distribution.Infra.Authorization.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ported from OSMCore's System/Authentication/JwtMiddleware.cs (action-filter attribute pattern,
|
||||||
|
/// applied per controller/action as [JwtMiddleware.CustomMiddleware]). Not applied anywhere in
|
||||||
|
/// this project — the standard AddJwtBearer handler (Program.cs) is this project's actual
|
||||||
|
/// validation path.
|
||||||
|
///
|
||||||
|
/// docs/authentication_flow.md explicitly calls out not running this alongside AuthMiddleware or
|
||||||
|
/// AddJwtBearer at the same time — all three independently re-validate the same RSA-signed token,
|
||||||
|
/// which is wasteful and error-prone (this is the same duplication OSMCore's own guide flags as a
|
||||||
|
/// Gotcha). Kept here, unregistered, only as a ported reference implementation.
|
||||||
|
///
|
||||||
|
/// Same two adaptations as AuthMiddleware: portable RSA parsing via
|
||||||
|
/// <see cref="RsaXmlKeyReader"/>, and local User/Role → role_id/role_level claim mapping after
|
||||||
|
/// validation (see AuthMiddleware's remarks for why this is required, not optional).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JwtMiddleware
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public sealed class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter
|
||||||
|
{
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var configuration = context.HttpContext.RequestServices.GetService<IConfiguration>();
|
||||||
|
|
||||||
|
if (!TryGetToken(context.HttpContext, out var token))
|
||||||
|
{
|
||||||
|
await RejectAsync(context.HttpContext, "Authentication token is missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (configuration is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Jwt configuration is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var principal = ValidateToken(token, configuration);
|
||||||
|
|
||||||
|
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
|
||||||
|
if (!await TryAttachLocalRoleClaimsAsync(principal, userRepo))
|
||||||
|
{
|
||||||
|
await RejectAsync(context.HttpContext, "User not found or inactive.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.HttpContext.User = principal;
|
||||||
|
PopulateContextItems(context.HttpContext, principal);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException or CryptographicException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
await RejectAsync(context.HttpContext, "Invalid or expired token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal ValidateToken(string token, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var xmlKey = configuration["Jwt:PublicKey"];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(xmlKey))
|
||||||
|
throw new SecurityTokenException("JWT public key not configured.");
|
||||||
|
|
||||||
|
using var rsa = RsaXmlKeyReader.ReadPublicKey(xmlKey);
|
||||||
|
var rsaParameters = rsa.ExportParameters(false);
|
||||||
|
|
||||||
|
var key = new RsaSecurityKey(rsaParameters)
|
||||||
|
{
|
||||||
|
KeyId = ComputeKeyId(rsaParameters)
|
||||||
|
};
|
||||||
|
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var parameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = configuration["Jwt:Issuer"],
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = configuration["Jwt:Audience"],
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = key,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
return handler.ValidateToken(token, parameters, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirrors Program.cs's OnTokenValidated mapping — see class remarks.</summary>
|
||||||
|
private static async Task<bool> TryAttachLocalRoleClaimsAsync(ClaimsPrincipal principal, IUserRepository userRepo)
|
||||||
|
{
|
||||||
|
var subClaim = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!Guid.TryParse(subClaim, out var userId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var user = await userRepo.GetByIdAsync(userId);
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var identity = (ClaimsIdentity)principal.Identity!;
|
||||||
|
identity.AddClaim(new Claim("username", user.Username));
|
||||||
|
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
|
||||||
|
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetToken(HttpContext context, out string token)
|
||||||
|
{
|
||||||
|
token = string.Empty;
|
||||||
|
|
||||||
|
if (TryGetBearerToken(context.Request.Headers.Authorization, out token))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerToken = context.Request.Headers["MobileAuthToken"].FirstOrDefault();
|
||||||
|
if (TryGetBearerToken(headerToken, out token))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cookieToken = context.Request.Cookies["access_token"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(cookieToken))
|
||||||
|
{
|
||||||
|
token = cookieToken.Trim();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBearerToken(string? authorizationHeader, out string token)
|
||||||
|
{
|
||||||
|
token = string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string bearerPrefix = "Bearer ";
|
||||||
|
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
token = authorizationHeader.Trim();
|
||||||
|
return !string.IsNullOrWhiteSpace(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
token = authorizationHeader[bearerPrefix.Length..].Trim();
|
||||||
|
return !string.IsNullOrWhiteSpace(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PopulateContextItems(HttpContext context, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
context.Items["UserId"] = principal.FindFirst("sub")?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
context.Items["Username"] = principal.FindFirst("username")?.Value;
|
||||||
|
context.Items["RoleId"] = principal.FindFirst("role_id")?.Value;
|
||||||
|
context.Items["RoleLevel"] = principal.FindFirst("role_level")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RejectAsync(HttpContext context, string message)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status401Unauthorized,
|
||||||
|
Success = false,
|
||||||
|
Message = message,
|
||||||
|
Data = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeKeyId(RSAParameters publicParameters)
|
||||||
|
{
|
||||||
|
var modulus = publicParameters.Modulus ?? [];
|
||||||
|
var exponent = publicParameters.Exponent ?? [];
|
||||||
|
var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}";
|
||||||
|
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
|
||||||
|
return Base64UrlEncoder.Encode(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Distribution.Infra.Authorization.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ported from OSMCore's System/Authentication/JwtOptions.cs. Binds to the same "Jwt" config
|
||||||
|
/// section already used by Program.cs's AddJwtBearer setup (Jwt:PublicKey/Audience/Issuer) — no
|
||||||
|
/// new config keys needed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record JwtOptions
|
||||||
|
{
|
||||||
|
public string PublicKey { get; init; } = string.Empty;
|
||||||
|
public string Audience { get; init; } = string.Empty;
|
||||||
|
public string Issuer { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
+1977
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class payment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
collation: "ascii_general_ci");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "FulfilledAt",
|
||||||
|
table: "Orders",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Status",
|
||||||
|
table: "Orders",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "QuantityFulfilled",
|
||||||
|
table: "OrderItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DeliveryTasks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Sequence = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CustomerConfirmed = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
ConfirmedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DeliveryTasks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DeliveryTasks_DeliveryRuns_FK_RideId",
|
||||||
|
column: x => x.FK_RideId,
|
||||||
|
principalTable: "DeliveryRuns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DeliveryTasks_Orders_FK_OrderId",
|
||||||
|
column: x => x.FK_OrderId,
|
||||||
|
principalTable: "Orders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Sales",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
SaleDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PaymentStatus = table.Column<int>(type: "int", nullable: false),
|
||||||
|
FulfillImmediately = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Sales", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Sales_DeliveryRuns_FK_RideId",
|
||||||
|
column: x => x.FK_RideId,
|
||||||
|
principalTable: "DeliveryRuns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Sales_Stores_FK_StoreId",
|
||||||
|
column: x => x.FK_StoreId,
|
||||||
|
principalTable: "Stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Sales_Users_CreatedByUserId",
|
||||||
|
column: x => x.CreatedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Payments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SaleId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
Method = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CollectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CollectedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Payments", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Payments_Sales_FK_SaleId",
|
||||||
|
column: x => x.FK_SaleId,
|
||||||
|
principalTable: "Sales",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Payments_Stores_FK_StoreId",
|
||||||
|
column: x => x.FK_StoreId,
|
||||||
|
principalTable: "Stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Orders_FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "FK_SaleId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DeliveryTasks_FK_OrderId",
|
||||||
|
table: "DeliveryTasks",
|
||||||
|
column: "FK_OrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DeliveryTasks_FK_RideId",
|
||||||
|
table: "DeliveryTasks",
|
||||||
|
column: "FK_RideId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Payments_FK_SaleId",
|
||||||
|
table: "Payments",
|
||||||
|
column: "FK_SaleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Payments_FK_StoreId",
|
||||||
|
table: "Payments",
|
||||||
|
column: "FK_StoreId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Sales_CreatedByUserId",
|
||||||
|
table: "Sales",
|
||||||
|
column: "CreatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Sales_FK_RideId",
|
||||||
|
table: "Sales",
|
||||||
|
column: "FK_RideId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Sales_FK_StoreId",
|
||||||
|
table: "Sales",
|
||||||
|
column: "FK_StoreId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Orders_Sales_FK_SaleId",
|
||||||
|
table: "Orders",
|
||||||
|
column: "FK_SaleId",
|
||||||
|
principalTable: "Sales",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Orders_Sales_FK_SaleId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DeliveryTasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Payments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Sales");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Orders_FK_SaleId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FK_SaleId",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FulfilledAt",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuantityFulfilled",
|
||||||
|
table: "OrderItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReturnsAndClaims : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Claims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_AgencyUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Details = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SettlementAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
|
||||||
|
SettledByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
SettledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Claims", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Claims_Users_FK_AgencyUserId",
|
||||||
|
column: x => x.FK_AgencyUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Claims_Users_SettledByUserId",
|
||||||
|
column: x => x.SettledByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SaleReturns",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Mode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalCreditAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SaleReturns", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturns_Orders_FK_OrderId",
|
||||||
|
column: x => x.FK_OrderId,
|
||||||
|
principalTable: "Orders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturns_Users_CreatedByUserId",
|
||||||
|
column: x => x.CreatedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CreditNotes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CreditNotes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CreditNotes_SaleReturns_FK_SaleReturnId",
|
||||||
|
column: x => x.FK_SaleReturnId,
|
||||||
|
principalTable: "SaleReturns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SaleReturnItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SaleReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
ReasonCode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SaleReturnItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturnItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SaleReturnItems_SaleReturns_FK_SaleReturnId",
|
||||||
|
column: x => x.FK_SaleReturnId,
|
||||||
|
principalTable: "SaleReturns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Claims_FK_AgencyUserId",
|
||||||
|
table: "Claims",
|
||||||
|
column: "FK_AgencyUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Claims_SettledByUserId",
|
||||||
|
table: "Claims",
|
||||||
|
column: "SettledByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CreditNotes_FK_SaleReturnId",
|
||||||
|
table: "CreditNotes",
|
||||||
|
column: "FK_SaleReturnId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturnItems_FK_ItemId",
|
||||||
|
table: "SaleReturnItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturnItems_FK_SaleReturnId",
|
||||||
|
table: "SaleReturnItems",
|
||||||
|
column: "FK_SaleReturnId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturns_CreatedByUserId",
|
||||||
|
table: "SaleReturns",
|
||||||
|
column: "CreatedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SaleReturns_FK_OrderId",
|
||||||
|
table: "SaleReturns",
|
||||||
|
column: "FK_OrderId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Claims");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CreditNotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleReturnItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SaleReturns");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSupplierClaims : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SupplierClaims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
Details = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
EvidenceUrl = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ResolutionType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ResolutionAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
|
||||||
|
ResolutionNotes = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
RaisedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
ResolvedByUserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
ResolvedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SupplierClaims", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_GoodsReceipts_FK_GoodsReceiptId",
|
||||||
|
column: x => x.FK_GoodsReceiptId,
|
||||||
|
principalTable: "GoodsReceipts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_PurchaseOrders_FK_PurchaseOrderId",
|
||||||
|
column: x => x.FK_PurchaseOrderId,
|
||||||
|
principalTable: "PurchaseOrders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_Users_RaisedByUserId",
|
||||||
|
column: x => x.RaisedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaims_Users_ResolvedByUserId",
|
||||||
|
column: x => x.ResolvedByUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SupplierClaimItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SupplierClaimId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Quantity = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
IssueType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SupplierClaimItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaimItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SupplierClaimItems_SupplierClaims_FK_SupplierClaimId",
|
||||||
|
column: x => x.FK_SupplierClaimId,
|
||||||
|
principalTable: "SupplierClaims",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaimItems_FK_ItemId",
|
||||||
|
table: "SupplierClaimItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaimItems_FK_SupplierClaimId",
|
||||||
|
table: "SupplierClaimItems",
|
||||||
|
column: "FK_SupplierClaimId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_FK_GoodsReceiptId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "FK_GoodsReceiptId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_FK_PurchaseOrderId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_RaisedByUserId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "RaisedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SupplierClaims_ResolvedByUserId",
|
||||||
|
table: "SupplierClaims",
|
||||||
|
column: "ResolvedByUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SupplierClaimItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SupplierClaims");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,74 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("Categories");
|
b.ToTable("Categories");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Claim", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_AgencyUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SettledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SettledByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("SettlementAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_AgencyUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SettledByUserId");
|
||||||
|
|
||||||
|
b.ToTable("Claims");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_SaleReturnId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("IssuedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SaleReturnId");
|
||||||
|
|
||||||
|
b.ToTable("CreditNotes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
|
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -131,6 +199,48 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("DeliveryRuns");
|
b.ToTable("DeliveryRuns");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ConfirmedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("CustomerConfirmed")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_OrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_RideId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int>("Sequence")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_OrderId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RideId");
|
||||||
|
|
||||||
|
b.ToTable("DeliveryTasks");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b =>
|
modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -397,20 +507,32 @@ namespace Distribution.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_SaleId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<Guid>("FK_StoreId")
|
b.Property<Guid>("FK_StoreId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FulfilledAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("tinyint(1)");
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
b.Property<DateTime>("OrderDate")
|
b.Property<DateTime>("OrderDate")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal>("TotalAmount")
|
b.Property<decimal>("TotalAmount")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SaleId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("FK_StoreId");
|
b.HasIndex("FK_StoreId");
|
||||||
|
|
||||||
b.ToTable("Orders");
|
b.ToTable("Orders");
|
||||||
@@ -436,6 +558,9 @@ namespace Distribution.Migrations
|
|||||||
b.Property<int>("Quantity")
|
b.Property<int>("Quantity")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("QuantityFulfilled")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal>("UnitPrice")
|
b.Property<decimal>("UnitPrice")
|
||||||
.HasColumnType("decimal(65,30)");
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
@@ -446,6 +571,45 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("OrderItems");
|
b.ToTable("OrderItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CollectedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CollectedBy")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_SaleId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_StoreId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Method")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SaleId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_StoreId");
|
||||||
|
|
||||||
|
b.ToTable("Payments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Permission", b =>
|
modelBuilder.Entity("Distribution.Models.Permission", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -860,6 +1024,125 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("Routes");
|
b.ToTable("Routes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Sale", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_RideId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_StoreId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("FulfillImmediately")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("PaymentStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SaleDate")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RideId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_StoreId");
|
||||||
|
|
||||||
|
b.ToTable("Sales");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_OrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Mode")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalCreditAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_OrderId");
|
||||||
|
|
||||||
|
b.ToTable("SaleReturns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_SaleReturnId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<int>("ReasonCode")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitPrice")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SaleReturnId");
|
||||||
|
|
||||||
|
b.ToTable("SaleReturnItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
|
modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1231,6 +1514,98 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("Suppliers");
|
b.ToTable("Suppliers");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("EvidenceUrl")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_GoodsReceiptId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PurchaseOrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RaisedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("ResolutionAmount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<string>("ResolutionNotes")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<int?>("ResolutionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ResolvedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ResolvedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_GoodsReceiptId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
b.HasIndex("RaisedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("ResolvedByUserId");
|
||||||
|
|
||||||
|
b.ToTable("SupplierClaims");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_SupplierClaimId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("IssueType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SupplierClaimId");
|
||||||
|
|
||||||
|
b.ToTable("SupplierClaimItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Uom", b =>
|
modelBuilder.Entity("Distribution.Models.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1464,6 +1839,35 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("Warehouses");
|
b.ToTable("Warehouses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Claim", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.User", "AgencyUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_AgencyUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "SettledByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SettledByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("AgencyUser");
|
||||||
|
|
||||||
|
b.Navigation("SettledByUser");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.CreditNote", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_SaleReturnId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SaleReturn");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
|
modelBuilder.Entity("Distribution.Models.DeliveryRun", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.DriverProfile", "DriverProfile")
|
b.HasOne("Distribution.Models.DriverProfile", "DriverProfile")
|
||||||
@@ -1499,6 +1903,25 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Vehicle");
|
b.Navigation("Vehicle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Order", "Order")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_OrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RideId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Order");
|
||||||
|
|
||||||
|
b.Navigation("Ride");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.DriverProfile", b =>
|
modelBuilder.Entity("Distribution.Models.DriverProfile", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.User", "User")
|
b.HasOne("Distribution.Models.User", "User")
|
||||||
@@ -1619,12 +2042,20 @@ namespace Distribution.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Order", b =>
|
modelBuilder.Entity("Distribution.Models.Order", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("Distribution.Models.Sale", "Sale")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_SaleId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Store", "Store")
|
b.HasOne("Distribution.Models.Store", "Store")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("FK_StoreId")
|
.HasForeignKey("FK_StoreId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Sale");
|
||||||
|
|
||||||
b.Navigation("Store");
|
b.Navigation("Store");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1637,7 +2068,7 @@ namespace Distribution.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Distribution.Models.Order", "Order")
|
b.HasOne("Distribution.Models.Order", "Order")
|
||||||
.WithMany()
|
.WithMany("OrderItems")
|
||||||
.HasForeignKey("FK_OrderId")
|
.HasForeignKey("FK_OrderId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
@@ -1647,6 +2078,25 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Order");
|
b.Navigation("Order");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Payment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Sale", "Sale")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_SaleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Sale");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Region", "Region")
|
b.HasOne("Distribution.Models.Region", "Region")
|
||||||
@@ -1776,6 +2226,70 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Region");
|
b.Navigation("Region");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Sale", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.User", "CreatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RideId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CreatedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Ride");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.User", "CreatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Order", "Order")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_OrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CreatedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Order");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SaleReturnItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.SaleReturn", "SaleReturn")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_SaleReturnId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("SaleReturn");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Item", "FreeItem")
|
b.HasOne("Distribution.Models.Item", "FreeItem")
|
||||||
@@ -1898,6 +2412,58 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Category");
|
b.Navigation("Category");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_GoodsReceiptId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_PurchaseOrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "RaisedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RaisedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.User", "ResolvedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ResolvedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("GoodsReceipt");
|
||||||
|
|
||||||
|
b.Navigation("PurchaseOrder");
|
||||||
|
|
||||||
|
b.Navigation("RaisedByUser");
|
||||||
|
|
||||||
|
b.Navigation("ResolvedByUser");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SupplierClaimItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.SupplierClaim", "SupplierClaim")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_SupplierClaimId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("SupplierClaim");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.UomConversion", b =>
|
modelBuilder.Entity("Distribution.Models.UomConversion", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Uom", "FromUom")
|
b.HasOne("Distribution.Models.Uom", "FromUom")
|
||||||
@@ -2032,6 +2598,11 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Order", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("OrderItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
@@ -2047,12 +2618,22 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SaleReturn", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BundleComponents");
|
b.Navigation("BundleComponents");
|
||||||
|
|
||||||
b.Navigation("Redemptions");
|
b.Navigation("Redemptions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.SupplierClaim", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,13 @@
|
|||||||
namespace Distribution.Models
|
namespace Distribution.Models
|
||||||
{
|
{
|
||||||
public enum LocationType
|
public enum LocationType
|
||||||
{
|
{ Warehouse, Vehicle }
|
||||||
Warehouse,
|
|
||||||
Vehicle
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum KycStatus
|
public enum KycStatus { Pending,Verified, Rejected }
|
||||||
{
|
|
||||||
Pending,
|
|
||||||
Verified,
|
|
||||||
Rejected }
|
|
||||||
|
|
||||||
public enum MovementType
|
public enum MovementType { Sale, CustomerReturn, Transfer, Damage, Adjustment, Load, PurchaseReceipt, PurchaseReturn }
|
||||||
{
|
|
||||||
Sale,
|
|
||||||
CustomerReturn,
|
|
||||||
Transfer,
|
|
||||||
Damage,
|
|
||||||
Adjustment,
|
|
||||||
Load,
|
|
||||||
PurchaseReceipt,
|
|
||||||
PurchaseReturn
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum LoadingSheetStatus
|
public enum LoadingSheetStatus { Pending, DispatchConfirmed, ReceiptConfirmed, DiscrepancyFlagged, Resolved }
|
||||||
{
|
|
||||||
Pending,
|
|
||||||
DispatchConfirmed,
|
|
||||||
ReceiptConfirmed,
|
|
||||||
DiscrepancyFlagged,
|
|
||||||
Resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RideType { Delivery, Sale }
|
public enum RideType { Delivery, Sale }
|
||||||
|
|
||||||
@@ -42,4 +18,27 @@ namespace Distribution.Models
|
|||||||
public enum CancelRequestStatus { Pending, Approved, Rejected }
|
public enum CancelRequestStatus { Pending, Approved, Rejected }
|
||||||
|
|
||||||
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
|
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
|
||||||
|
|
||||||
|
public enum SaleStatus { Pending, Completed, Cancelled }
|
||||||
|
|
||||||
|
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
|
||||||
|
|
||||||
|
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
|
||||||
|
|
||||||
|
public enum PaymentMethod { Cash, Credit }
|
||||||
|
|
||||||
|
public enum DeliveryTaskStatus
|
||||||
|
{ Pending, Delivered, PartiallyDelivered, Failed}
|
||||||
|
|
||||||
|
public enum ReturnMode { CreditDeduction, ItemExchange }
|
||||||
|
|
||||||
|
public enum ReturnReasonCode { Expired, Damaged, OrderError, Other }
|
||||||
|
|
||||||
|
public enum ClaimStatus { Open, Settled }
|
||||||
|
|
||||||
|
public enum SupplierClaimStatus { Open, Approved, Rejected }
|
||||||
|
|
||||||
|
public enum SupplierClaimIssueType { Damaged, Short, Expired, WrongItem, Other }
|
||||||
|
|
||||||
|
public enum SupplierClaimResolutionType { Replacement, Credit, Refund, PriceAdjustment }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ namespace Distribution.Models
|
|||||||
{
|
{
|
||||||
public class Order : Template
|
public class Order : Template
|
||||||
{
|
{
|
||||||
|
public Guid FK_SaleId { get; set; }
|
||||||
|
[ForeignKey("FK_SaleId")]
|
||||||
|
public Sale Sale { get; set; }
|
||||||
|
|
||||||
public DateTime OrderDate { get; set; }
|
public DateTime OrderDate { get; set; }
|
||||||
public Guid FK_StoreId { get; set; }
|
public Guid FK_StoreId { get; set; }
|
||||||
[ForeignKey("FK_StoreId")]
|
[ForeignKey("FK_StoreId")]
|
||||||
public Store Store { get; set; }
|
public Store Store { get; set; }
|
||||||
public decimal TotalAmount { get; set; }
|
public decimal TotalAmount { get; set; }
|
||||||
|
public OrderStatus Status { get; set; } = OrderStatus.Pending;
|
||||||
|
public DateTime? FulfilledAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace Distribution.Models
|
|||||||
[ForeignKey("FK_ItemId")]
|
[ForeignKey("FK_ItemId")]
|
||||||
public Item Item { get; set; }
|
public Item Item { get; set; }
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
|
public int QuantityFulfilled { get; set; } = 0;
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,267 +1,78 @@
|
|||||||
# Phase Plan — Route & Store Management
|
# Phase Plan - Route and Store Management
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, OrderItem**.
|
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order,
|
||||||
Item model is sourced from ERP Core — excluded from this phase.
|
OrderItem**.
|
||||||
SaleRoute shape is TBD — excluded until clarified.
|
|
||||||
|
Item model is sourced from ERP Core - excluded from this phase.
|
||||||
|
|
||||||
|
Historical note: `SaleRoute` was marked TBD here. For now, rides stay single-route and route
|
||||||
|
assignment remains simple until further clarification.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure (target)
|
## Project Structure (target)
|
||||||
|
|
||||||
```
|
```text
|
||||||
Distribution/
|
Distribution/
|
||||||
├── Models/ # All entity POCOs — unchanged
|
|-- Models/
|
||||||
│ ├── Template.cs
|
| |-- Template.cs
|
||||||
│ ├── Region.cs
|
| |-- Region.cs
|
||||||
│ ├── Route.cs
|
| |-- Route.cs
|
||||||
│ ├── Vehicle.cs
|
| |-- Vehicle.cs
|
||||||
│ ├── Store.cs
|
| |-- Store.cs
|
||||||
│ ├── Warehouse.cs
|
| |-- Warehouse.cs
|
||||||
│ ├── SaleRoute.cs # TBD
|
| |-- SaleRoute.cs
|
||||||
│ ├── Order.cs
|
| |-- Order.cs
|
||||||
│ ├── OrderItem.cs
|
| |-- OrderItem.cs
|
||||||
│ ├── DeliveryRun.cs
|
| |-- DeliveryRun.cs
|
||||||
│ └── Item.cs # Sourced from ERP Core
|
| `-- Item.cs
|
||||||
├── Data/
|
|-- Data/
|
||||||
│ └── AppDBContext.cs # DbSets + Fluent API config
|
| `-- AppDBContext.cs
|
||||||
├── Infra/
|
|-- Infra/
|
||||||
│ ├── UoW/ # Already implemented
|
| |-- UoW/
|
||||||
│ │ ├── IUnitOfWork.cs
|
| | |-- IUnitOfWork.cs
|
||||||
│ │ └── EFUnitOfWork.cs
|
| | `-- EFUnitOfWork.cs
|
||||||
│ └── Repositories/ # Shared generic base only
|
| `-- Repositories/
|
||||||
│ ├── IGenericRepo.cs
|
| |-- IGenericRepo.cs
|
||||||
│ └── GenericRepo.cs
|
| `-- GenericRepo.cs
|
||||||
└── Features/
|
`-- Features/
|
||||||
├── Region/
|
|-- Region/
|
||||||
│ ├── RegionDTOs.cs
|
|-- Route/
|
||||||
│ ├── IRegionRepository.cs
|
|-- Vehicle/
|
||||||
│ ├── RegionRepository.cs
|
|-- Store/
|
||||||
│ ├── RegionService.cs
|
|-- Warehouse/
|
||||||
│ └── RegionController.cs
|
`-- Order/
|
||||||
├── Route/
|
|
||||||
│ ├── RouteDTOs.cs
|
|
||||||
│ ├── IRouteRepository.cs
|
|
||||||
│ ├── RouteRepository.cs
|
|
||||||
│ ├── RouteService.cs
|
|
||||||
│ └── RouteController.cs
|
|
||||||
├── Vehicle/
|
|
||||||
│ ├── VehicleDTOs.cs
|
|
||||||
│ ├── IVehicleRepository.cs
|
|
||||||
│ ├── VehicleRepository.cs
|
|
||||||
│ ├── VehicleService.cs
|
|
||||||
│ └── VehicleController.cs
|
|
||||||
├── Store/
|
|
||||||
│ ├── StoreDTOs.cs
|
|
||||||
│ ├── IStoreRepository.cs
|
|
||||||
│ ├── StoreRepository.cs
|
|
||||||
│ ├── StoreService.cs
|
|
||||||
│ └── StoreController.cs
|
|
||||||
├── Warehouse/
|
|
||||||
│ ├── WarehouseDTOs.cs
|
|
||||||
│ ├── IWarehouseRepository.cs
|
|
||||||
│ ├── WarehouseRepository.cs
|
|
||||||
│ ├── WarehouseService.cs
|
|
||||||
│ └── WarehouseController.cs
|
|
||||||
└── Order/
|
|
||||||
├── OrderDTOs.cs
|
|
||||||
├── IOrderRepository.cs
|
|
||||||
├── OrderRepository.cs
|
|
||||||
├── OrderService.cs
|
|
||||||
└── OrderController.cs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hard Rules (from AGENTS.md)
|
## Hard Rules
|
||||||
|
|
||||||
- Only `uow.CommitAsync()` in services — never in repositories.
|
- Only `uow.CommitAsync()` in services - never in repositories.
|
||||||
- Use `DateTime.Now` — never `DateTime.UtcNow`.
|
- Use `DateTime.Now` - never `DateTime.UtcNow`.
|
||||||
- Never build anything not explicitly requested.
|
- Never build anything not explicitly requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Complete AppDBContext
|
|
||||||
|
|
||||||
File: `Data/AppDBContext.cs`
|
|
||||||
|
|
||||||
Add DbSets and Fluent API configuration:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public DbSet<Region> Regions { get; set; }
|
|
||||||
public DbSet<Route> Routes { get; set; }
|
|
||||||
public DbSet<Vehicle> Vehicles { get; set; }
|
|
||||||
public DbSet<Store> Stores { get; set; }
|
|
||||||
public DbSet<Warehouse> Warehouses { get; set; }
|
|
||||||
public DbSet<SaleRoute> SaleRoutes { get; set; }
|
|
||||||
public DbSet<Order> Orders { get; set; }
|
|
||||||
public DbSet<OrderItem> OrderItems { get; set; }
|
|
||||||
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
|
|
||||||
public DbSet<Item> Items { get; set; }
|
|
||||||
|
|
||||||
// OnModelCreating
|
|
||||||
modelBuilder.Entity<OrderItem>()
|
|
||||||
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Initial Migration
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet ef migrations add InitialCreate --project Distribution
|
|
||||||
dotnet ef database update --project Distribution
|
|
||||||
```
|
|
||||||
|
|
||||||
Each developer applies this locally. No migration files are shared.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4 — Generic Repository (Infra/Repositories)
|
|
||||||
|
|
||||||
**`IGenericRepo<T>`**
|
|
||||||
```
|
|
||||||
GetByIdAsync(Guid id)
|
|
||||||
GetAllAsync()
|
|
||||||
AddAsync(T entity)
|
|
||||||
Update(T entity)
|
|
||||||
Remove(T entity)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`GenericRepo<T>`** — implements above using DbContext. No `SaveChanges` calls.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5 — Feature Implementation Order
|
|
||||||
|
|
||||||
Build each feature in this dependency order:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Region → no dependencies
|
|
||||||
2. Route → no dependencies
|
|
||||||
3. Vehicle → no dependencies
|
|
||||||
4. Store → no dependencies
|
|
||||||
5. Warehouse → depends on Vehicle (FK)
|
|
||||||
6. Order → depends on Store (FK)
|
|
||||||
7. OrderItem → depends on Order + Item (composite FK)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Per-feature deliverables
|
|
||||||
|
|
||||||
#### DTOs (`{Feature}DTOs.cs`)
|
|
||||||
- `CreateRequest` — fields accepted on POST
|
|
||||||
- `UpdateRequest` — fields accepted on PUT
|
|
||||||
- `Response` — shape returned to client (never expose the entity directly)
|
|
||||||
|
|
||||||
#### Repository interface (`I{Feature}Repository.cs`)
|
|
||||||
Extends `IGenericRepo<T>`. Add feature-specific queries (e.g., `GetByStoreId`).
|
|
||||||
|
|
||||||
#### Repository implementation (`{Feature}Repository.cs`)
|
|
||||||
Inherits `GenericRepo<T>`. Implements feature-specific queries. No `CommitAsync` calls.
|
|
||||||
|
|
||||||
#### Service (`{Feature}Service.cs`)
|
|
||||||
- Contains all business logic.
|
|
||||||
- Calls `_uow.CommitAsync(ct)` after write operations.
|
|
||||||
- For multi-step writes: `BeginAsync → work → CommitAsync` (catch → `RollbackAsync`).
|
|
||||||
|
|
||||||
#### Controller (`{Feature}Controller.cs`)
|
|
||||||
Thin — maps HTTP → service call → returns result. No logic, no UoW calls.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6 — Endpoints
|
|
||||||
|
|
||||||
### Region — `/api/regions`
|
|
||||||
| Method | Route | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/` | Get all active regions |
|
|
||||||
| GET | `/{id}` | Get region by id |
|
|
||||||
| POST | `/` | Create region |
|
|
||||||
| PUT | `/{id}` | Update region |
|
|
||||||
| DELETE | `/{id}` | Soft delete (set IsActive = false) |
|
|
||||||
|
|
||||||
### Route — `/api/routes`
|
|
||||||
| Method | Route | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/` | Get all active routes |
|
|
||||||
| GET | `/{id}` | Get route by id |
|
|
||||||
| POST | `/` | Create route |
|
|
||||||
| PUT | `/{id}` | Update route |
|
|
||||||
| DELETE | `/{id}` | Soft delete |
|
|
||||||
|
|
||||||
### Vehicle — `/api/vehicles`
|
|
||||||
| Method | Route | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/` | Get all active vehicles |
|
|
||||||
| GET | `/{id}` | Get vehicle by id |
|
|
||||||
| POST | `/` | Create vehicle |
|
|
||||||
| PUT | `/{id}` | Update vehicle |
|
|
||||||
| DELETE | `/{id}` | Soft delete |
|
|
||||||
|
|
||||||
### Store — `/api/stores`
|
|
||||||
| Method | Route | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/` | Get all active stores |
|
|
||||||
| GET | `/{id}` | Get store by id |
|
|
||||||
| POST | `/` | Create store |
|
|
||||||
| PUT | `/{id}` | Update store |
|
|
||||||
| DELETE | `/{id}` | Soft delete |
|
|
||||||
|
|
||||||
### Warehouse — `/api/warehouses`
|
|
||||||
| Method | Route | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/` | Get all active warehouses |
|
|
||||||
| GET | `/{id}` | Get warehouse by id |
|
|
||||||
| POST | `/` | Create warehouse |
|
|
||||||
| PUT | `/{id}` | Update warehouse |
|
|
||||||
| DELETE | `/{id}` | Soft delete |
|
|
||||||
|
|
||||||
### Order — `/api/orders`
|
|
||||||
| Method | Route | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/` | Get all active orders |
|
|
||||||
| GET | `/{id}` | Get order with items |
|
|
||||||
| GET | `/store/{storeId}` | Get orders by store |
|
|
||||||
| POST | `/` | Create order with items |
|
|
||||||
| PUT | `/{id}` | Update order |
|
|
||||||
| DELETE | `/{id}` | Soft delete |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7 — DI Registration (Program.cs)
|
|
||||||
|
|
||||||
Register all feature repositories and services:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Generic base
|
|
||||||
builder.Services.AddScoped(typeof(IGenericRepo<>), typeof(GenericRepo<>));
|
|
||||||
|
|
||||||
// Feature repositories
|
|
||||||
builder.Services.AddScoped<IRegionRepository, RegionRepository>();
|
|
||||||
builder.Services.AddScoped<IRouteRepository, RouteRepository>();
|
|
||||||
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
|
|
||||||
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
|
|
||||||
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
|
|
||||||
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
|
|
||||||
|
|
||||||
// Feature services
|
|
||||||
builder.Services.AddScoped<RegionService>();
|
|
||||||
builder.Services.AddScoped<RouteService>();
|
|
||||||
builder.Services.AddScoped<VehicleService>();
|
|
||||||
builder.Services.AddScoped<StoreService>();
|
|
||||||
builder.Services.AddScoped<WarehouseService>();
|
|
||||||
builder.Services.AddScoped<OrderService>();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
| Item | Reason |
|
| Item | Reason |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `SaleRoute` | Shape undefined — implement after clarification |
|
| `SaleRoute` | Re-evaluate after ride/route design is clarified |
|
||||||
| `Item` model | Sourced from ERP Core — user handles integration |
|
| `Item` model | Sourced from ERP Core |
|
||||||
| `DeliveryRun` | Not in scope for this phase |
|
| `DeliveryRun` | Not in scope for this phase |
|
||||||
| Driver FK on DeliveryRun | Commented out — depends on HR/Driver module |
|
| Tests | No test project yet |
|
||||||
| Tests | No test project yet — add after endpoints stabilize |
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
This file is historical phase planning only. Current business definitions for ride, sale, order,
|
||||||
|
and fulfillment are maintained in:
|
||||||
|
|
||||||
|
- `docs/03_ride_lifecycle.md`
|
||||||
|
- `docs/04_loading_event.md`
|
||||||
|
- `docs/05_delivery_ride.md`
|
||||||
|
- `docs/06_sale_ride.md`
|
||||||
|
|||||||
+60
-4
@@ -1,8 +1,10 @@
|
|||||||
using System.Text;
|
using System.Security.Claims;
|
||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Features.Auth;
|
using Distribution.Features.Auth;
|
||||||
using Distribution.Features.Brand;
|
using Distribution.Features.Brand;
|
||||||
using Distribution.Features.Category;
|
using Distribution.Features.Category;
|
||||||
|
using Distribution.Features.DeliveryRide;
|
||||||
|
using Distribution.Features.Payment;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
using Distribution.Features.Grn;
|
using Distribution.Features.Grn;
|
||||||
@@ -11,11 +13,15 @@ using Distribution.Features.Order;
|
|||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Po;
|
using Distribution.Features.Po;
|
||||||
using Distribution.Features.PurchaseReturn;
|
using Distribution.Features.PurchaseReturn;
|
||||||
|
using Distribution.Features.Returns;
|
||||||
|
using Distribution.Features.Claims;
|
||||||
|
using Distribution.Features.SupplierClaims;
|
||||||
using Distribution.Features.Region;
|
using Distribution.Features.Region;
|
||||||
using Distribution.Features.LoadingEvent;
|
using Distribution.Features.LoadingEvent;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
using Distribution.Features.Role;
|
using Distribution.Features.Role;
|
||||||
using Distribution.Features.Route;
|
using Distribution.Features.Route;
|
||||||
|
using Distribution.Features.Sale;
|
||||||
using Distribution.Features.Scheme;
|
using Distribution.Features.Scheme;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
using Distribution.Features.Store.Kyc;
|
using Distribution.Features.Store.Kyc;
|
||||||
@@ -49,6 +55,7 @@ builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
|
|||||||
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
|
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
|
||||||
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
|
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
|
||||||
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
|
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
|
||||||
|
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
|
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
|
||||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
@@ -69,6 +76,11 @@ builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
|
|||||||
builder.Services.AddScoped<IPoRepository, PoRepository>();
|
builder.Services.AddScoped<IPoRepository, PoRepository>();
|
||||||
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
|
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
|
||||||
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
|
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
|
||||||
|
builder.Services.AddScoped<IReturnRepository, ReturnRepository>();
|
||||||
|
builder.Services.AddScoped<IClaimRepository, ClaimRepository>();
|
||||||
|
builder.Services.AddScoped<ISupplierClaimRepository, SupplierClaimRepository>();
|
||||||
|
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
|
||||||
|
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
|
||||||
|
|
||||||
builder.Services.AddScoped<RegionService>();
|
builder.Services.AddScoped<RegionService>();
|
||||||
builder.Services.AddScoped<RouteService>();
|
builder.Services.AddScoped<RouteService>();
|
||||||
@@ -76,6 +88,7 @@ builder.Services.AddScoped<VehicleService>();
|
|||||||
builder.Services.AddScoped<StoreService>();
|
builder.Services.AddScoped<StoreService>();
|
||||||
builder.Services.AddScoped<WarehouseService>();
|
builder.Services.AddScoped<WarehouseService>();
|
||||||
builder.Services.AddScoped<OrderService>();
|
builder.Services.AddScoped<OrderService>();
|
||||||
|
builder.Services.AddScoped<SaleService>();
|
||||||
builder.Services.AddScoped<UserService>();
|
builder.Services.AddScoped<UserService>();
|
||||||
builder.Services.AddScoped<RoleService>();
|
builder.Services.AddScoped<RoleService>();
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
@@ -96,10 +109,19 @@ builder.Services.AddScoped<SchemeService>();
|
|||||||
builder.Services.AddScoped<PoService>();
|
builder.Services.AddScoped<PoService>();
|
||||||
builder.Services.AddScoped<GrnService>();
|
builder.Services.AddScoped<GrnService>();
|
||||||
builder.Services.AddScoped<PurchaseReturnService>();
|
builder.Services.AddScoped<PurchaseReturnService>();
|
||||||
|
builder.Services.AddScoped<ReturnService>();
|
||||||
|
builder.Services.AddScoped<ClaimService>();
|
||||||
|
builder.Services.AddScoped<SupplierClaimService>();
|
||||||
|
builder.Services.AddScoped<DeliveryTaskService>();
|
||||||
|
builder.Services.AddScoped<PaymentService>();
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
|
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
|
||||||
|
{
|
||||||
|
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||||
|
client.BaseAddress = new Uri(baseUrl);
|
||||||
|
});
|
||||||
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
||||||
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
||||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||||
@@ -108,7 +130,8 @@ builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler
|
|||||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
||||||
|
|
||||||
var jwtSection = builder.Configuration.GetSection("Jwt");
|
var jwtSection = builder.Configuration.GetSection("Jwt");
|
||||||
var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured.");
|
||||||
|
var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml));
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
@@ -124,10 +147,43 @@ builder.Services.AddAuthentication(options =>
|
|||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
ValidAudience = jwtSection["Audience"],
|
ValidAudience = jwtSection["Audience"],
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
IssuerSigningKey = jwtSigningKey,
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AuthHex issues the token and owns its claim shape; this app's authorization (permission
|
||||||
|
// policies, role-level checks) runs against the *local* Role/Permission tables, so every
|
||||||
|
// validated token is mapped back to a local User/Role once here, not duplicated per
|
||||||
|
// endpoint. A token for a user with no matching local User record is rejected outright —
|
||||||
|
// login via AuthHex does not implicitly provision a local user.
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
var subClaim = context.Principal?.FindFirst("sub")?.Value
|
||||||
|
?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
if (!Guid.TryParse(subClaim, out var userId))
|
||||||
|
{
|
||||||
|
context.Fail("Token is missing a valid user id claim.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
|
||||||
|
var user = await userRepo.GetByIdAsync(userId);
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
{
|
||||||
|
context.Fail("User not found or inactive.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = (ClaimsIdentity)context.Principal!.Identity!;
|
||||||
|
identity.AddClaim(new Claim("username", user.Username));
|
||||||
|
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
|
||||||
|
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|||||||
@@ -10,10 +10,14 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "CHANGE_ME_dev_only_replace_with_a_long_random_secret_before_deploying",
|
"PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
||||||
"Issuer": "Distribution.Api",
|
"Audience": "AuthHexClient",
|
||||||
"Audience": "Distribution.Api",
|
"Issuer": "AuthHex"
|
||||||
"ExpiryMinutes": 120
|
// "ExpiryMinutes": 120
|
||||||
|
|
||||||
|
},
|
||||||
|
"AuthHex": {
|
||||||
|
"BaseUrl": "http://localhost:6102"
|
||||||
},
|
},
|
||||||
"StoreManagement": {
|
"StoreManagement": {
|
||||||
"UnverifiedKycOrderThreshold": 0
|
"UnverifiedKycOrderThreshold": 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 00 — Existing Gap Fixes
|
# 00 — Existing Gap Fixes
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done
|
||||||
**Depends On**: —
|
**Depends On**: —
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 01 — Item Catalog
|
# 01 — Item Catalog
|
||||||
|
|
||||||
**Status**: Ready
|
**Status**: Done
|
||||||
**Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done)
|
**Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,41 +1,59 @@
|
|||||||
# 03 — Ride Lifecycle
|
# 03 - Ride Lifecycle
|
||||||
|
|
||||||
**Status**: Done
|
**Status**: Done
|
||||||
**Depends On**: 02 — Stock Ledger
|
**Depends On**: 02 - Stock Ledger
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Turn the currently-unused `DeliveryRun` model into a fully working ride — a session that turns a
|
Turn the currently-unused `DeliveryRun` model into a fully working ride - a session that turns a
|
||||||
vehicle into a temporary warehouse. Covers driver profile management, ride creation, state
|
vehicle into a temporary warehouse. Covers driver profile management, ride creation, state
|
||||||
transitions, cancellation workflow, and the vehicle breakdown scenario.
|
transitions, cancellation workflow, and the vehicle breakdown scenario.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Current Business Understanding
|
||||||
|
|
||||||
|
- A `Ride` is one operational trip for one vehicle and one driver on one route for now.
|
||||||
|
- `RideType` distinguishes business behavior:
|
||||||
|
- `Delivery` ride: used to fulfill existing orders.
|
||||||
|
- `Sale` ride: used by sales agents in the field to create sales, and every sale creates an
|
||||||
|
order.
|
||||||
|
- `Sale` and `Order` are different:
|
||||||
|
- `Sale` is the commercial transaction created by the sales agent.
|
||||||
|
- `Order` is the fulfillment document created from every sale.
|
||||||
|
- Item lines are maintained once in `OrderItem`; `Sale` does not keep a separate item-line table.
|
||||||
|
- `Sale` statuses use sales language such as `Completed`.
|
||||||
|
- `Order` statuses use logistics language such as `Fulfilled`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### New Entity — DriverProfile
|
### New Entity - DriverProfile
|
||||||
|
|
||||||
Linked one-to-one with `User`. Holds compliance data only — no login or auth fields.
|
Linked one-to-one with `User`. Holds compliance data only - no login or auth fields.
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Id | Guid | PK |
|
| Id | Guid | PK |
|
||||||
| FK_UserId | Guid | FK → User (unique) |
|
| FK_UserId | Guid | FK -> User (unique) |
|
||||||
| LicenseNumber | string | |
|
| LicenseNumber | string | |
|
||||||
| LicenseExpiry | DateTime | |
|
| LicenseExpiry | DateTime | |
|
||||||
| MedicalClearanceExpiry | DateTime | |
|
| MedicalClearanceExpiry | DateTime | |
|
||||||
| IsActive | bool | Can be suspended without deleting |
|
| IsActive | bool | Can be suspended without deleting |
|
||||||
| CreatedAt | DateTime | |
|
| CreatedAt | DateTime | |
|
||||||
|
|
||||||
No license class tied to vehicle type — not required.
|
No license class tied to vehicle type - not required.
|
||||||
|
|
||||||
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled: `LicenseExpiry > today` AND/OR `MedicalClearanceExpiry > today`.
|
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are
|
||||||
|
enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled:
|
||||||
|
`LicenseExpiry > today` and/or `MedicalClearanceExpiry > today`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Updated Entity — Vehicle
|
### Updated Entity - Vehicle
|
||||||
|
|
||||||
Add one nullable field:
|
Add one nullable field:
|
||||||
|
|
||||||
@@ -45,7 +63,7 @@ Add one nullable field:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Updated Entity — DeliveryRun (becomes Ride)
|
### Updated Entity - DeliveryRun (becomes Ride)
|
||||||
|
|
||||||
Add the following fields to the existing `DeliveryRun`:
|
Add the following fields to the existing `DeliveryRun`:
|
||||||
|
|
||||||
@@ -53,34 +71,43 @@ Add the following fields to the existing `DeliveryRun`:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| RideType | RideType | Delivery or Sale |
|
| RideType | RideType | Delivery or Sale |
|
||||||
| Status | RideStatus | State machine below |
|
| Status | RideStatus | State machine below |
|
||||||
| FK_DriverProfileId | Guid | FK → DriverProfile |
|
| FK_DriverProfileId | Guid | FK -> DriverProfile |
|
||||||
| FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. |
|
| FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. |
|
||||||
| StartedAt | DateTime? | Set when Draft → Open |
|
| StartedAt | DateTime? | Set when Draft -> Open |
|
||||||
| ActivatedAt | DateTime? | Set when Open → Active |
|
| ActivatedAt | DateTime? | Set when Open -> Active |
|
||||||
| ClosedAt | DateTime? | Set when Active → Closed |
|
| ClosedAt | DateTime? | Set when Active -> Closed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### New Enum — RideType
|
### New Enum - RideType
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public enum RideType { Delivery, Sale }
|
public enum RideType { Delivery, Sale }
|
||||||
```
|
```
|
||||||
|
|
||||||
### New Enum — RideStatus
|
### New Enum - RideStatus
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
|
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Meaning of statuses:
|
||||||
|
|
||||||
|
- `Draft`: ride exists but operational work has not started.
|
||||||
|
- `Open`: ride is prepared and ready for loading/preparation work.
|
||||||
|
- `Active`: ride is live in the field.
|
||||||
|
- `CancelRequested`: active ride is waiting for cancellation approval or rejection.
|
||||||
|
- `Cancelled`: ride ended through approved cancellation.
|
||||||
|
- `Closed`: ride ended normally.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### New Entity — RideCancelRequest
|
### New Entity - RideCancelRequest
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Id | Guid | PK |
|
| Id | Guid | PK |
|
||||||
| FK_RideId | Guid | FK → DeliveryRun |
|
| FK_RideId | Guid | FK -> DeliveryRun |
|
||||||
| TypeCode | CancelRequestType | Reason category |
|
| TypeCode | CancelRequestType | Reason category |
|
||||||
| Reason | string | Free text explanation |
|
| Reason | string | Free text explanation |
|
||||||
| RequestedBy | Guid | UserId |
|
| RequestedBy | Guid | UserId |
|
||||||
@@ -89,7 +116,7 @@ public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed
|
|||||||
| ApprovedBy | Guid? | UserId of approver |
|
| ApprovedBy | Guid? | UserId of approver |
|
||||||
| ApprovedAt | DateTime? | |
|
| ApprovedAt | DateTime? | |
|
||||||
|
|
||||||
### New Enum — CancelRequestType
|
### New Enum - CancelRequestType
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public enum CancelRequestType
|
public enum CancelRequestType
|
||||||
@@ -101,7 +128,7 @@ public enum CancelRequestType
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### New Entity — DriverComplianceConfig
|
### New Entity - DriverComplianceConfig
|
||||||
|
|
||||||
Single-row configuration table. Controls which compliance checks are enforced at ride open time.
|
Single-row configuration table. Controls which compliance checks are enforced at ride open time.
|
||||||
|
|
||||||
@@ -117,7 +144,7 @@ Managed via a dedicated config endpoint. Only one row ever exists.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### New Enum — CancelRequestStatus
|
### New Enum - CancelRequestStatus
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public enum CancelRequestStatus { Pending, Approved, Rejected }
|
public enum CancelRequestStatus { Pending, Approved, Rejected }
|
||||||
@@ -127,21 +154,22 @@ public enum CancelRequestStatus { Pending, Approved, Rejected }
|
|||||||
|
|
||||||
## State Machine
|
## State Machine
|
||||||
|
|
||||||
```
|
```text
|
||||||
Draft → Open → Active → Closed
|
Draft -> Open -> Active -> Closed
|
||||||
↘ CancelRequested → Cancelled
|
\-> CancelRequested -> Cancelled
|
||||||
```
|
```
|
||||||
|
|
||||||
| Transition | Trigger | What it does |
|
| Transition | Trigger | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Draft → Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
|
| Draft -> Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
|
||||||
| Open → Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
|
| Open -> Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
|
||||||
| Active → Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
|
| Active -> Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
|
||||||
| Active → CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
|
| Active -> CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
|
||||||
| CancelRequested → Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
|
| CancelRequested -> Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
|
||||||
| CancelRequested → Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
|
| CancelRequested -> Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
|
||||||
|
|
||||||
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride transitions to Open. No new orders after Open.
|
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride
|
||||||
|
transitions to Open. No new orders after Open.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -150,7 +178,7 @@ Draft → Open → Active → Closed
|
|||||||
Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse)
|
Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse)
|
||||||
or it is null (warehouse is selected manually at ride creation time).
|
or it is null (warehouse is selected manually at ride creation time).
|
||||||
|
|
||||||
Loading and unloading warehouse are always the same — stock must return to its source warehouse
|
Loading and unloading warehouse are always the same - stock must return to its source warehouse
|
||||||
before a ride can close or cancel.
|
before a ride can close or cancel.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -160,12 +188,12 @@ before a ride can close or cancel.
|
|||||||
1. Driver submits cancel request with `TypeCode = VehicleBreakdown`.
|
1. Driver submits cancel request with `TypeCode = VehicleBreakdown`.
|
||||||
2. Ride status moves to `CancelRequested`.
|
2. Ride status moves to `CancelRequested`.
|
||||||
3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their
|
3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their
|
||||||
current vehicle stock levels and the stranded ride's open orders — supervisor decides manually
|
current vehicle stock levels and the stranded ride's open orders. Supervisor decides manually
|
||||||
which ride takes over which orders.
|
which ride takes over which orders.
|
||||||
4. Supervisor approves the cancel request.
|
4. Supervisor approves the cancel request.
|
||||||
5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse.
|
5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse.
|
||||||
|
|
||||||
The system surfaces the information — it does not automatically reassign orders.
|
The system surfaces the information - it does not automatically reassign orders.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,14 +207,14 @@ Base route: `/api/rides`
|
|||||||
| GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. |
|
| GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. |
|
||||||
| GET | `/{id}` | Get ride detail |
|
| GET | `/{id}` | Get ride detail |
|
||||||
| PUT | `/{id}/driver` | Assign or change driver profile |
|
| PUT | `/{id}/driver` | Assign or change driver profile |
|
||||||
| POST | `/{id}/open` | Transition Draft → Open |
|
| POST | `/{id}/open` | Transition Draft -> Open |
|
||||||
| POST | `/{id}/activate` | Transition Open → Active (called by loading event) |
|
| POST | `/{id}/activate` | Transition Open -> Active (called by loading event) |
|
||||||
| POST | `/{id}/close` | Transition Active → Closed (called by reconciliation) |
|
| POST | `/{id}/close` | Transition Active -> Closed (called by reconciliation) |
|
||||||
| POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) |
|
| POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) |
|
||||||
| GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. |
|
| GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. |
|
||||||
| POST | `/cancel-requests/{id}/approve` | Approve cancel request |
|
| POST | `/cancel-requests/{id}/approve` | Approve cancel request |
|
||||||
| POST | `/cancel-requests/{id}/reject` | Reject cancel request |
|
| POST | `/cancel-requests/{id}/reject` | Reject cancel request |
|
||||||
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock + stranded ride's open orders |
|
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock plus stranded ride's open orders |
|
||||||
|
|
||||||
### DriverComplianceConfig
|
### DriverComplianceConfig
|
||||||
|
|
||||||
@@ -215,37 +243,42 @@ Base route: `/api/driver-profiles`
|
|||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
1. A ride cannot transition Draft → Open if `IsActive = false` on the driver profile. If `EnforceLicenseExpiry = true`, also blocks if license is expired. If `EnforceMedicalClearance = true`, also blocks if medical clearance is expired.
|
1. A ride cannot transition Draft -> Open if `IsActive = false` on the driver profile. If
|
||||||
|
`EnforceLicenseExpiry = true`, also block if license is expired. If
|
||||||
|
`EnforceMedicalClearance = true`, also block if medical clearance is expired.
|
||||||
2. A vehicle cannot be on more than one Open or Active ride at a time.
|
2. A vehicle cannot be on more than one Open or Active ride at a time.
|
||||||
3. Orders can only be assigned to a Delivery ride while it is in Draft status.
|
3. Orders can only be assigned to a Delivery ride while it is in Draft status.
|
||||||
4. Loading and unloading warehouse must be the same — enforced at close/cancel.
|
4. Loading and unloading warehouse must be the same - enforced at close/cancel.
|
||||||
5. A CancelRequested ride cannot close or activate until the request is resolved.
|
5. A CancelRequested ride cannot close or activate until the request is resolved.
|
||||||
6. All cancel requests must have a TypeCode — free text reason alone is not sufficient.
|
6. All cancel requests must have a TypeCode - free text reason alone is not sufficient.
|
||||||
7. All list endpoints return paginated responses.
|
7. All list endpoints return paginated responses.
|
||||||
|
8. A ride uses a single route for now.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
1. **Cancel request type codes** — ✅ Resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other`.
|
1. Cancel request type codes - resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`,
|
||||||
2. **Breakdown assist endpoint** — ✅ Resolved. System shows info only, supervisor decides manually. No auto-reassignment.
|
`Other`.
|
||||||
3. **Driver compliance** — ✅ Resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive` is always enforced. License and medical checks are optional flags, default off.
|
2. Breakdown assist endpoint - resolved. System shows info only, supervisor decides manually. No
|
||||||
|
auto-reassignment.
|
||||||
|
3. Driver compliance - resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive`
|
||||||
|
is always enforced. License and medical checks are optional flags, default off.
|
||||||
|
4. Multi-route ride assignment - deferred for now. Current model stays single-route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
**Build order:**
|
**Build order:**
|
||||||
1. ✅ Enums — `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` → added to `Models/Enums.cs`
|
1. Enums - `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus`
|
||||||
2. ✅ Entities — `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` (new); updated `Vehicle` (+`FK_DefaultWarehouseId`), updated `DeliveryRun` (+`RideType`, `Status`, `FK_DriverProfileId`, `FK_SourceWarehouseId`, `StartedAt`, `ActivatedAt`, `ClosedAt`)
|
2. Entities - `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest`
|
||||||
3. ✅ `AppDBContext` — new DbSets + Fluent config
|
3. `AppDBContext` - new DbSets plus Fluent config
|
||||||
4. ✅ Migrations — `RideLifecycle` (schema), `RidePermissions` (10 new permissions seeded across all roles)
|
4. Migrations - ride lifecycle schema and permissions
|
||||||
5. ✅ DTOs — `DriverProfileDTOs.cs`, `DriverComplianceConfigDTOs.cs`, `RideDTOs.cs`
|
5. DTOs - driver profile, compliance config, ride
|
||||||
6. ✅ Repositories — `IDriverProfileRepository`, `IDriverComplianceConfigRepository`, `IRideRepository`, `IRideCancelRequestRepository`
|
6. Repositories - ride and ride cancel request support
|
||||||
7. ✅ Services — `DriverProfileService`, `DriverComplianceConfigService`, `RideService`
|
7. Services - driver profile, compliance config, ride
|
||||||
8. ✅ Controllers — `DriverProfileController`, `DriverComplianceConfigController`, `RideController`
|
8. Controllers - driver profile, compliance config, ride
|
||||||
9. ✅ DI registration in `Program.cs`
|
9. DI registration in `Program.cs`
|
||||||
|
|
||||||
**Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/`
|
**Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/`
|
||||||
|
|
||||||
**Pending:** Tests for the three new services.
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 04 — Loading Event
|
# 04 - Loading Event
|
||||||
|
|
||||||
**Status**: Planning
|
**Status**: Done
|
||||||
**Depends On**: 03 — Ride Lifecycle
|
**Depends On**: 03 - Ride Lifecycle
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,32 +9,36 @@
|
|||||||
|
|
||||||
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
|
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
|
||||||
When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions
|
When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions
|
||||||
`Open → Active` automatically. This is the first place the stock movement model gets exercised
|
`Open -> Active` automatically.
|
||||||
end-to-end.
|
|
||||||
|
This loading flow serves both ride types:
|
||||||
|
- `Delivery` rides load stock to fulfill orders already linked to the ride.
|
||||||
|
- `Sale` rides load stock so the sales agent can sell in the field and create sale-linked orders.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger — the same math as
|
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger - the same math as a
|
||||||
a warehouse. When loading is confirmed, two stock movements are created per item:
|
warehouse. When loading is confirmed, two stock movements are created per item:
|
||||||
- **Debit** source warehouse: `-qty` at `LocationType.Warehouse`
|
|
||||||
- **Credit** vehicle: `+qty` at `LocationType.Vehicle`
|
|
||||||
|
|
||||||
From this point the vehicle carries live inventory that decreases with every sale or delivery
|
- Debit source warehouse: `-qty` at `LocationType.Warehouse`
|
||||||
(Steps 05/06) and must return to zero before the ride can close (Step 07).
|
- Credit vehicle: `+qty` at `LocationType.Vehicle`
|
||||||
|
|
||||||
|
From this point the vehicle carries live inventory that decreases with every sale or delivery and
|
||||||
|
must return to zero before the ride can close.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### New Entity — LoadingSheet
|
### New Entity - LoadingSheet
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Id | Guid | PK |
|
| Id | Guid | PK |
|
||||||
| FK_RideId | Guid | FK → DeliveryRun |
|
| FK_RideId | Guid | FK -> DeliveryRun |
|
||||||
| FK_SourceWarehouseId | Guid | FK → Warehouse |
|
| FK_SourceWarehouseId | Guid | FK -> Warehouse |
|
||||||
| Status | LoadingSheetStatus | State machine below |
|
| Status | LoadingSheetStatus | State machine below |
|
||||||
| DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch |
|
| DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch |
|
||||||
| DispatchConfirmedBy | Guid? | UserId of warehouse manager |
|
| DispatchConfirmedBy | Guid? | UserId of warehouse manager |
|
||||||
@@ -42,18 +46,18 @@ From this point the vehicle carries live inventory that decreases with every sal
|
|||||||
| ReceiptConfirmedBy | Guid? | UserId of driver |
|
| ReceiptConfirmedBy | Guid? | UserId of driver |
|
||||||
| CreatedAt | DateTime | |
|
| CreatedAt | DateTime | |
|
||||||
|
|
||||||
### New Entity — LoadingSheetItem
|
### New Entity - LoadingSheetItem
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Id | Guid | PK |
|
| Id | Guid | PK |
|
||||||
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
|
| FK_LoadingSheetId | Guid | FK -> LoadingSheet |
|
||||||
| FK_ItemId | Guid | FK → Item |
|
| FK_ItemId | Guid | FK -> Item |
|
||||||
| QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch |
|
| QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch |
|
||||||
| QuantityReceived | decimal? | Set by driver before confirm-receipt |
|
| QuantityReceived | decimal? | Set by driver before confirm-receipt |
|
||||||
| QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy |
|
| QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy |
|
||||||
|
|
||||||
### New Enum — LoadingSheetStatus
|
### New Enum - LoadingSheetStatus
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public enum LoadingSheetStatus
|
public enum LoadingSheetStatus
|
||||||
@@ -66,23 +70,23 @@ public enum LoadingSheetStatus
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
No changes to existing entities. Vehicle capacity check is deferred to Step 13 (Fleet Management).
|
No changes to existing entities. Vehicle capacity check is deferred to Step 13.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## State Machine
|
## State Machine
|
||||||
|
|
||||||
```
|
```text
|
||||||
Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
|
Pending -> DispatchConfirmed -> ReceiptConfirmed
|
||||||
↘ DiscrepancyFlagged → Resolved
|
\-> DiscrepancyFlagged -> Resolved
|
||||||
```
|
```
|
||||||
|
|
||||||
| Transition | Trigger | What it does |
|
| Transition | Trigger | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Pending → DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
|
| Pending -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
|
||||||
| DispatchConfirmed → ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open → Active`. |
|
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open -> Active`. |
|
||||||
| DispatchConfirmed → DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
|
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
|
||||||
| DiscrepancyFlagged → Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open → Active`. |
|
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open -> Active`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,10 +94,11 @@ Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
|
|||||||
|
|
||||||
| Ride Type | How items are added to the sheet |
|
| Ride Type | How items are added to the sheet |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride |
|
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride's route |
|
||||||
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` (no orders to derive from) |
|
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` based on expected field selling demand |
|
||||||
|
|
||||||
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before confirming dispatch. `PUT /{id}/items` is still available to override if needed.
|
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before
|
||||||
|
confirming dispatch. `PUT /{id}/items` is still available to override if needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,12 +106,13 @@ For Delivery rides the warehouse manager reviews the pre-populated quantities an
|
|||||||
|
|
||||||
Created at `ReceiptConfirmed` or `Resolved`. One pair per line item:
|
Created at `ReceiptConfirmed` or `Resolved`. One pair per line item:
|
||||||
|
|
||||||
- **Debit** warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
|
- Debit warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
|
||||||
- **Credit** vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
|
- Credit vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
|
||||||
|
|
||||||
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a discrepancy was resolved.
|
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a
|
||||||
|
discrepancy was resolved.
|
||||||
|
|
||||||
Movement `Type` = `Load` (new `StockMovementType` value to add to enums).
|
Movement `Type` = `Load`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,23 +125,25 @@ Base route: `/api/loading-sheets`
|
|||||||
| POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. |
|
| POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. |
|
||||||
| GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. |
|
| GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. |
|
||||||
| GET | `/{id}` | Any | Get sheet detail with all line items |
|
| GET | `/{id}` | Any | Get sheet detail with all line items |
|
||||||
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities (required for Sale rides, optional for Delivery) |
|
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities |
|
||||||
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending → DispatchConfirmed |
|
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending -> DispatchConfirmed |
|
||||||
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line |
|
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line |
|
||||||
| POST | `/{id}/confirm-receipt` | Driver | → ReceiptConfirmed or DiscrepancyFlagged |
|
| POST | `/{id}/confirm-receipt` | Driver | -> ReceiptConfirmed or DiscrepancyFlagged |
|
||||||
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line → Resolved + ride activation |
|
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line -> Resolved plus ride activation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
1. A loading sheet can only be created for a ride in `Open` status.
|
1. A loading sheet can only be created for a ride in `Open` status.
|
||||||
2. A ride can only have one active loading sheet (not `Resolved` or `ReceiptConfirmed`) at a time.
|
2. A ride can only have one active loading sheet at a time.
|
||||||
3. `QuantityDispatched != QuantityReceived` on any line → `DiscrepancyFlagged`. No partial activation.
|
3. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial activation.
|
||||||
4. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
|
4. Stock movements are created only once - at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
|
||||||
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. No bulk "accept driver" or "accept warehouse" shortcut.
|
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value.
|
||||||
6. All list endpoints return paginated responses.
|
6. All list endpoints return paginated responses.
|
||||||
7. Vehicle capacity check is not enforced in this step — deferred to Step 13.
|
7. Vehicle capacity check is not enforced in this step.
|
||||||
|
8. Immediate handover from a Sale ride does not remove the need for an `Order`; every sale still
|
||||||
|
creates an order, and that order is fulfilled immediately when stock is handed over at sale time.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,54 +153,27 @@ Base route: `/api/loading-sheets`
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `loading.view` | All roles |
|
| `loading.view` | All roles |
|
||||||
| `loading.dispatch` | Warehouse manager and above |
|
| `loading.dispatch` | Warehouse manager and above |
|
||||||
| `loading.receive` | Driver (AreaManager and above for testing) |
|
| `loading.receive` | Driver |
|
||||||
| `loading.resolve` | Supervisor and above |
|
| `loading.resolve` | Supervisor and above |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fraud / Control Considerations
|
## Fraud / Control Considerations
|
||||||
|
|
||||||
- Discrepancies are immediately visible and block activation — they cannot be silently absorbed.
|
- Discrepancies are immediately visible and block activation.
|
||||||
- `QuantityResolved` is permanently stored per line for audit purposes, even after the sheet is resolved.
|
- `QuantityResolved` is permanently stored per line for audit purposes.
|
||||||
- See [09_fraud_controls.md](./09_fraud_controls.md) for the audit trail layer that will sit on top of this.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- ✅ Vehicle capacity check — deferred to Step 13 (Fleet Management).
|
1. Vehicle capacity check - deferred to Step 13.
|
||||||
- ✅ Discrepancy resolution — supervisor enters `QuantityResolved` per line item.
|
2. Discrepancy resolution - supervisor enters `QuantityResolved` per line item.
|
||||||
- ✅ Ride activation trigger — `confirm-receipt` and `resolve` auto-trigger `Open → Active` internally.
|
3. Ride activation trigger - `confirm-receipt` and `resolve` auto-trigger `Open -> Active`.
|
||||||
- ✅ Item population — Delivery rides auto-populated from orders; Sale rides manually entered.
|
4. Delivery item population - from assigned orders on the ride's route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
**Build order:**
|
Build after the ride model and before full delivery/sale execution.
|
||||||
1. Enum — add `LoadingSheetStatus` to `Models/Enums.cs`; add `Load` to `StockMovementType`
|
|
||||||
2. Entities — `LoadingSheet`, `LoadingSheetItem` (new)
|
|
||||||
3. `AppDBContext` — new DbSets + Fluent config
|
|
||||||
4. Migration — `LoadingEvent` (schema) + `LoadingPermissions` (4 new permissions seeded)
|
|
||||||
5. DTOs — `LoadingSheetDTOs.cs`
|
|
||||||
6. Repository — `ILoadingSheetRepository`, `LoadingSheetRepository`
|
|
||||||
7. Service — `LoadingSheetService`
|
|
||||||
8. Controller — `LoadingSheetController`
|
|
||||||
9. DI registration in `Program.cs`
|
|
||||||
|
|
||||||
**Feature folder:** `Features/LoadingEvent/`
|
|
||||||
|
|
||||||
**What was built:**
|
|
||||||
1. ✅ Enums — added `LoadingSheetStatus` and `Load` to `MovementType` in `Models/Enums.cs`
|
|
||||||
2. ✅ Entities — `LoadingSheet`, `LoadingSheetItem` created in `Models/`
|
|
||||||
3. ✅ `AppDBContext` — `LoadingSheets`, `LoadingSheetItems` DbSets added; Fluent config for FK restrictions and ride/warehouse relationships
|
|
||||||
4. ✅ DTOs — `LoadingSheetDTOs.cs` in `Features/LoadingEvent/`
|
|
||||||
5. ✅ Repository — `ILoadingSheetRepository`, `LoadingSheetRepository` in `Features/LoadingEvent/`
|
|
||||||
6. ✅ Service — `LoadingSheetService` in `Features/LoadingEvent/`
|
|
||||||
7. ✅ Controller — `LoadingSheetController` in `Features/LoadingEvent/`
|
|
||||||
8. ✅ DI registration in `Program.cs`
|
|
||||||
9. ⚠️ Permissions added to seed (`loading.view`, `loading.dispatch`, `loading.receive`, `loading.resolve`) — **authorization layer is being replaced, skip this**
|
|
||||||
|
|
||||||
**Pending (run manually):**
|
|
||||||
- `dotnet ef migrations add LoadingEvent` — blocked by a pre-existing duplicate Permission seed ID in `AppDBContextModelSnapshot.cs` (ID `20000000-0000-0000-0000-000000000040` is assigned to both `categories.delete` and `driver_profiles.create`). This is a snapshot consistency issue from a previous session and needs to be resolved before migration generation will succeed.
|
|
||||||
- `dotnet ef database update` after migration is generated.
|
|
||||||
|
|||||||
@@ -1,79 +1,148 @@
|
|||||||
# 05 — Delivery Ride Execution
|
# 05 — Delivery Ride Execution
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done
|
||||||
**Depends On**: 04 — Loading Event
|
**Depends On**: 04 — Loading Event
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half
|
Fulfill pending orders against an active Delivery ride. The driver visits each store on the route,
|
||||||
of [mainPlan.md §1](./mainPlan.md#1-order-management):
|
delivers the ordered items, gets customer confirmation, and the system creates the stock movement,
|
||||||
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist
|
updates the order, and completes the sale.
|
||||||
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
Today `Order` has no status and no link to a ride. This step is where the order lifecycle
|
- A Delivery ride goes `Active` after the loading event is confirmed.
|
||||||
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the
|
- Orders are linked to a ride implicitly through route: `Ride → Route → Stores → Pending Orders`.
|
||||||
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)).
|
- `QuantityFulfilled` on `OrderItem` tracks what was actually delivered (added in pre-planning).
|
||||||
|
- No separate backorder entity — partially fulfilled orders stay `PartiallyFulfilled` and get picked
|
||||||
|
up by the next delivery ride on the same route.
|
||||||
|
- Customer confirmation in v1 is a boolean flag (`CustomerConfirmed`). Full signature/OTP deferred
|
||||||
|
to Step 09 (Fraud Controls).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### Delivery Task (per order on ride)
|
### New Entity — DeliveryTask
|
||||||
|
|
||||||
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`,
|
Extends `Template` (adds `IsActive`, `CreatedAt` — consistent with all other entities).
|
||||||
`Failed`).
|
|
||||||
|
|
||||||
### Delivery Outcome
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Id | Guid | PK (from Template) |
|
||||||
|
| FK_RideId | Guid | FK → DeliveryRun |
|
||||||
|
| FK_OrderId | Guid | FK → Order |
|
||||||
|
| Sequence | int | Visit order on the route |
|
||||||
|
| Status | DeliveryTaskStatus | State machine below |
|
||||||
|
| CustomerConfirmed | bool | Set to true at confirm time |
|
||||||
|
| ConfirmedAt | DateTime? | Timestamp of customer confirmation |
|
||||||
|
| Note | string? | Driver note on failure or partial delivery |
|
||||||
|
| IsActive | bool | From Template (soft delete) |
|
||||||
|
| CreatedAt | DateTime | From Template |
|
||||||
|
|
||||||
Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder`
|
### New Enum — DeliveryTaskStatus
|
||||||
for the shortfall.
|
|
||||||
|
|
||||||
### Customer Confirmation
|
```csharp
|
||||||
|
public enum DeliveryTaskStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Delivered,
|
||||||
|
PartiallyDelivered,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Signature or OTP captured against the delivery task before it can be marked complete.
|
### No new columns on OrderItem
|
||||||
|
|
||||||
|
`QuantityFulfilled` already added in pre-planning. No further model changes needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
Pending → Delivered (all lines fully delivered + customer confirmed)
|
||||||
|
Pending → PartiallyDelivered (some lines short + customer confirmed)
|
||||||
|
Pending → Failed (driver could not deliver, no stock movement)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stock Movements
|
||||||
|
|
||||||
|
Created on `Delivered` or `PartiallyDelivered` after customer confirmation. One movement per item:
|
||||||
|
|
||||||
|
- **Debit** vehicle: `-QuantityFulfilled` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
|
||||||
|
- `VehicleId` is resolved from `DeliveryTask.FK_RideId → DeliveryRun.FK_VehicleId`
|
||||||
|
- `MovementType.Sale`
|
||||||
|
- `ReferenceId = OrderId`
|
||||||
|
|
||||||
|
No movement on `Failed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Order and Sale Status Transitions (triggered internally)
|
||||||
|
|
||||||
|
| Condition | Order | Sale |
|
||||||
|
|---|---|---|
|
||||||
|
| All OrderItem lines: QuantityFulfilled == Quantity | → Fulfilled | → Completed |
|
||||||
|
| Any OrderItem line: QuantityFulfilled < Quantity | → PartiallyFulfilled | stays Pending |
|
||||||
|
| Task Failed | stays Pending | stays Pending |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
Base routes: `/api/rides/{rideId}/tasks` and `/api/delivery-tasks`
|
||||||
|---|---|
|
|
||||||
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` |
|
| Method | Route | Who | Description |
|
||||||
| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) |
|
|---|---|---|---|
|
||||||
| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` |
|
| POST | `/api/rides/{rideId}/tasks/generate` | Driver/System | Auto-create tasks from all Pending/PartiallyFulfilled orders for stores on ride's route |
|
||||||
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP |
|
| GET | `/api/rides/{rideId}/tasks` | Driver | List all tasks for this ride with current status |
|
||||||
|
| GET | `/api/delivery-tasks/{id}` | Driver | Task detail with order items and quantities |
|
||||||
|
| PUT | `/api/delivery-tasks/{id}/quantities` | Driver | Set QuantityFulfilled per order item before confirming |
|
||||||
|
| POST | `/api/delivery-tasks/{id}/confirm` | Driver | Customer confirmed — creates stock movements, transitions order/sale |
|
||||||
|
| POST | `/api/delivery-tasks/{id}/fail` | Driver | Mark task as failed with a note |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- A delivery task cannot be marked `Delivered` without a customer confirmation.
|
1. Delivery tasks can only be created for a ride in `Active` status and of type `Delivery`.
|
||||||
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the
|
2. One task per order per ride — cannot create a second task for the same order on the same ride.
|
||||||
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape).
|
3. `QuantityFulfilled` must be set on all order items before calling confirm.
|
||||||
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle
|
4. `QuantityFulfilled` cannot exceed `Quantity` on any line.
|
||||||
location per [02_stock_ledger.md](./02_stock_ledger.md).
|
5. `CustomerConfirmed` must be true before the task can move to `Delivered` or `PartiallyDelivered`.
|
||||||
|
6. Stock movement is created only once — at confirm. Never on fail.
|
||||||
|
7. A failed task does not change the order or sale status — the order stays available for the next ride.
|
||||||
|
8. Partial delivery: the unfulfilled quantity (`Quantity - QuantityFulfilled`) remains on the same
|
||||||
|
order. The next delivery ride on the same route will pick it up automatically via `generate`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fraud / Control Considerations
|
## Implementation Build Order
|
||||||
|
|
||||||
- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not
|
1. Enum — add `DeliveryTaskStatus` to `Models/Enums.cs`
|
||||||
editable after submission).
|
2. Entity — `DeliveryTask` in `Models/`
|
||||||
|
3. `AppDBContext` — new DbSet + Fluent config (Restrict on Ride, Order FKs)
|
||||||
|
4. Migration
|
||||||
|
5. DTOs — `DeliveryTaskDTOs.cs` in `Features/DeliveryRide/`
|
||||||
|
6. Repository — `IDeliveryTaskRepository`, `DeliveryTaskRepository`
|
||||||
|
7. Service — `DeliveryTaskService`
|
||||||
|
8. Controller — `DeliveryTaskController`
|
||||||
|
9. DI registration in `Program.cs`
|
||||||
|
|
||||||
|
**Feature folder:** `Features/DeliveryRide/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions — Resolved
|
||||||
|
|
||||||
- Signature capture (image) or OTP (SMS code) — one, or both, in v1?
|
- ✅ Customer confirmation — boolean flag in v1, full signature/OTP in Step 09
|
||||||
|
- ✅ Backorder — no separate entity; same order stays PartiallyFulfilled for next ride
|
||||||
---
|
- ✅ Task generation — on-demand via `generate` endpoint, not automatic on ride activation
|
||||||
|
- ✅ Failed task — order stays Pending, available for next ride, no stock movement
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
|
||||||
|
|||||||
@@ -1,80 +1,132 @@
|
|||||||
# 06 — Sale Ride Execution
|
# 06 — Sale Ride Execution
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done
|
||||||
**Depends On**: 04 — Loading Event
|
**Depends On**: 04 — Loading Event
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is
|
Allow a sales agent on an active Sale ride to create sales in the field. Every sale creates an
|
||||||
generated immediately, and payment is captured at point of sale. Covers `createInvoice`,
|
order. Immediate handover deducts vehicle stock and closes the order immediately. Non-immediate
|
||||||
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting),
|
handover leaves the order pending for a future delivery ride.
|
||||||
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
|
|
||||||
(§11) if promotions are live by the time this ships.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row —
|
- `SaleService.CreateAsync` already exists and handles both `FulfillImmediately` paths.
|
||||||
the field invoice *is* the order, created and settled in one step.
|
- What Step 06 adds: vehicle stock validation and deduction on immediate handover, and the
|
||||||
|
Payment recording endpoint.
|
||||||
|
- `Sale.PaymentStatus` and the `Payment` model were added in pre-planning.
|
||||||
|
- Pricing is already resolved via `PriceListService` inside `SaleService`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### Field Invoice
|
No new entities. Everything needed was added in pre-planning:
|
||||||
|
|
||||||
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`,
|
| What | Where | Status |
|
||||||
`OnCredit`).
|
|---|---|---|
|
||||||
|
| `Payment` entity | `Models/Payment.cs` | ✅ Done |
|
||||||
|
| `PaymentStatus` enum | `Models/Enums.cs` | ✅ Done |
|
||||||
|
| `PaymentMethod` enum | `Models/Enums.cs` | ✅ Done |
|
||||||
|
| `Sale.PaymentStatus` | `Models/Sale.cs` | ✅ Done |
|
||||||
|
| `OrderItem.QuantityFulfilled` | `Models/OrderItem.cs` | ✅ Done |
|
||||||
|
|
||||||
### Field Invoice Line Item
|
Migration needed for: `Payment` table, `Sale.PaymentStatus` column, `OrderItem.QuantityFulfilled` column.
|
||||||
|
|
||||||
`FieldInvoiceId`, `ItemId`, `Quantity`, `UnitPrice` (subject to the discount-limit rules in
|
|
||||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md)).
|
|
||||||
|
|
||||||
### Payment Record
|
|
||||||
|
|
||||||
`FieldInvoiceId`, `Amount`, `Method` (`Cash`, `Credit`), `CollectedAt`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## Changes to Existing SaleService
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
`SaleService.CreateAsync` needs two additions when `FulfillImmediately = true` and `RideId` is set:
|
||||||
|---|---|
|
|
||||||
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here |
|
1. **Stock check** — for each item, verify vehicle has enough stock (`StockBalance` at
|
||||||
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` |
|
`LocationType.Vehicle` with `LocationId = VehicleId`). Reject the whole sale if any item is short.
|
||||||
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits |
|
2. **Stock deduction** — debit vehicle balance per item, create one `StockMovement` per item:
|
||||||
|
- `MovementType.Sale`
|
||||||
|
- `FromLocationId = VehicleId`, `FromLocationType = Vehicle`
|
||||||
|
- `ReferenceId = OrderId`
|
||||||
|
|
||||||
|
`VehicleId` is resolved from `Sale.FK_RideId → DeliveryRun.FK_VehicleId`.
|
||||||
|
|
||||||
|
**SaleService constructor additions required:**
|
||||||
|
- `IStockRepository` — for stock balance check and debit
|
||||||
|
- `IRideRepository` — to look up the ride and resolve `FK_VehicleId`
|
||||||
|
|
||||||
|
When `FulfillImmediately = false`, no stock movement — order stays Pending for a delivery ride.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Feature — Payment
|
||||||
|
|
||||||
|
**Feature folder:** `Features/Payment/`
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Method | Route | Who | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
|
||||||
|
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
|
||||||
|
|
||||||
|
### Payment Creation Logic
|
||||||
|
|
||||||
|
On `POST /api/sales/{id}/payments`:
|
||||||
|
1. Validate sale exists and is not `Cancelled`.
|
||||||
|
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
|
||||||
|
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
|
||||||
|
- `sum == 0` → `Pending`
|
||||||
|
- `sum < Sale.TotalAmount` → `PartiallyPaid`
|
||||||
|
- `sum >= Sale.TotalAmount` → `Paid`
|
||||||
|
4. Update `Sale.PaymentStatus` in the same transaction.
|
||||||
|
|
||||||
|
### DTOs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||||
|
public record PaymentResponse(Guid Id, Guid SaleId, Guid StoreId, decimal Amount,
|
||||||
|
PaymentMethod Method, DateTime CollectedAt, Guid CollectedBy);
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against
|
1. Driver cannot sell more than vehicle stock for immediate handover — entire sale rejected if any
|
||||||
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the
|
item is short (no partial sale creation).
|
||||||
invoice is finalized.
|
2. Every sale creates an order, even when all items are handed over immediately.
|
||||||
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location,
|
3. `FulfillImmediately = true`:
|
||||||
immediately (not batched at ride close).
|
- Vehicle stock debited immediately per item.
|
||||||
- Discount applied per line must not exceed the agent's role-level limit (§8).
|
- Order → `Fulfilled`, Sale → `Completed` at creation time (already in SaleService).
|
||||||
|
- `QuantityFulfilled = Quantity` on all order items (already in SaleService).
|
||||||
|
4. `FulfillImmediately = false`:
|
||||||
|
- No stock movement at sale time.
|
||||||
|
- Order → `Pending`, Sale → `Pending` at creation time.
|
||||||
|
5. Payment can be recorded at any time — sale does not need to be `Completed` first.
|
||||||
|
6. Overpayment is allowed (`sum > TotalAmount`) — `PaymentStatus` stays `Paid`.
|
||||||
|
7. A `Cancelled` sale cannot receive new payments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fraud / Control Considerations
|
## Implementation Build Order
|
||||||
|
|
||||||
- Cash collected must reconcile against invoiced totals at ride close — see
|
1. Migration — `Payment` table, `Sale.PaymentStatus`, `OrderItem.QuantityFulfilled`
|
||||||
[07_ride_reconciliation.md](./07_ride_reconciliation.md).
|
2. Extend `SaleService.CreateAsync` — stock check + stock movement on `FulfillImmediately`
|
||||||
|
3. DTOs — `PaymentDTOs.cs` in `Features/Payment/`
|
||||||
|
4. Repository — `IPaymentRepository`, `PaymentRepository`
|
||||||
|
5. Service — `PaymentService`
|
||||||
|
6. Controller — `PaymentController`
|
||||||
|
7. DI registration in `Program.cs`
|
||||||
|
|
||||||
|
**Feature folder:** `Features/Payment/` (new), `Features/Sale/` (extended)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions — Resolved
|
||||||
|
|
||||||
- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one
|
- ✅ Payment timing — can be recorded at any time, not locked to sale completion
|
||||||
sequence?
|
- ✅ Overpayment — allowed, status stays Paid
|
||||||
|
- ✅ Stock check scope — entire sale rejected if any item is short, no partial creation
|
||||||
---
|
- ✅ Non-immediate handover — no stock movement at sale time, order picked up by delivery ride
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ GoodsReceiptItem
|
|||||||
```
|
```
|
||||||
|
|
||||||
### PurchaseReturn
|
### PurchaseReturn
|
||||||
|
|
||||||
|
> **Not the same as `SaleReturn`** ([18_returns_claims.md](./18_returns_claims.md)), despite the
|
||||||
|
> similar name. `PurchaseReturn` moves goods **Distributor → Supplier** (debits the receiving
|
||||||
|
> warehouse); `SaleReturn` moves goods **Shop → Distributor** (credits a delivery vehicle). Also
|
||||||
|
> distinct from `SupplierClaim` ([18_returns_claims.md](./18_returns_claims.md)), which is for
|
||||||
|
> supplier disputes that don't involve physically returning goods (short-shipment, price dispute)
|
||||||
|
> and — unlike this entity — requires approval. See that doc's "Four distinct entities" table for
|
||||||
|
> the full comparison.
|
||||||
|
|
||||||
```
|
```
|
||||||
PurchaseReturn
|
PurchaseReturn
|
||||||
├── Id (Guid) — PK, from Template
|
├── Id (Guid) — PK, from Template
|
||||||
|
|||||||
@@ -1,74 +1,469 @@
|
|||||||
# 18 — Returns & Claims Management
|
# 18 — Returns & Claims Management
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done — migrations applied to the database. Inspection/classification for customer
|
||||||
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
|
returns is deferred (see Open Questions).
|
||||||
|
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for
|
||||||
|
`SupplierClaim`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
|
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where
|
||||||
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
|
a sales rep records a return during a store visit and a claim workflow against a Regional Agency,
|
||||||
both reference this step's credit note and approval shapes — this is where those are actually
|
plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve
|
||||||
defined.
|
physically returning goods.
|
||||||
|
|
||||||
|
### Four distinct entities — don't conflate them
|
||||||
|
|
||||||
|
`SaleReturn` and `PurchaseReturn` are both named "...Return" but move goods in opposite
|
||||||
|
directions, and neither is the same as the two `Claim` entities, which never move stock at all:
|
||||||
|
|
||||||
|
| Entity | Direction | Where it lives | Approval? | Stock effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SaleReturn` | Shop → Distributor | This step (`Features/Returns`) | No — settles immediately | Credits the ride's **vehicle** |
|
||||||
|
| `Claim` | vs. Regional Agency | This step (`Features/Claims`) | Settle only (`claims.settle`) | None |
|
||||||
|
| `PurchaseReturn` | Distributor → Supplier | [15_procurement.md](./15_procurement.md) (`Features/PurchaseReturn`) | No — settles immediately | Debits the receiving **warehouse** |
|
||||||
|
| `SupplierClaim` | vs. Supplier | This step (`Features/SupplierClaims`) | Yes (`suppliers.claims.approve`) | None — financial/administrative record only |
|
||||||
|
|
||||||
|
`PurchaseReturn` is for physically shipping goods back to a supplier; `SupplierClaim` is for
|
||||||
|
disputes that don't (or can't yet) involve moving goods back — a short-shipment, damage, or a
|
||||||
|
price dispute.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
No return, credit note, or claim entity exists today. `createBackorder` (§1,
|
No return, credit note, or claim entity existed before this step. `createBackorder` (§1,
|
||||||
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
|
[05_delivery_ride.md](./05_delivery_ride.md)) needed a credit note target once this step landed.
|
||||||
|
|
||||||
|
The original spec (mainPlan §12) framed customer returns as an async, supervisor-approved
|
||||||
|
workflow — `createReturnRequest` → `approveReturn` → stock credited only on approval. **That model
|
||||||
|
was dropped during planning.** In practice a sales rep records a return on the spot, during the
|
||||||
|
same store visit as a new order, and it must settle immediately (stock back in the vehicle, the
|
||||||
|
new order's amount adjusted) — there is no point in the field workflow where a return sits
|
||||||
|
"pending" a remote supervisor's sign-off. See Business Rules for what this changes.
|
||||||
|
|
||||||
|
The existing `PurchaseReturn` ([15_procurement.md](./15_procurement.md)) only covers physically
|
||||||
|
returning received goods to a supplier and debiting stock — it has no place for a claim that
|
||||||
|
doesn't (or can't yet) involve moving goods back: a short-shipment (500 ordered, 480 received),
|
||||||
|
goods damaged in transit, or a price dispute. `SupplierClaim` was added alongside `PurchaseReturn`
|
||||||
|
for this, not as a rework of it.
|
||||||
|
|
||||||
|
### Connections to existing modules
|
||||||
|
|
||||||
|
- **Order Management**: a `SaleReturn` is always filed against a specific open `Order`
|
||||||
|
(`Features/Order`) and immediately nets its effect onto that order's `TotalAmount` /
|
||||||
|
`OrderItem.Quantity` — no `Invoice` entity exists yet (Finance, step 19, not built), so there is
|
||||||
|
nothing else for a credit note to net against.
|
||||||
|
- **Stock Ledger** ([02_stock_ledger.md](./02_stock_ledger.md)): `SaleReturn` credits
|
||||||
|
`StockBalance` at the active ride's vehicle (`MovementType.CustomerReturn`), the same enum value
|
||||||
|
`StockService.RecordCustomerReturnAsync` already defined but that no sales-side flow was wiring
|
||||||
|
up until this step. `SupplierClaim` writes no stock movement at all (see Business Rules).
|
||||||
|
- **Ride Execution** ([05_delivery_ride.md](./05_delivery_ride.md)): a `SaleReturn` can only be
|
||||||
|
recorded against an order created during an active ride (`Sale.FK_RideId` must be set), since
|
||||||
|
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`).
|
||||||
|
- **Region Management** ([10_region_management.md](./10_region_management.md)): there is no
|
||||||
|
separate Agency entity in the codebase — "Regional Agency" is a `Role`
|
||||||
|
(`Data/Seed/RoleAndPermissionSeed.cs`), so `Claim` references the `User` holding that role.
|
||||||
|
- **Procurement** ([15_procurement.md](./15_procurement.md)): `SupplierClaim.FK_PurchaseOrderId`
|
||||||
|
is required, `FK_GoodsReceiptId` optional; a short-shipment is already excluded from stock at
|
||||||
|
receipt time (`GoodsReceiptItem.RejectedQuantity`), so `SupplierClaim` never needs to touch
|
||||||
|
`StockBalance` itself.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### Return Request
|
Six new tables.
|
||||||
|
|
||||||
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
|
### SaleReturn
|
||||||
`Rejected`), `RequestedByUserId`.
|
```
|
||||||
|
SaleReturn
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_OrderId (Guid) — required, the order this return is filed against
|
||||||
|
├── Mode (enum) — CreditDeduction, ItemExchange — chosen by the rep per return
|
||||||
|
├── TotalCreditAmount (decimal)— sum(Quantity * UnitPrice) for CreditDeduction mode; 0 for ItemExchange
|
||||||
|
├── CreatedByUserId (Guid) — required, the rep who recorded it
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
### Credit Note
|
### SaleReturnItem
|
||||||
|
```
|
||||||
|
SaleReturnItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SaleReturnId (Guid) — required
|
||||||
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Quantity (decimal) — returned quantity, credited back to the ride's vehicle
|
||||||
|
├── ReasonCode (enum) — Expired, Damaged, OrderError, Other
|
||||||
|
├── UnitPrice (decimal) — snapshot at return time (from the matching OrderItem if one
|
||||||
|
│ exists on this order, else Item.SalePrice)
|
||||||
|
```
|
||||||
|
|
||||||
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`.
|
### CreditNote
|
||||||
|
```
|
||||||
|
CreditNote
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SaleReturnId (Guid) — required
|
||||||
|
├── Amount (decimal) — amount netted off the order's TotalAmount
|
||||||
|
├── IssuedAt (DateTime)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
Audit-trail only — written for `CreditDeduction`-mode returns, not itself an approval gate or an
|
||||||
|
invoice-linked document (no `Invoice` entity exists yet; see Background/Context).
|
||||||
|
|
||||||
### Claim
|
### Claim
|
||||||
|
```
|
||||||
|
Claim
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_AgencyUserId (Guid) — required, the User holding the RegionalAgency role this claim is against
|
||||||
|
├── Details (string) — required
|
||||||
|
├── Status (enum) — Open, Settled
|
||||||
|
├── SettlementAmount (decimal?)— set once settled
|
||||||
|
├── SettledByUserId (Guid?), SettledAt (DateTime?)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
Independent of `SaleReturn`/stock — its own open/settle workflow.
|
||||||
|
|
||||||
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled).
|
### SupplierClaim
|
||||||
|
```
|
||||||
|
SupplierClaim
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_PurchaseOrderId (Guid) — required
|
||||||
|
├── FK_GoodsReceiptId (Guid?) — optional; set when the claim stems from a specific receipt
|
||||||
|
│ (e.g. short/damaged at receipt)
|
||||||
|
├── Details (string) — description of the issue
|
||||||
|
├── EvidenceUrl (string?) — link/path to a photo or document; no file-upload storage
|
||||||
|
│ exists yet, so this is a plain string field for now
|
||||||
|
├── Status (enum) — Open, Approved, Rejected
|
||||||
|
├── ResolutionType (enum?) — Replacement, Credit, Refund, PriceAdjustment — set on approve
|
||||||
|
├── ResolutionAmount (decimal?) — for Credit / Refund / PriceAdjustment
|
||||||
|
├── ResolutionNotes (string?)
|
||||||
|
├── RaisedByUserId (Guid)
|
||||||
|
├── ResolvedByUserId (Guid?), ResolvedAt (DateTime?)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
|
### SupplierClaimItem
|
||||||
|
```
|
||||||
|
SupplierClaimItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SupplierClaimId (Guid) — required
|
||||||
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Quantity (decimal)
|
||||||
|
├── IssueType (enum) — Damaged, Short, Expired, WrongItem, Other
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Two migrations, applied:
|
||||||
|
1. **AddReturnsAndClaims** (`20260825073453_AddReturnsAndClaims`) — created `SaleReturns`,
|
||||||
|
`SaleReturnItems`, `CreditNotes`, `Claims`, with FKs to `Orders`/`Items`/`Users` and indexes on
|
||||||
|
every FK column.
|
||||||
|
2. **AddSupplierClaims** (`20260825084218_AddSupplierClaims`) — created `SupplierClaims`,
|
||||||
|
`SupplierClaimItems`, with FKs to `PurchaseOrders`/`GoodsReceipts`/`Items`/`Users` and indexes on
|
||||||
|
every FK column.
|
||||||
|
|
||||||
|
Both applied via `dotnet ef database update`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
> Return routes use bare `[Authorize]` — matches the Supplier/PriceList/Procurement convention (no
|
||||||
|
> per-action policy). `claims.settle` and `suppliers.claims.approve` are the two enforced policies,
|
||||||
|
> since those are the two steps with real approval weight. See Permissions to Add to Seed.
|
||||||
|
|
||||||
|
### Returns (`Features/Returns`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` |
|
| `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles |
|
||||||
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
|
| `getSaleReturn(returnId)` | `GET /api/returns/{id}` |
|
||||||
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
|
| `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` |
|
||||||
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
|
|
||||||
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
|
### Claims (`Features/Claims`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `createClaim(agencyUserId, details)` | `POST /api/claims` |
|
||||||
|
| `getClaim(claimId)` | `GET /api/claims/{id}` |
|
||||||
|
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` — `[Authorize(Policy = "claims.settle")]` |
|
||||||
|
|
||||||
|
### Supplier Claims (`Features/SupplierClaims`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | `POST /api/purchase-orders/{id}/claims` |
|
||||||
|
| `getSupplierClaim(claimId)` | `GET /api/supplier-claims/{id}` |
|
||||||
|
| `listSupplierClaims(poId)` | `GET /api/purchase-orders/{id}/claims` |
|
||||||
|
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | `POST /api/supplier-claims/{id}/approve` — `[Authorize(Policy = "suppliers.claims.approve")]` |
|
||||||
|
| `rejectSupplierClaim(claimId, notes?)` | `POST /api/supplier-claims/{id}/reject` — `[Authorize(Policy = "suppliers.claims.approve")]` |
|
||||||
|
|
||||||
|
`approve`/`reject` combine the "inspect the claim" and "decide the outcome" steps into one call —
|
||||||
|
unlike the customer-side flow there's no separate physical-inspection step to wait on here, since
|
||||||
|
the evidence (photos/notes, and `GoodsReceiptItem.RejectedQuantity` if applicable) is already
|
||||||
|
attached at claim-creation time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / Response Shapes
|
||||||
|
|
||||||
|
### POST `/api/orders/{id}/returns`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "CreditDeduction",
|
||||||
|
"items": [{ "itemId": "...", "quantity": 5, "reasonCode": "Expired" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`mode` is `CreditDeduction` or `ItemExchange`, `reasonCode` is `Expired` | `Damaged` |
|
||||||
|
`OrderError` | `Other`.
|
||||||
|
|
||||||
|
### POST `/api/claims`
|
||||||
|
```json
|
||||||
|
{ "agencyUserId": "...", "details": "Short-shipped 20 units on last consignment" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/claims/{id}/settle`
|
||||||
|
```json
|
||||||
|
{ "settlementAmount": 1500.00 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/purchase-orders/{id}/claims`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"goodsReceiptId": "...",
|
||||||
|
"details": "20 units arrived damaged, 480 of 500 received",
|
||||||
|
"evidenceUrl": "https://...",
|
||||||
|
"items": [{ "itemId": "...", "quantity": 20, "issueType": "Damaged" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`goodsReceiptId` and `evidenceUrl` are optional. `issueType` is `Damaged` | `Short` | `Expired` |
|
||||||
|
`WrongItem` | `Other`.
|
||||||
|
|
||||||
|
### POST `/api/supplier-claims/{id}/approve`
|
||||||
|
```json
|
||||||
|
{ "resolutionType": "Credit", "resolutionAmount": 850.00, "resolutionNotes": "Agreed with supplier" }
|
||||||
|
```
|
||||||
|
`resolutionType` is `Replacement` | `Credit` | `Refund` | `PriceAdjustment`. `resolutionAmount` is
|
||||||
|
required unless `resolutionType` is `Replacement`.
|
||||||
|
|
||||||
|
### POST `/api/supplier-claims/{id}/reject`
|
||||||
|
```json
|
||||||
|
{ "resolutionNotes": "Discrepancy within accepted tolerance" }
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
|
1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This
|
||||||
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
|
deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see
|
||||||
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
|
Background/Context) since a rep records the return on the spot during a live store visit.
|
||||||
the `ReturnWithoutSale` anomaly flagged automatically in
|
2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or
|
||||||
[09_fraud_controls.md](./09_fraud_controls.md).
|
`PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since
|
||||||
- Approving a return creates a `Return` stock movement crediting the relevant location, per
|
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`,
|
||||||
[02_stock_ledger.md](./02_stock_ledger.md).
|
`MovementType.CustomerReturn`).
|
||||||
|
3. `createSaleReturn` rejects (409 Conflict) any line item the store has never had on **any** of
|
||||||
|
its past orders — checked across the store's full order history, not just the order the return
|
||||||
|
is filed against. This lets an expired or damaged item bought weeks ago still be returned; it
|
||||||
|
only blocks an item that store never bought at all — the real fraud case originally scoped as
|
||||||
|
`ReturnWithoutSale` in [09_fraud_controls.md](./09_fraud_controls.md), which is not otherwise
|
||||||
|
built.
|
||||||
|
4. `Mode = CreditDeduction`: the returned lines' value (`Quantity * UnitPrice`) is subtracted from
|
||||||
|
`Order.TotalAmount` (floored at 0), and a `CreditNote` audit row is written.
|
||||||
|
5. `Mode = ItemExchange`: the returned quantity is subtracted directly from the matching
|
||||||
|
`OrderItem.Quantity` on the same order (a physical swap of the same item), `TotalAmount`
|
||||||
|
adjusted by the offset amount. No `CreditNote` is written. A returned item with no matching line
|
||||||
|
on the order is credited to stock but does not offset anything on the order.
|
||||||
|
6. Stock credit, the `SaleReturn`/`SaleReturnItem` insert, and the order-total/credit-note update
|
||||||
|
all run inside one `IUnitOfWork` transaction — a failure anywhere rolls back the whole return.
|
||||||
|
7. `settleClaim` can only run once per claim — rejects (409) if already `Settled`. Claims never
|
||||||
|
touch `StockBalance` or any `Order`.
|
||||||
|
8. `createSupplierClaim` requires the referenced `PurchaseOrder` to exist and be active;
|
||||||
|
`FK_GoodsReceiptId`, if given, must belong to that PO (rejects with 409 otherwise).
|
||||||
|
9. Only `Open` supplier claims can be approved or rejected; both are terminal (no re-opening) —
|
||||||
|
enforced by `ApproveAsync`/`RejectAsync` throwing if `Status != Open`.
|
||||||
|
10. `approveSupplierClaim` requires `ResolutionType`; `ResolutionAmount` is required for `Credit`,
|
||||||
|
`Refund`, and `PriceAdjustment` (409 if missing), and is discarded (`null`) for `Replacement`
|
||||||
|
even if supplied.
|
||||||
|
11. Supplier claim approval/rejection is a real sign-off step (unlike the customer-side
|
||||||
|
`SaleReturn`, which settles immediately) — gated behind
|
||||||
|
`[Authorize(Policy = "suppliers.claims.approve")]`, following the Ride cancel-request pattern
|
||||||
|
(`RideController.cs:120-146`).
|
||||||
|
12. No automatic `StockBalance`/`StockMovement` write from any `SupplierClaim` action.
|
||||||
|
`ResolutionType = Replacement` records that new stock is expected; the actual stock credit
|
||||||
|
happens through an ordinary follow-up `GoodsReceipt` once it arrives (see Open Questions).
|
||||||
|
`Credit`/`Refund`/`PriceAdjustment` have no `Invoice`/Finance ledger to post against yet
|
||||||
|
(step 19, not built) — `ResolutionAmount` is captured as an audit field only, the same
|
||||||
|
limitation `CreditNote` has on the customer side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions to Add to Seed
|
||||||
|
|
||||||
|
**Status: `claims.settle` and `suppliers.claims.approve` enforced; return-creation routes
|
||||||
|
intentionally left as bare `[Authorize]`**, matching the Supplier/PriceList/Procurement convention
|
||||||
|
(steps 14/15) rather than introducing enforcement piecemeal. Both enforced policies use the same
|
||||||
|
dynamic policy-provider pattern as `rides.cancel.approve` (`PermissionPolicyProvider`) — no
|
||||||
|
explicit policy registration needed, just a seeded `Permission` row and a `RolePermission` grant.
|
||||||
|
No role assignments seeded yet; `suppliers.claims.approve` likely AreaManager (30) and above,
|
||||||
|
matching the Supplier/Procurement tier.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
- Should return-creation routes eventually get a real permission policy (`returns.create`) once
|
||||||
|
the Supplier/PriceList/Procurement group does, or do they stay bare `[Authorize]` indefinitely
|
||||||
|
since a rep needs to record a return without waiting on a scoped grant?
|
||||||
|
- `ItemExchange` mode clamps the offset to `min(returnedQty, orderItem.Quantity)` — if a rep
|
||||||
|
returns more of an item than is on the current order, the excess is credited to stock but not
|
||||||
|
reflected anywhere on the order. Worth a `CreditNote`-style fallback for the excess once this
|
||||||
|
edge case actually comes up.
|
||||||
|
- No `Invoice` entity exists yet (Finance, step 19) — `CreditNote` and `SupplierClaim`'s
|
||||||
|
`ResolutionAmount` are standalone audit fields for now, not linked to any invoice/ledger
|
||||||
|
document. Revisit once Finance & Accounting is built.
|
||||||
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
|
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
|
||||||
also be raised against an internal warehouse/supplier discrepancy?
|
also be raised against an internal warehouse/supplier discrepancy? Currently only
|
||||||
|
`FK_AgencyUserId` (a User with the RegionalAgency role) is supported.
|
||||||
|
- `SupplierClaim.ResolutionType = Replacement` has no link back to the eventual replacement
|
||||||
|
`GoodsReceipt` — a nullable `FK_SupplierClaimId` on `GoodsReceipt` would close that loop but
|
||||||
|
isn't built yet.
|
||||||
|
- **Inspection & Classification (deferred)**: the fuller process — inspect returned goods,
|
||||||
|
classify each line as Sellable / Damaged / Expired / Quarantine / Disposal, route sellable stock
|
||||||
|
back to available inventory while damaged/expired stock moves to a separate stock category, and
|
||||||
|
only then decide refund vs. replacement vs. credit vs. reject — is explicitly out of scope for
|
||||||
|
now. The current `SaleReturn` flow settles immediately with no inspection step (see
|
||||||
|
Background/Context for why). When it's picked up, it will need: an `InspectionResult` per
|
||||||
|
`SaleReturnItem` recorded after the item is physically back at a warehouse (not at the point of
|
||||||
|
return); a stock-location/category concept for quarantined or condemned stock (today
|
||||||
|
`StockBalance` only distinguishes `LocationType.Warehouse`/`Vehicle`); a real cash-refund concept
|
||||||
|
once Finance/Payments (step 19) exists to reverse a `Payment`; and a decision on whether
|
||||||
|
inspection reopens the immediate-settlement model into a provisional-then-confirmed one — a
|
||||||
|
bigger behavioral change than adding a field, needing its own planning pass first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
### File structure (as built)
|
||||||
|
```
|
||||||
|
Features/
|
||||||
|
Returns/
|
||||||
|
ReturnController.cs — /api/orders/{id}/returns (POST/GET), /api/returns/{id} (GET)
|
||||||
|
ReturnDTOs.cs — CreateSaleReturnRequest, ReturnItemRequest, SaleReturnResponse(+Item)
|
||||||
|
ReturnService.cs — CreateAsync (validate → credit stock → net order → commit),
|
||||||
|
GetByIdAsync, GetByOrderIdAsync
|
||||||
|
IReturnRepository.cs / ReturnRepository.cs — includes StoreHasPriorSaleAsync
|
||||||
|
Claims/
|
||||||
|
ClaimController.cs — /api/claims (POST/GET), /api/claims/{id}/settle (POST)
|
||||||
|
ClaimDTOs.cs — CreateClaimRequest, SettleClaimRequest, ClaimResponse
|
||||||
|
ClaimService.cs — CreateAsync, SettleAsync, GetByIdAsync, GetByAgencyUserIdAsync
|
||||||
|
IClaimRepository.cs / ClaimRepository.cs
|
||||||
|
SupplierClaims/
|
||||||
|
SupplierClaimController.cs — /api/purchase-orders/{id}/claims (POST/GET),
|
||||||
|
/api/supplier-claims/{id} (GET),
|
||||||
|
/api/supplier-claims/{id}/approve|reject (POST)
|
||||||
|
SupplierClaimDTOs.cs — CreateSupplierClaimRequest, ApproveSupplierClaimRequest,
|
||||||
|
RejectSupplierClaimRequest, SupplierClaimResponse(+Item)
|
||||||
|
SupplierClaimService.cs — CreateAsync, ApproveAsync, RejectAsync, GetByIdAsync,
|
||||||
|
GetByPurchaseOrderIdAsync
|
||||||
|
ISupplierClaimRepository.cs / SupplierClaimRepository.cs
|
||||||
|
|
||||||
|
Models/
|
||||||
|
SaleReturn.cs, SaleReturnItem.cs, CreditNote.cs, Claim.cs (new)
|
||||||
|
SupplierClaim.cs, SupplierClaimItem.cs (new)
|
||||||
|
Enums.cs (extended — ReturnMode, ReturnReasonCode, ClaimStatus, SupplierClaimStatus,
|
||||||
|
SupplierClaimIssueType, SupplierClaimResolutionType added)
|
||||||
|
|
||||||
|
Data/AppDBContext.cs
|
||||||
|
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
|
||||||
|
(SupplierClaim.FK_GoodsReceiptId, ResolvedByUserId configured as optional)
|
||||||
|
|
||||||
|
Migrations/
|
||||||
|
20260825073453_AddReturnsAndClaims (applied)
|
||||||
|
20260825084218_AddSupplierClaims (applied)
|
||||||
|
|
||||||
|
Program.cs
|
||||||
|
IReturnRepository/ReturnRepository, IClaimRepository/ClaimRepository,
|
||||||
|
ISupplierClaimRepository/SupplierClaimRepository registered;
|
||||||
|
ReturnService, ClaimService, SupplierClaimService registered
|
||||||
|
```
|
||||||
|
|
||||||
|
### How the models connect
|
||||||
|
```
|
||||||
|
Order (existing) ◄── FK_OrderId ── SaleReturn ── Mode: CreditDeduction | ItemExchange
|
||||||
|
│
|
||||||
|
├─ SaleReturnItem (FK_ItemId, Quantity, ReasonCode, UnitPrice)
|
||||||
|
│ └─► StockBalance/StockMovement (credited: vehicle of
|
||||||
|
│ the order's ride, MovementType.CustomerReturn)
|
||||||
|
│
|
||||||
|
└─ CreditNote (only when Mode = CreditDeduction;
|
||||||
|
Amount = sum(Quantity * UnitPrice))
|
||||||
|
|
||||||
|
Claim ── FK_AgencyUserId ──► User (holding the RegionalAgency role, seeded in
|
||||||
|
Data/Seed/RoleAndPermissionSeed.cs) — no separate Agency entity
|
||||||
|
|
||||||
|
PurchaseOrder (existing, step 15) ◄── FK_PurchaseOrderId ── SupplierClaim
|
||||||
|
│
|
||||||
|
GoodsReceipt (existing, step 15) ◄── FK_GoodsReceiptId (optional) ┘
|
||||||
|
│
|
||||||
|
├─ SupplierClaimItem (FK_ItemId,
|
||||||
|
│ Quantity, IssueType)
|
||||||
|
└─ ResolutionType: Replacement |
|
||||||
|
Credit | Refund | PriceAdjustment
|
||||||
|
(no stock/ledger write — see
|
||||||
|
Business Rules)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Return flow (as implemented)
|
||||||
|
```
|
||||||
|
ReturnService.CreateAsync(orderId, request, createdByUserId):
|
||||||
|
order = load Order, must be Pending/PartiallyFulfilled else throw
|
||||||
|
sale = load order's Sale, must have FK_RideId set else throw
|
||||||
|
ride = load Sale's Ride → vehicleId = ride.FK_VehicleId
|
||||||
|
orderItems = load order's OrderItems (for price snapshot + ItemExchange offset)
|
||||||
|
for each (itemId, qty, reasonCode) in request.Items:
|
||||||
|
guard StoreHasPriorSaleAsync(order.FK_StoreId, itemId) else throw 409
|
||||||
|
unitPrice = matching OrderItem.UnitPrice if present, else Item.SalePrice
|
||||||
|
begin transaction:
|
||||||
|
for each line: credit StockBalance(itemId, vehicleId, Vehicle) += qty
|
||||||
|
add StockMovement(Type = CustomerReturn, ToLocation = vehicle, ReferenceId = return.Id)
|
||||||
|
insert SaleReturn + SaleReturnItems
|
||||||
|
if Mode == CreditDeduction:
|
||||||
|
total = sum(qty * unitPrice); SaleReturn.TotalCreditAmount = total
|
||||||
|
Order.TotalAmount = max(0, Order.TotalAmount - total)
|
||||||
|
insert CreditNote(Amount = total)
|
||||||
|
else (ItemExchange):
|
||||||
|
for each line with a matching OrderItem:
|
||||||
|
offset = min(qty, orderItem.Quantity); orderItem.Quantity -= offset
|
||||||
|
Order.TotalAmount = max(0, Order.TotalAmount - offset * orderItem.UnitPrice)
|
||||||
|
commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claim flow (as implemented)
|
||||||
|
```
|
||||||
|
ClaimService.CreateAsync(request): insert Claim(Status = Open)
|
||||||
|
ClaimService.SettleAsync(claimId, settlementAmount, approverId):
|
||||||
|
guard claim exists and Status != Settled else throw
|
||||||
|
Claim.Status = Settled; SettlementAmount = settlementAmount
|
||||||
|
Claim.SettledByUserId = approverId; SettledAt = now
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supplier claim flow (as implemented)
|
||||||
|
```
|
||||||
|
SupplierClaimService.CreateAsync(purchaseOrderId, request, raisedByUserId):
|
||||||
|
po = load PurchaseOrder, must exist and be active else throw
|
||||||
|
guard Details non-empty, Items non-empty
|
||||||
|
if GoodsReceiptId given: guard it exists and belongs to this PO else throw
|
||||||
|
insert SupplierClaim(Status = Open) + SupplierClaimItems
|
||||||
|
|
||||||
|
SupplierClaimService.ApproveAsync(id, request, approverId):
|
||||||
|
guard claim exists and Status == Open else throw
|
||||||
|
guard ResolutionAmount present unless ResolutionType == Replacement else throw
|
||||||
|
Status = Approved; ResolutionType/Amount/Notes set; ResolvedByUserId = approverId; ResolvedAt = now
|
||||||
|
|
||||||
|
SupplierClaimService.RejectAsync(id, request, approverId):
|
||||||
|
guard claim exists and Status == Open else throw
|
||||||
|
Status = Rejected; ResolutionNotes set; ResolvedByUserId = approverId; ResolvedAt = now
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Authentication — Own-User Auth via External Identity Service (AuthHex)
|
||||||
|
|
||||||
|
**Status**: Done
|
||||||
|
**Depends On**: — (cross-cutting infrastructure, not a numbered feature step)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Own-user (staff/API) authentication is delegated to an external identity service ("AuthHex"),
|
||||||
|
which owns credentials and signs RS256 JWTs. This app validates those tokens and maps them onto
|
||||||
|
its own `User`/`Role` tables so the existing permission-policy authorization stays unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background / Context
|
||||||
|
|
||||||
|
Previously this app verified passwords and minted its own HMAC-signed JWT locally. That's been
|
||||||
|
replaced — AuthHex now owns credentials and signing; this app only holds AuthHex's **public** key
|
||||||
|
to validate tokens, and never sees a plaintext password for this flow.
|
||||||
|
|
||||||
|
Authorization is untouched: permission policies, role levels, scope checks, and the seeded role
|
||||||
|
set all still work exactly as before, because every validated token is mapped back onto a local
|
||||||
|
`User`/`Role` row before any authorization check runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Changes
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"Jwt": {
|
||||||
|
"PublicKey": "<RSAKeyValue>...</RSAKeyValue>",
|
||||||
|
"Audience": "AuthHexClient",
|
||||||
|
"Issuer": "AuthHex"
|
||||||
|
},
|
||||||
|
"AuthHex": { "BaseUrl": "http://localhost:6102" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`Jwt:Key` (old symmetric secret) is removed. `Jwt:PublicKey` is AuthHex's RSA public key in XML
|
||||||
|
format (not PEM).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Route | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | /api/auth/login | Proxies to AuthHex `loginUser` |
|
||||||
|
| POST | /api/auth/register | Proxies to AuthHex `registerUser` |
|
||||||
|
| GET | /api/auth/me | `[Authorize]`, proxies to AuthHex `getUserDetails` |
|
||||||
|
| POST | /api/auth/logout | `[Authorize]`, proxies to AuthHex `LogoutUser` |
|
||||||
|
|
||||||
|
All four just forward to `POST {AuthHex:BaseUrl}/api/user` and pass AuthHex's response back
|
||||||
|
as-is. No cookie — token returned in the response body, sent back as `Authorization: Bearer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Rules
|
||||||
|
|
||||||
|
1. No local password check or token issuance — both delegated to AuthHex.
|
||||||
|
2. `AddJwtBearer` validates issuer/audience/signature/lifetime against AuthHex's public key.
|
||||||
|
3. `OnTokenValidated` maps the token's `sub` claim to a local `User` (`IUserRepository`); fails
|
||||||
|
the token if no matching active user exists.
|
||||||
|
4. On success, `username`/`role_id`/`role_level` claims are attached from the **local** `User`/
|
||||||
|
`Role` row — authorization checks downstream are unaffected by anything AuthHex's own token
|
||||||
|
claims contain.
|
||||||
|
5. Only one validation mechanism runs (`AddJwtBearer`) — two ported alternatives exist but are not
|
||||||
|
registered (see Implementation Notes).
|
||||||
|
6. No refresh tokens — once the AuthHex-issued token expires, the client must log in again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions to Add to Seed
|
||||||
|
|
||||||
|
None — this only changes authentication. Permission policies and role seed data are untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Add a refresh-token flow if AuthHex doesn't offer one and re-login UX proves disruptive.
|
||||||
|
- Keep or delete the ported `AuthMiddleware`/`JwtMiddleware` if never actually needed.
|
||||||
|
- No retry/circuit-breaker if AuthHex is unreachable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
```
|
||||||
|
Features/Auth/ — AuthController, AuthService, AuthDTOs (thin AuthHex proxies)
|
||||||
|
Infra/Authorization/
|
||||||
|
AuthHex/ — IAuthHexClient, AuthHexClient, AuthHexResponse
|
||||||
|
Jwt/RsaXmlKeyReader.cs — portable XML→RSA key parser (RSA.FromXmlString is Windows-only)
|
||||||
|
Middleware/ — AuthMiddleware, JwtMiddleware, JwtOptions, ApiResponse
|
||||||
|
(ported from OSMCore for reference — NOT registered)
|
||||||
|
Program.cs — AddHttpClient<IAuthHexClient>, AddJwtBearer + OnTokenValidated
|
||||||
|
(maps token → local User/Role → role_id/role_level claims)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** don't run more than one of `AddJwtBearer` / `AuthMiddleware` /
|
||||||
|
`JwtMiddleware.CustomMiddlewareAttribute` at once — each re-validates the same token
|
||||||
|
independently.
|
||||||
@@ -11,26 +11,32 @@
|
|||||||
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
|
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
|
||||||
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
|
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
|
||||||
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
|
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
|
||||||
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
|
| Order CRUD | Done | Scope-aware (user only sees their assigned stores); an `Order` is now created only as a side effect of `Sale` creation, not standalone — see §1 Order Management notes below |
|
||||||
|
| Sale & Payment | Done | `Features/Sale` creates a `Sale`+linked `Order` together (pricing, KYC threshold, immediate-handover stock check); `Features/Payment` records collections against a `Sale` and drives its `PaymentStatus` |
|
||||||
| Unit of Work + Repositories | Done | Transaction support throughout |
|
| Unit of Work + Repositories | Done | Transaction support throughout |
|
||||||
| Soft Deletes | Done | IsActive pattern on all entities |
|
| Soft Deletes | Done | IsActive pattern on all entities |
|
||||||
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||||
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||||
| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) |
|
| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) |
|
||||||
|
| Returns & Claims Management | Done | Sale returns, claims, supplier claims; see [18_returns_claims.md](./18_returns_claims.md) |
|
||||||
|
|
||||||
## Known Gaps in Existing System (Pre-Work)
|
## Known Gaps in Existing System (Pre-Work) — Resolved
|
||||||
|
|
||||||
These are not new features — they are fixable holes in what's already built.
|
Step 00 closed these. Kept here for history; all are fixed in the current codebase.
|
||||||
They must be resolved before new feature steps begin because later steps depend on correct foundations.
|
|
||||||
|
| Gap | Impact | Resolution |
|
||||||
|
|---|---|---|
|
||||||
|
| Route DTOs missing RegionId | Can't link routes to regions via API | Fixed — `CreateRouteRequest`/`UpdateRouteRequest`/`RouteResponse` all carry `RegionId` (`Features/Route/RouteDTOs.cs`). |
|
||||||
|
| Store DTOs missing RouteId | Can't link stores to routes via API | Fixed — all Store DTOs carry `RouteId` (`Features/Store/StoreDTOs.cs`). |
|
||||||
|
| Role endpoints have no [Authorize] | Anyone can read roles without authentication | Fixed — `[Authorize]` on `RoleController` (`Features/Role/RoleController.cs`). |
|
||||||
|
| Item model is empty (no properties) | OrderItems reference items that have no data | Fixed — `Item` has `Sku`/`Name`/`Description`/category/brand/UOM/pricing fields (`Models/Item.cs`). |
|
||||||
|
|
||||||
|
### Still open (not a step-00 blocker, tracked separately)
|
||||||
|
|
||||||
| Gap | Impact |
|
| Gap | Impact |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Route DTOs missing RegionId | Can't link routes to regions via API |
|
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment — `appsettings.json` still has a literal DB password and placeholder JWT key. |
|
||||||
| Store DTOs missing RouteId | Can't link stores to routes via API |
|
| No input validation on any DTO | Bad data can enter the system silently. |
|
||||||
| Role endpoints have no [Authorize] | Anyone can read roles without authentication |
|
|
||||||
| JWT key & DB credentials in appsettings.json | Security risk in any shared/production environment |
|
|
||||||
| No input validation on any DTO | Bad data can enter the system silently |
|
|
||||||
| Item model is empty (no properties) | OrderItems reference items that have no data |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,8 +49,8 @@ They must be resolved before new feature steps begin because later steps depend
|
|||||||
| 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
|
| 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
|
||||||
| 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
|
| 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
|
||||||
| 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) |
|
| 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) |
|
||||||
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
|
| 05 | Delivery Ride Execution | Done | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
|
||||||
| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) |
|
| 06 | Sale Ride Execution | Done | 04 | [06_sale_ride.md](./06_sale_ride.md) |
|
||||||
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
|
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
|
||||||
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
|
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
|
||||||
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
||||||
@@ -56,7 +62,7 @@ They must be resolved before new feature steps begin because later steps depend
|
|||||||
| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
||||||
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
||||||
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||||
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
| 18 | Returns & Claims Management | Done | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
||||||
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
|
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
|
||||||
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
|
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
|
||||||
| 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
|
| 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
|
||||||
@@ -81,18 +87,27 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
### 1. Order Management
|
### 1. Order Management
|
||||||
|
|
||||||
|
> **Design change since this section was first written**: `Order` is no longer created directly
|
||||||
|
> from `storeId/items/salesRepId`. Order creation was folded into `Features/Sale` — every `Order`
|
||||||
|
> is now a 1:1 side effect of `SaleService.CreateAsync` (see §6 Sale Ride Execution). `POST
|
||||||
|
> /api/orders` (`OrderService.CreateAsync`) is now just a lookup — it throws
|
||||||
|
> `"Orders are created automatically from sales"` if called for a sale that doesn't already have
|
||||||
|
> one, rather than creating a standalone order. All the validation described below (stock check,
|
||||||
|
> KYC threshold, pricing) now happens in `SaleService.CreateAsync`
|
||||||
|
> (`Features/Sale/SaleService.cs`), not `OrderService`.
|
||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, applies the unverified-KYC order threshold from config, and now checks stock availability (see `checkStockAvailability` below). It still has no credit-limit validation, approval flow, or order state machine. |
|
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Superseded — see design-change note above. The equivalent entry point is `SaleService.CreateAsync` (`createSale`, §6), which creates the `Sale` and its linked `Order` together. `OrderService.CreateAsync` itself only fetches the existing order for a given `SaleId`. |
|
||||||
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. |
|
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or sale/order-time credit-limit enforcement yet. |
|
||||||
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Implemented — `OrderService.CreateAsync` rejects an order if any line's requested quantity exceeds total `StockBalance` for that item summed across every location (added alongside [15_procurement.md](./15_procurement.md)). Not location-specific yet (no fulfilling-warehouse concept on `Store`/`Order`), and does not reserve or decrement stock — that stays deferred to loading/delivery (§05-06). |
|
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Partially implemented — `SaleService.CreateAsync` checks `StockBalance` at the ride's vehicle, but **only** when `FulfillImmediately = true` and a `RideId` is supplied (immediate van-sale handover); a deferred/pending sale does no stock check at create time at all. Not warehouse-wide, not reserved — a failed check simply returns `null` (400) rather than a typed error. |
|
||||||
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`). The scheme-discount half now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. |
|
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `SaleService.CreateAsync` resolves the price-list half via `ResolveUnitPriceAsync` → `PriceListService.ResolvePriceAsync` (region-scoped via the store's route → `Item.SalePrice` fallback, or a caller-supplied `UnitPrice` validated against `[MinPrice, Price]`). The scheme-discount half is a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — not auto-invoked from `createSale`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after the order exists. |
|
||||||
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
||||||
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
||||||
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
|
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Implemented, with a smaller state machine than described — `Models/Enums.cs` defines `OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }`; `OrderService.UpdateAsync` (`PUT /api/orders/{id}`) sets `Order.Status` directly (any status accepted, no transition guard) and mirrors it onto the linked `Sale.Status`/`CompletedAt`. No `approved/picked/dispatched/invoiced` states exist. |
|
||||||
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. |
|
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. |
|
||||||
| `createBackorder(orderId, unfulfilledItems[])` | Splits unfulfillable quantity into a linked backorder. | Not implemented. |
|
| `createBackorder(orderId, unfulfilledItems[])` | Splits unfulfillable quantity into a linked backorder. | Not implemented. |
|
||||||
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Not implemented — `OrderController` supports soft delete only, no reason code or stock release. |
|
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Partially implemented — `OrderService.DeleteAsync` (`DELETE /api/orders/{id}`) soft-deletes the order and cascades `Sale.Status = Cancelled`, but takes no reason code and releases no reserved stock (nothing is reserved at create time to release). |
|
||||||
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. |
|
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. |
|
||||||
| `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
|
| `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
|
||||||
|
|
||||||
@@ -129,8 +144,11 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
|
||||||
|
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{id}/kyc-documents`. |
|
||||||
|
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{id}/kyc-documents`. |
|
||||||
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
|
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
|
||||||
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
|
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
|
||||||
|
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
|
||||||
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
|
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
|
||||||
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
||||||
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
||||||
@@ -177,9 +195,18 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
|
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
|
||||||
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
|
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
|
||||||
|
| `listPriceLists(regionId?)` | Lists price lists, optionally filtered by region. | Implemented — `GET /api/price-lists`. |
|
||||||
|
| `getPriceList(priceListId)` | Fetches a price list and its items. | Implemented — `GET /api/price-lists/{id}`. |
|
||||||
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). |
|
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). |
|
||||||
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
|
| `updatePriceList(priceListId, name, region, effectiveDates)` | Updates price-list metadata. | Implemented — `PUT /api/price-lists/{id}`. |
|
||||||
|
| `deletePriceList(priceListId)` | Soft-deletes a price list. | Implemented — `DELETE /api/price-lists/{id}`. |
|
||||||
|
| `updateProductPricing(sku, priceListId, price, minPrice?)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
|
||||||
|
| `removeProductPricing(sku, priceListId)` | Removes a SKU override from a price list. | Implemented — `DELETE /api/price-lists/{id}/items/{sku}`. |
|
||||||
|
| `listSuppliers()` | Lists suppliers. | Implemented — `GET /api/suppliers`. |
|
||||||
|
| `getSupplier(supplierId)` | Fetches a supplier by ID. | Implemented — `GET /api/suppliers/{id}`. |
|
||||||
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. |
|
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. |
|
||||||
|
| `updateSupplier(supplierId, supplierData)` | Updates supplier master data. | Implemented — `PUT /api/suppliers/{id}`. `PerformanceScore` remains read-only here. |
|
||||||
|
| `deleteSupplier(supplierId)` | Soft-deletes a supplier. | Implemented — `DELETE /api/suppliers/{id}`. Blocked if an active PurchaseOrder references the supplier. |
|
||||||
|
|
||||||
### 8. Enterprise-wide Inventory & Stock
|
### 8. Enterprise-wide Inventory & Stock
|
||||||
|
|
||||||
@@ -197,9 +224,21 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
|
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
|
||||||
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. |
|
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. |
|
||||||
|
| `getPurchaseOrder(poId)` | Fetches a purchase order. | Implemented — `GET /api/purchase-orders/{id}`. |
|
||||||
|
| `listPurchaseOrders(filters)` | Lists purchase orders. | Implemented — `GET /api/purchase-orders`. |
|
||||||
|
| `updatePurchaseOrder(poId, items[])` | Updates an open purchase order. | Implemented — `PUT /api/purchase-orders/{id}`. |
|
||||||
|
| `closePurchaseOrder(poId)` | Closes a received purchase order. | Implemented — `POST /api/purchase-orders/{id}/close`. |
|
||||||
|
| `cancelPurchaseOrder(poId, reason)` | Cancels a purchase order before receipts exist. | Implemented — `POST /api/purchase-orders/{id}/cancel`. |
|
||||||
|
| `cancelPurchaseOrderLine(poId, poItemId, reason)` | Cancels a single PO line. | Implemented — `POST /api/purchase-orders/{id}/items/{itemId}/cancel`. |
|
||||||
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. |
|
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. |
|
||||||
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
|
| `resolveInvoiceVariance(poId, variance, resolution)` | Records how a mismatch should be handled. | Implemented — `POST /api/purchase-orders/{id}/resolve-variance`. |
|
||||||
|
| `getGoodsReceipt(grnId)` | Fetches a goods receipt. | Implemented — `GET /api/goods-receipts/{id}`. |
|
||||||
|
| `listGoodsReceipts(poId)` | Lists goods receipts for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/goods-receipts`. |
|
||||||
|
| `createGoodsReceipt(poId, warehouseId, items[])` | Records inbound stock against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/goods-receipts`. |
|
||||||
|
| `getPurchaseReturn(returnId)` | Fetches a purchase return. | Implemented — `GET /api/purchase-returns/{id}`. |
|
||||||
|
| `listPurchaseReturns(poId)` | Lists purchase returns for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/returns`. |
|
||||||
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. |
|
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. |
|
||||||
|
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
|
||||||
|
|
||||||
### 10. Sales Force Automation (SFA) / CRM
|
### 10. Sales Force Automation (SFA) / CRM
|
||||||
|
|
||||||
@@ -225,19 +264,25 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createReturnRequest(orderId, items[], reasonCode)` | Initiates a return against a delivered order. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) for the fraud-control angle on returns. |
|
| `createSaleReturn(orderId, mode, items[])` | Records a customer return against a delivered order. | Implemented — `POST /api/orders/{id}/returns`, settles immediately and credits the ride's vehicle. No approval step. |
|
||||||
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. |
|
| `getSaleReturn(returnId)` | Fetches a sale return by ID. | Implemented — `GET /api/returns/{id}`. |
|
||||||
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. |
|
| `listSaleReturns(orderId)` | Lists sale returns for an order. | Implemented — `GET /api/orders/{id}/returns`. |
|
||||||
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. |
|
| `createClaim(agencyUserId, details)` | Raises a claim against a Regional Agency user. | Implemented — `POST /api/claims`. |
|
||||||
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. |
|
| `getClaim(claimId)` | Fetches a claim by ID. | Implemented — `GET /api/claims/{id}`. |
|
||||||
|
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Implemented — `POST /api/claims/{id}/settle`, requires `claims.settle`. |
|
||||||
|
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | Raises a supplier claim against a purchase order. | Implemented — `POST /api/purchase-orders/{id}/claims`. |
|
||||||
|
| `getSupplierClaim(claimId)` | Fetches a supplier claim by ID. | Implemented — `GET /api/supplier-claims/{id}`. |
|
||||||
|
| `listSupplierClaims(poId)` | Lists supplier claims for a purchase order. | Implemented — `GET /api/purchase-orders/{id}/claims`. |
|
||||||
|
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | Approves a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/approve`, requires `suppliers.claims.approve`. |
|
||||||
|
| `rejectSupplierClaim(claimId, notes?)` | Rejects a supplier claim. | Implemented — `POST /api/supplier-claims/{id}/reject`, requires `suppliers.claims.approve`. |
|
||||||
|
|
||||||
### 13. Finance & Accounting
|
### 13. Finance & Accounting
|
||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. |
|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
|
||||||
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. |
|
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
|
||||||
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. |
|
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
|
||||||
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
|
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
|
||||||
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
|
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
|
||||||
|
|
||||||
@@ -303,13 +348,17 @@ Introduce the concept of stock per location. Every warehouse (fixed or vehicle)
|
|||||||
of each item. Stock is never edited directly — all changes happen through movement transactions
|
of each item. Stock is never edited directly — all changes happen through movement transactions
|
||||||
(load, sale, return, damage, adjustment). The current balance is always the running sum.
|
(load, sale, return, damage, adjustment). The current balance is always the running sum.
|
||||||
This is the single most important architectural decision for the whole system.
|
This is the single most important architectural decision for the whole system.
|
||||||
|
For now, the shop is treated as a business destination, not a tracked stock location in this
|
||||||
|
system. That means warehouse-to-vehicle and vehicle-to-warehouse movements update tracked stock
|
||||||
|
balances, while vehicle-to-shop handover reduces vehicle stock and updates sale/order fulfillment
|
||||||
|
state without creating a shop stock balance.
|
||||||
**Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction.
|
**Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction.
|
||||||
|
|
||||||
### 03 — Ride Lifecycle
|
### 03 — Ride Lifecycle
|
||||||
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine:
|
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine:
|
||||||
Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time.
|
Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time.
|
||||||
Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving,
|
Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving,
|
||||||
which route, which vehicle, and when it started and ended.
|
which route, which vehicle, and when it started and ended. For now, one ride uses one route.
|
||||||
**Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens.
|
**Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens.
|
||||||
|
|
||||||
### 04 — Loading Event
|
### 04 — Loading Event
|
||||||
@@ -324,14 +373,18 @@ On a delivery ride, the driver fulfills pre-confirmed orders. Each order becomes
|
|||||||
The driver marks each as delivered, partially delivered, or failed. Partially delivered orders
|
The driver marks each as delivered, partially delivered, or failed. Partially delivered orders
|
||||||
generate a credit note for the undelivered quantity. Customer confirmation (signature or OTP)
|
generate a credit note for the undelivered quantity. Customer confirmation (signature or OTP)
|
||||||
is required to mark a delivery complete. Stock movements are created per delivery event.
|
is required to mark a delivery complete. Stock movements are created per delivery event.
|
||||||
|
Vehicle-to-shop handover reduces vehicle stock only; the shop does not yet exist as a tracked stock
|
||||||
|
location in the ledger.
|
||||||
**Gate**: A delivery ride can execute all orders on its route and produce a delivery report.
|
**Gate**: A delivery ride can execute all orders on its route and produce a delivery report.
|
||||||
|
|
||||||
### 06 — Sale Ride Execution
|
### 06 — Sale Ride Execution
|
||||||
On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist.
|
On a sale ride, the sales agent visits customers and creates sales in the field. Every sale creates
|
||||||
The driver selects items, quantities, and prices (within approved discount limits), and the invoice
|
an order. If items are handed over immediately, the order is fulfilled immediately and the sale is
|
||||||
is generated immediately. Payment is collected (cash or credit) at the point of sale.
|
completed immediately. If items are not handed over immediately, the order stays pending for later
|
||||||
The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded.
|
fulfillment. Payment is collected based on the final sale flow. The vehicle's stock ledger decreases
|
||||||
**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale.
|
only for quantities actually handed over from the vehicle. The shop is not yet a tracked stock
|
||||||
|
location in the ledger.
|
||||||
|
**Gate**: A sale ride can create sales in the field, create linked orders, and reduce vehicle stock for immediate handover quantities.
|
||||||
|
|
||||||
### 07 — Ride Close & Reconciliation
|
### 07 — Ride Close & Reconciliation
|
||||||
When a ride ends, the system performs a full reconciliation. The equation must balance:
|
When a ride ends, the system performs a full reconciliation. The equation must balance:
|
||||||
@@ -351,8 +404,9 @@ and approval chains for exceptions. Supervisors can grant one-time overrides log
|
|||||||
A dedicated audit log records every sensitive event (who, what, when, from where).
|
A dedicated audit log records every sensitive event (who, what, when, from where).
|
||||||
Automated checks flag: returns without a matching prior sale, sales outside the geofenced route,
|
Automated checks flag: returns without a matching prior sale, sales outside the geofenced route,
|
||||||
agents with consistently high variance rates, and cash collected below invoiced amounts.
|
agents with consistently high variance rates, and cash collected below invoiced amounts.
|
||||||
Return approval requires supervisor sign-off before stock is credited. Anomaly reports are
|
Return-related processing is handled in §12 as an immediate-settlement flow; this step focuses on
|
||||||
surfaced to area managers on a scheduled basis.
|
audit logging and anomaly detection around those events. Anomaly reports are surfaced to area
|
||||||
|
managers on a scheduled basis.
|
||||||
**Gate**: Every sensitive event is logged immutably. Automated flags surface to the right level of management.
|
**Gate**: Every sensitive event is logged immutably. Automated flags surface to the right level of management.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user