Production #10

Merged
DeepnaPooja merged 8 commits from production into dev 2026-08-25 11:12:17 +05:30
38 changed files with 3938 additions and 563 deletions
Showing only changes of commit fcca03d0b2 - Show all commits
+44
View File
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
@@ -41,6 +42,8 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
public DbSet<LoadingSheet> LoadingSheets { get; set; }
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
public DbSet<Payment> Payments { get; set; }
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
@@ -79,6 +82,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
@@ -102,6 +109,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
@@ -238,6 +270,18 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
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);
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Authorize]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
{
var result = await service.GenerateAsync(rideId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("rides/{rideId:guid}/tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
{
var result = await service.SetQuantitiesAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
{
var result = await service.FailAsync(id, request.Note, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
DeliveryTaskStatus Status,
bool CustomerConfirmed,
DateTime? ConfirmedAt,
string? Note,
List<DeliveryTaskItemResponse> Items,
DateTime CreatedAt);
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
public record FailTaskRequest(string Note);
@@ -0,0 +1,38 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
public void Update(DeliveryTask task) =>
context.DeliveryTasks.Update(task);
}
@@ -0,0 +1,224 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
i.QuantityFulfilled,
i.UnitPrice)).ToList(),
t.CreatedAt);
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
var tasks = await repo.GetByRideIdAsync(rideId);
return tasks.Select(ToResponse).ToList();
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
return task is null ? null : ToResponse(task);
}
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Quantities can only be set for a Pending task");
foreach (var input in request.Items)
{
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
vehicleBalance.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be failed");
task.Status = DeliveryTaskStatus.Failed;
task.Note = note;
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(task);
}
}
@@ -0,0 +1,12 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
}
@@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId)
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
@@ -4,10 +4,12 @@ public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
+12 -4
View File
@@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")]
+8 -5
View File
@@ -1,18 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateOrderRequest(Guid SaleId);
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
+38 -87
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Sale;
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
@@ -15,16 +15,16 @@ public class OrderService(
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -35,42 +35,6 @@ public class OrderService(
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
/// <summary>
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
/// can be resolved, or if the requested price is outside the allowed band.
/// </summary>
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
/// <summary>
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
/// </summary>
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
{
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
var totalAvailable = balances.Sum(b => b.Quantity);
return quantity <= totalAvailable;
}
public async Task<List<OrderResponse>> GetAllAsync()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -112,52 +76,15 @@ public class OrderService(
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var order = new Models.Order
{
FK_StoreId = request.StoreId,
OrderDate = request.OrderDate,
TotalAmount = request.TotalAmount
};
var orderItems = new List<Models.OrderItem>();
foreach (var i in request.Items)
{
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity,
UnitPrice = unitPrice.Value
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(order);
await repo.AddItemsAsync(orderItems);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, orderItems);
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
@@ -165,12 +92,26 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
order.OrderDate = request.OrderDate;
order.TotalAmount = request.TotalAmount;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
@@ -178,6 +119,7 @@ public class OrderService(
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
@@ -187,10 +129,19 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
@@ -0,0 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Authorize]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
Guid CollectedBy,
DateTime CreatedAt);
@@ -0,0 +1,17 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Payment;
public class PaymentRepository(AppDBContext context) : IPaymentRepository
{
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
}
@@ -0,0 +1,67 @@
using Distribution.Features.Sale;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var payment = new Models.Payment
{
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
CollectedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(payment);
}
}
@@ -0,0 +1,14 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync();
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
[Authorize]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
}
+33
View File
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync() =>
await context.Sales.ToListAsync();
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+233
View File
@@ -0,0 +1,233 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<List<SaleResponse>> GetAllAsync()
{
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
}
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
TotalAmount = request.TotalAmount,
Status = saleStatus,
FulfillImmediately = request.FulfillImmediately,
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
UnitPrice = unitPrice.Value
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
TotalAmount = request.TotalAmount,
Status = orderStatus,
FulfilledAt = fulfilledAt
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
if (ride is null) return null;
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
if (balance is null || balance.Quantity < item.Quantity)
return null;
}
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
Quantity = item.Quantity,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
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");
}
}
}
@@ -131,6 +131,48 @@ namespace Distribution.Migrations
b.ToTable("DeliveryRuns");
});
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("CustomerConfirmed")
.HasColumnType("tinyint(1)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Note")
.HasColumnType("longtext");
b.Property<int>("Sequence")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_OrderId");
b.HasIndex("FK_RideId");
b.ToTable("DeliveryTasks");
});
modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b =>
{
b.Property<Guid>("Id")
@@ -397,20 +439,32 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<DateTime?>("FulfilledAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("OrderDate")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_SaleId")
.IsUnique();
b.HasIndex("FK_StoreId");
b.ToTable("Orders");
@@ -436,6 +490,9 @@ namespace Distribution.Migrations
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("QuantityFulfilled")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
@@ -446,6 +503,45 @@ namespace Distribution.Migrations
b.ToTable("OrderItems");
});
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("Amount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CollectedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CollectedBy")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Method")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_SaleId");
b.HasIndex("FK_StoreId");
b.ToTable("Payments");
});
modelBuilder.Entity("Distribution.Models.Permission", b =>
{
b.Property<Guid>("Id")
@@ -860,6 +956,56 @@ namespace Distribution.Migrations
b.ToTable("Routes");
});
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_RideId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<bool>("FulfillImmediately")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.Property<DateTime>("SaleDate")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_RideId");
b.HasIndex("FK_StoreId");
b.ToTable("Sales");
});
modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
{
b.Property<Guid>("Id")
@@ -1499,6 +1645,25 @@ namespace Distribution.Migrations
b.Navigation("Vehicle");
});
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.DriverProfile", b =>
{
b.HasOne("Distribution.Models.User", "User")
@@ -1619,12 +1784,20 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
@@ -1637,7 +1810,7 @@ namespace Distribution.Migrations
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.WithMany("OrderItems")
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -1647,6 +1820,25 @@ namespace Distribution.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
@@ -1776,6 +1968,32 @@ namespace Distribution.Migrations
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Ride");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.HasOne("Distribution.Models.Item", "FreeItem")
@@ -2032,6 +2250,11 @@ namespace Distribution.Migrations
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.Navigation("OrderItems");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.Navigation("Items");
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class DeliveryTask : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public int Sequence { get; set; }
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
public bool CustomerConfirmed { get; set; } = false;
public DateTime? ConfirmedAt { get; set; }
public string? Note { get; set; }
}
}
+16
View File
@@ -42,4 +42,20 @@ namespace Distribution.Models
public enum CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum SaleStatus { Pending, Completed, Cancelled }
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
public enum PaymentMethod { Cash, Credit }
public enum DeliveryTaskStatus
{
Pending,
Delivered,
PartiallyDelivered,
Failed
}
}
+8
View File
@@ -4,10 +4,18 @@ namespace Distribution.Models
{
public class Order : Template
{
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public DateTime OrderDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime? FulfilledAt { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
}
}
+1
View File
@@ -11,6 +11,7 @@ namespace Distribution.Models
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; }
}
}
+20
View File
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Payment : Template
{
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal Amount { get; set; }
public PaymentMethod Method { get; set; }
public DateTime CollectedAt { get; set; }
public Guid CollectedBy { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Sale : Template
{
public DateTime SaleDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public Guid? FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun? Ride { get; set; }
public decimal TotalAmount { get; set; }
public SaleStatus Status { get; set; } = SaleStatus.Pending;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public bool FulfillImmediately { get; set; }
public DateTime? CompletedAt { get; set; }
}
}
+55 -244
View File
@@ -1,267 +1,78 @@
# Phase Plan — Route & Store Management
# Phase Plan - Route and Store Management
## Scope
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, OrderItem**.
Item model is sourced from ERP Core — excluded from this phase.
SaleRoute shape is TBD — excluded until clarified.
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order,
OrderItem**.
Item model is sourced from ERP Core - excluded from this phase.
Historical note: `SaleRoute` was marked TBD here. For now, rides stay single-route and route
assignment remains simple until further clarification.
---
## Project Structure (target)
```
```text
Distribution/
├── Models/ # All entity POCOs — unchanged
│ ├── Template.cs
│ ├── Region.cs
│ ├── Route.cs
│ ├── Vehicle.cs
│ ├── Store.cs
│ ├── Warehouse.cs
│ ├── SaleRoute.cs # TBD
│ ├── Order.cs
│ ├── OrderItem.cs
│ ├── DeliveryRun.cs
│ └── Item.cs # Sourced from ERP Core
├── Data/
│ └── AppDBContext.cs # DbSets + Fluent API config
├── Infra/
│ ├── UoW/ # Already implemented
│ │ ├── IUnitOfWork.cs
│ │ └── EFUnitOfWork.cs
│ └── Repositories/ # Shared generic base only
│ ├── IGenericRepo.cs
│ └── GenericRepo.cs
└── Features/
├── Region/
│ ├── RegionDTOs.cs
│ ├── IRegionRepository.cs
│ ├── RegionRepository.cs
│ ├── RegionService.cs
│ └── RegionController.cs
├── Route/
│ ├── RouteDTOs.cs
│ ├── IRouteRepository.cs
│ ├── RouteRepository.cs
│ ├── RouteService.cs
│ └── RouteController.cs
├── Vehicle/
│ ├── VehicleDTOs.cs
│ ├── IVehicleRepository.cs
│ ├── VehicleRepository.cs
│ ├── VehicleService.cs
│ └── VehicleController.cs
├── Store/
│ ├── StoreDTOs.cs
│ ├── IStoreRepository.cs
│ ├── StoreRepository.cs
│ ├── StoreService.cs
│ └── StoreController.cs
├── Warehouse/
│ ├── WarehouseDTOs.cs
│ ├── IWarehouseRepository.cs
│ ├── WarehouseRepository.cs
│ ├── WarehouseService.cs
│ └── WarehouseController.cs
└── Order/
├── OrderDTOs.cs
├── IOrderRepository.cs
├── OrderRepository.cs
├── OrderService.cs
└── OrderController.cs
|-- Models/
| |-- Template.cs
| |-- Region.cs
| |-- Route.cs
| |-- Vehicle.cs
| |-- Store.cs
| |-- Warehouse.cs
| |-- SaleRoute.cs
| |-- Order.cs
| |-- OrderItem.cs
| |-- DeliveryRun.cs
| `-- Item.cs
|-- Data/
| `-- AppDBContext.cs
|-- Infra/
| |-- UoW/
| | |-- IUnitOfWork.cs
| | `-- EFUnitOfWork.cs
| `-- Repositories/
| |-- IGenericRepo.cs
| `-- GenericRepo.cs
`-- Features/
|-- Region/
|-- Route/
|-- Vehicle/
|-- Store/
|-- Warehouse/
`-- Order/
```
---
## Hard Rules (from AGENTS.md)
## Hard Rules
- Only `uow.CommitAsync()` in services — never in repositories.
- Use `DateTime.Now` — never `DateTime.UtcNow`.
- Only `uow.CommitAsync()` in services - never in repositories.
- Use `DateTime.Now` - never `DateTime.UtcNow`.
- Never build anything not explicitly requested.
---
## Step 2 — Complete AppDBContext
File: `Data/AppDBContext.cs`
Add DbSets and Fluent API configuration:
```csharp
public DbSet<Region> Regions { get; set; }
public DbSet<Route> Routes { get; set; }
public DbSet<Vehicle> Vehicles { get; set; }
public DbSet<Store> Stores { get; set; }
public DbSet<Warehouse> Warehouses { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<Item> Items { get; set; }
// OnModelCreating
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
```
---
## Step 3 — Initial Migration
```powershell
dotnet ef migrations add InitialCreate --project Distribution
dotnet ef database update --project Distribution
```
Each developer applies this locally. No migration files are shared.
---
## Step 4 — Generic Repository (Infra/Repositories)
**`IGenericRepo<T>`**
```
GetByIdAsync(Guid id)
GetAllAsync()
AddAsync(T entity)
Update(T entity)
Remove(T entity)
```
**`GenericRepo<T>`** — implements above using DbContext. No `SaveChanges` calls.
---
## Step 5 — Feature Implementation Order
Build each feature in this dependency order:
```
1. Region → no dependencies
2. Route → no dependencies
3. Vehicle → no dependencies
4. Store → no dependencies
5. Warehouse → depends on Vehicle (FK)
6. Order → depends on Store (FK)
7. OrderItem → depends on Order + Item (composite FK)
```
### Per-feature deliverables
#### DTOs (`{Feature}DTOs.cs`)
- `CreateRequest` — fields accepted on POST
- `UpdateRequest` — fields accepted on PUT
- `Response` — shape returned to client (never expose the entity directly)
#### Repository interface (`I{Feature}Repository.cs`)
Extends `IGenericRepo<T>`. Add feature-specific queries (e.g., `GetByStoreId`).
#### Repository implementation (`{Feature}Repository.cs`)
Inherits `GenericRepo<T>`. Implements feature-specific queries. No `CommitAsync` calls.
#### Service (`{Feature}Service.cs`)
- Contains all business logic.
- Calls `_uow.CommitAsync(ct)` after write operations.
- For multi-step writes: `BeginAsync → work → CommitAsync` (catch → `RollbackAsync`).
#### Controller (`{Feature}Controller.cs`)
Thin — maps HTTP → service call → returns result. No logic, no UoW calls.
---
## Step 6 — Endpoints
### Region — `/api/regions`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active regions |
| GET | `/{id}` | Get region by id |
| POST | `/` | Create region |
| PUT | `/{id}` | Update region |
| DELETE | `/{id}` | Soft delete (set IsActive = false) |
### Route — `/api/routes`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active routes |
| GET | `/{id}` | Get route by id |
| POST | `/` | Create route |
| PUT | `/{id}` | Update route |
| DELETE | `/{id}` | Soft delete |
### Vehicle — `/api/vehicles`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active vehicles |
| GET | `/{id}` | Get vehicle by id |
| POST | `/` | Create vehicle |
| PUT | `/{id}` | Update vehicle |
| DELETE | `/{id}` | Soft delete |
### Store — `/api/stores`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active stores |
| GET | `/{id}` | Get store by id |
| POST | `/` | Create store |
| PUT | `/{id}` | Update store |
| DELETE | `/{id}` | Soft delete |
### Warehouse — `/api/warehouses`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active warehouses |
| GET | `/{id}` | Get warehouse by id |
| POST | `/` | Create warehouse |
| PUT | `/{id}` | Update warehouse |
| DELETE | `/{id}` | Soft delete |
### Order — `/api/orders`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active orders |
| GET | `/{id}` | Get order with items |
| GET | `/store/{storeId}` | Get orders by store |
| POST | `/` | Create order with items |
| PUT | `/{id}` | Update order |
| DELETE | `/{id}` | Soft delete |
---
## Step 7 — DI Registration (Program.cs)
Register all feature repositories and services:
```csharp
// Generic base
builder.Services.AddScoped(typeof(IGenericRepo<>), typeof(GenericRepo<>));
// Feature repositories
builder.Services.AddScoped<IRegionRepository, RegionRepository>();
builder.Services.AddScoped<IRouteRepository, RouteRepository>();
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Feature services
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
```
---
## Deferred
| Item | Reason |
|---|---|
| `SaleRoute` | Shape undefined — implement after clarification |
| `Item` model | Sourced from ERP Core — user handles integration |
| `SaleRoute` | Re-evaluate after ride/route design is clarified |
| `Item` model | Sourced from ERP Core |
| `DeliveryRun` | Not in scope for this phase |
| Driver FK on DeliveryRun | Commented out — depends on HR/Driver module |
| Tests | No test project yet — add after endpoints stabilize |
| Tests | No test project yet |
---
## Note
This file is historical phase planning only. Current business definitions for ride, sale, order,
and fulfillment are maintained in:
- `docs/03_ride_lifecycle.md`
- `docs/04_loading_event.md`
- `docs/05_delivery_ride.md`
- `docs/06_sale_ride.md`
+9
View File
@@ -3,6 +3,8 @@ using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.DeliveryRide;
using Distribution.Features.Payment;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
@@ -16,6 +18,7 @@ using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
using Distribution.Features.Role;
using Distribution.Features.Route;
using Distribution.Features.Sale;
using Distribution.Features.Scheme;
using Distribution.Features.Store;
using Distribution.Features.Store.Kyc;
@@ -49,6 +52,7 @@ builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
@@ -69,6 +73,8 @@ builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<IPoRepository, PoRepository>();
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -76,6 +82,7 @@ builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>();
@@ -96,6 +103,8 @@ builder.Services.AddScoped<SchemeService>();
builder.Services.AddScoped<PoService>();
builder.Services.AddScoped<GrnService>();
builder.Services.AddScoped<PurchaseReturnService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+88 -55
View File
@@ -1,41 +1,59 @@
# 03 — Ride Lifecycle
# 03 - Ride Lifecycle
**Status**: Done
**Depends On**: 02 — Stock Ledger
**Depends On**: 02 - Stock Ledger
---
## Goal
Turn the currently-unused `DeliveryRun` model into a fully working ride — a session that turns a
Turn the currently-unused `DeliveryRun` model into a fully working ride - a session that turns a
vehicle into a temporary warehouse. Covers driver profile management, ride creation, state
transitions, cancellation workflow, and the vehicle breakdown scenario.
---
## Current Business Understanding
- A `Ride` is one operational trip for one vehicle and one driver on one route for now.
- `RideType` distinguishes business behavior:
- `Delivery` ride: used to fulfill existing orders.
- `Sale` ride: used by sales agents in the field to create sales, and every sale creates an
order.
- `Sale` and `Order` are different:
- `Sale` is the commercial transaction created by the sales agent.
- `Order` is the fulfillment document created from every sale.
- Item lines are maintained once in `OrderItem`; `Sale` does not keep a separate item-line table.
- `Sale` statuses use sales language such as `Completed`.
- `Order` statuses use logistics language such as `Fulfilled`.
---
## Data Model Changes
### New Entity — DriverProfile
### New Entity - DriverProfile
Linked one-to-one with `User`. Holds compliance data only — no login or auth fields.
Linked one-to-one with `User`. Holds compliance data only - no login or auth fields.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_UserId | Guid | FK → User (unique) |
| FK_UserId | Guid | FK -> User (unique) |
| LicenseNumber | string | |
| LicenseExpiry | DateTime | |
| MedicalClearanceExpiry | DateTime | |
| IsActive | bool | Can be suspended without deleting |
| CreatedAt | DateTime | |
No license class tied to vehicle type — not required.
No license class tied to vehicle type - not required.
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled: `LicenseExpiry > today` AND/OR `MedicalClearanceExpiry > today`.
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are
enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled:
`LicenseExpiry > today` and/or `MedicalClearanceExpiry > today`.
---
### Updated Entity — Vehicle
### Updated Entity - Vehicle
Add one nullable field:
@@ -45,7 +63,7 @@ Add one nullable field:
---
### Updated Entity — DeliveryRun (becomes Ride)
### Updated Entity - DeliveryRun (becomes Ride)
Add the following fields to the existing `DeliveryRun`:
@@ -53,34 +71,43 @@ Add the following fields to the existing `DeliveryRun`:
|---|---|---|
| RideType | RideType | Delivery or Sale |
| Status | RideStatus | State machine below |
| FK_DriverProfileId | Guid | FK → DriverProfile |
| FK_DriverProfileId | Guid | FK -> DriverProfile |
| FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. |
| StartedAt | DateTime? | Set when Draft → Open |
| ActivatedAt | DateTime? | Set when Open → Active |
| ClosedAt | DateTime? | Set when Active → Closed |
| StartedAt | DateTime? | Set when Draft -> Open |
| ActivatedAt | DateTime? | Set when Open -> Active |
| ClosedAt | DateTime? | Set when Active -> Closed |
---
### New Enum — RideType
### New Enum - RideType
```csharp
public enum RideType { Delivery, Sale }
```
### New Enum — RideStatus
### New Enum - RideStatus
```csharp
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
```
Meaning of statuses:
- `Draft`: ride exists but operational work has not started.
- `Open`: ride is prepared and ready for loading/preparation work.
- `Active`: ride is live in the field.
- `CancelRequested`: active ride is waiting for cancellation approval or rejection.
- `Cancelled`: ride ended through approved cancellation.
- `Closed`: ride ended normally.
---
### New Entity — RideCancelRequest
### New Entity - RideCancelRequest
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_RideId | Guid | FK -> DeliveryRun |
| TypeCode | CancelRequestType | Reason category |
| Reason | string | Free text explanation |
| RequestedBy | Guid | UserId |
@@ -89,7 +116,7 @@ public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed
| ApprovedBy | Guid? | UserId of approver |
| ApprovedAt | DateTime? | |
### New Enum — CancelRequestType
### New Enum - CancelRequestType
```csharp
public enum CancelRequestType
@@ -101,7 +128,7 @@ public enum CancelRequestType
}
```
### New Entity — DriverComplianceConfig
### New Entity - DriverComplianceConfig
Single-row configuration table. Controls which compliance checks are enforced at ride open time.
@@ -117,7 +144,7 @@ Managed via a dedicated config endpoint. Only one row ever exists.
---
### New Enum — CancelRequestStatus
### New Enum - CancelRequestStatus
```csharp
public enum CancelRequestStatus { Pending, Approved, Rejected }
@@ -127,21 +154,22 @@ public enum CancelRequestStatus { Pending, Approved, Rejected }
## State Machine
```
Draft → Open → Active → Closed
↘ CancelRequested → Cancelled
```text
Draft -> Open -> Active -> Closed
\-> CancelRequested -> Cancelled
```
| Transition | Trigger | What it does |
|---|---|---|
| Draft → Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
| Open → Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
| Active → Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
| Active → CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
| CancelRequested → Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
| CancelRequested → Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
| Draft -> Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
| Open -> Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
| Active -> Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
| Active -> CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
| CancelRequested -> Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
| CancelRequested -> Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride transitions to Open. No new orders after Open.
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride
transitions to Open. No new orders after Open.
---
@@ -150,7 +178,7 @@ Draft → Open → Active → Closed
Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse)
or it is null (warehouse is selected manually at ride creation time).
Loading and unloading warehouse are always the same — stock must return to its source warehouse
Loading and unloading warehouse are always the same - stock must return to its source warehouse
before a ride can close or cancel.
---
@@ -160,12 +188,12 @@ before a ride can close or cancel.
1. Driver submits cancel request with `TypeCode = VehicleBreakdown`.
2. Ride status moves to `CancelRequested`.
3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their
current vehicle stock levels and the stranded ride's open orders — supervisor decides manually
current vehicle stock levels and the stranded ride's open orders. Supervisor decides manually
which ride takes over which orders.
4. Supervisor approves the cancel request.
5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse.
The system surfaces the information — it does not automatically reassign orders.
The system surfaces the information - it does not automatically reassign orders.
---
@@ -179,14 +207,14 @@ Base route: `/api/rides`
| GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. |
| GET | `/{id}` | Get ride detail |
| PUT | `/{id}/driver` | Assign or change driver profile |
| POST | `/{id}/open` | Transition Draft → Open |
| POST | `/{id}/activate` | Transition Open → Active (called by loading event) |
| POST | `/{id}/close` | Transition Active → Closed (called by reconciliation) |
| POST | `/{id}/open` | Transition Draft -> Open |
| POST | `/{id}/activate` | Transition Open -> Active (called by loading event) |
| POST | `/{id}/close` | Transition Active -> Closed (called by reconciliation) |
| POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) |
| GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. |
| POST | `/cancel-requests/{id}/approve` | Approve cancel request |
| POST | `/cancel-requests/{id}/reject` | Reject cancel request |
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock + stranded ride's open orders |
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock plus stranded ride's open orders |
### DriverComplianceConfig
@@ -215,37 +243,42 @@ Base route: `/api/driver-profiles`
## Business Rules
1. A ride cannot transition Draft → Open if `IsActive = false` on the driver profile. If `EnforceLicenseExpiry = true`, also blocks if license is expired. If `EnforceMedicalClearance = true`, also blocks if medical clearance is expired.
1. A ride cannot transition Draft -> Open if `IsActive = false` on the driver profile. If
`EnforceLicenseExpiry = true`, also block if license is expired. If
`EnforceMedicalClearance = true`, also block if medical clearance is expired.
2. A vehicle cannot be on more than one Open or Active ride at a time.
3. Orders can only be assigned to a Delivery ride while it is in Draft status.
4. Loading and unloading warehouse must be the same — enforced at close/cancel.
4. Loading and unloading warehouse must be the same - enforced at close/cancel.
5. A CancelRequested ride cannot close or activate until the request is resolved.
6. All cancel requests must have a TypeCode — free text reason alone is not sufficient.
6. All cancel requests must have a TypeCode - free text reason alone is not sufficient.
7. All list endpoints return paginated responses.
8. A ride uses a single route for now.
---
## Open Questions
1. **Cancel request type codes** — ✅ Resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other`.
2. **Breakdown assist endpoint** — ✅ Resolved. System shows info only, supervisor decides manually. No auto-reassignment.
3. **Driver compliance** — ✅ Resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive` is always enforced. License and medical checks are optional flags, default off.
1. Cancel request type codes - resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`,
`Other`.
2. Breakdown assist endpoint - resolved. System shows info only, supervisor decides manually. No
auto-reassignment.
3. Driver compliance - resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive`
is always enforced. License and medical checks are optional flags, default off.
4. Multi-route ride assignment - deferred for now. Current model stays single-route.
---
## Implementation Notes
**Build order:**
1. ✅ Enums — `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` → added to `Models/Enums.cs`
2. ✅ Entities — `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` (new); updated `Vehicle` (+`FK_DefaultWarehouseId`), updated `DeliveryRun` (+`RideType`, `Status`, `FK_DriverProfileId`, `FK_SourceWarehouseId`, `StartedAt`, `ActivatedAt`, `ClosedAt`)
3. ✅ `AppDBContext` — new DbSets + Fluent config
4. ✅ Migrations — `RideLifecycle` (schema), `RidePermissions` (10 new permissions seeded across all roles)
5. ✅ DTOs — `DriverProfileDTOs.cs`, `DriverComplianceConfigDTOs.cs`, `RideDTOs.cs`
6. ✅ Repositories — `IDriverProfileRepository`, `IDriverComplianceConfigRepository`, `IRideRepository`, `IRideCancelRequestRepository`
7. ✅ Services — `DriverProfileService`, `DriverComplianceConfigService`, `RideService`
8. ✅ Controllers — `DriverProfileController`, `DriverComplianceConfigController`, `RideController`
9. ✅ DI registration in `Program.cs`
1. Enums - `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus`
2. Entities - `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest`
3. `AppDBContext` - new DbSets plus Fluent config
4. Migrations - ride lifecycle schema and permissions
5. DTOs - driver profile, compliance config, ride
6. Repositories - ride and ride cancel request support
7. Services - driver profile, compliance config, ride
8. Controllers - driver profile, compliance config, ride
9. DI registration in `Program.cs`
**Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/`
**Pending:** Tests for the three new services.
+57 -76
View File
@@ -1,7 +1,7 @@
# 04 — Loading Event
# 04 - Loading Event
**Status**: Planning
**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.
When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions
`Open → Active` automatically. This is the first place the stock movement model gets exercised
end-to-end.
`Open -> Active` automatically.
This loading flow serves both ride types:
- `Delivery` rides load stock to fulfill orders already linked to the ride.
- `Sale` rides load stock so the sales agent can sell in the field and create sale-linked orders.
---
## Background / Context
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger — the same math as
a warehouse. When loading is confirmed, two stock movements are created per item:
- **Debit** source warehouse: `-qty` at `LocationType.Warehouse`
- **Credit** vehicle: `+qty` at `LocationType.Vehicle`
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger - the same math as a
warehouse. When loading is confirmed, two stock movements are created per item:
From this point the vehicle carries live inventory that decreases with every sale or delivery
(Steps 05/06) and must return to zero before the ride can close (Step 07).
- Debit source warehouse: `-qty` at `LocationType.Warehouse`
- Credit vehicle: `+qty` at `LocationType.Vehicle`
From this point the vehicle carries live inventory that decreases with every sale or delivery and
must return to zero before the ride can close.
---
## Data Model Changes
### New Entity — LoadingSheet
### New Entity - LoadingSheet
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_SourceWarehouseId | Guid | FK → Warehouse |
| FK_RideId | Guid | FK -> DeliveryRun |
| FK_SourceWarehouseId | Guid | FK -> Warehouse |
| Status | LoadingSheetStatus | State machine below |
| DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch |
| DispatchConfirmedBy | Guid? | UserId of warehouse manager |
@@ -42,18 +46,18 @@ From this point the vehicle carries live inventory that decreases with every sal
| ReceiptConfirmedBy | Guid? | UserId of driver |
| CreatedAt | DateTime | |
### New Entity — LoadingSheetItem
### New Entity - LoadingSheetItem
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| FK_ItemId | Guid | FK → Item |
| FK_LoadingSheetId | Guid | FK -> LoadingSheet |
| FK_ItemId | Guid | FK -> Item |
| QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch |
| QuantityReceived | decimal? | Set by driver before confirm-receipt |
| QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy |
### New Enum — LoadingSheetStatus
### New Enum - LoadingSheetStatus
```csharp
public enum LoadingSheetStatus
@@ -66,23 +70,23 @@ public enum LoadingSheetStatus
}
```
No changes to existing entities. Vehicle capacity check is deferred to Step 13 (Fleet Management).
No changes to existing entities. Vehicle capacity check is deferred to Step 13.
---
## State Machine
```
Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
↘ DiscrepancyFlagged → Resolved
```text
Pending -> DispatchConfirmed -> ReceiptConfirmed
\-> DiscrepancyFlagged -> Resolved
```
| Transition | Trigger | What it does |
|---|---|---|
| Pending → DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
| DispatchConfirmed → ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open → Active`. |
| DispatchConfirmed → DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
| DiscrepancyFlagged → Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open → Active`. |
| Pending -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open -> Active`. |
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open -> Active`. |
---
@@ -90,10 +94,11 @@ Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
| Ride Type | How items are added to the sheet |
|---|---|
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride |
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` (no orders to derive from) |
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride's route |
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` based on expected field selling demand |
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before confirming dispatch. `PUT /{id}/items` is still available to override if needed.
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before
confirming dispatch. `PUT /{id}/items` is still available to override if needed.
---
@@ -101,12 +106,13 @@ For Delivery rides the warehouse manager reviews the pre-populated quantities an
Created at `ReceiptConfirmed` or `Resolved`. One pair per line item:
- **Debit** warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- **Credit** vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
- Debit warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- Credit vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a discrepancy was resolved.
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a
discrepancy was resolved.
Movement `Type` = `Load` (new `StockMovementType` value to add to enums).
Movement `Type` = `Load`.
---
@@ -119,23 +125,25 @@ Base route: `/api/loading-sheets`
| POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. |
| GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail with all line items |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities (required for Sale rides, optional for Delivery) |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending → DispatchConfirmed |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending -> DispatchConfirmed |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line |
| POST | `/{id}/confirm-receipt` | Driver | → ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line → Resolved + ride activation |
| POST | `/{id}/confirm-receipt` | Driver | -> ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line -> Resolved plus ride activation |
---
## Business Rules
1. A loading sheet can only be created for a ride in `Open` status.
2. A ride can only have one active loading sheet (not `Resolved` or `ReceiptConfirmed`) at a time.
3. `QuantityDispatched != QuantityReceived` on any line → `DiscrepancyFlagged`. No partial activation.
4. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. No bulk "accept driver" or "accept warehouse" shortcut.
2. A ride can only have one active loading sheet at a time.
3. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial activation.
4. Stock movements are created only once - at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value.
6. All list endpoints return paginated responses.
7. Vehicle capacity check is not enforced in this step — deferred to Step 13.
7. Vehicle capacity check is not enforced in this step.
8. Immediate handover from a Sale ride does not remove the need for an `Order`; every sale still
creates an order, and that order is fulfilled immediately when stock is handed over at sale time.
---
@@ -145,54 +153,27 @@ Base route: `/api/loading-sheets`
|---|---|
| `loading.view` | All roles |
| `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver (AreaManager and above for testing) |
| `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above |
---
## Fraud / Control Considerations
- Discrepancies are immediately visible and block activation — they cannot be silently absorbed.
- `QuantityResolved` is permanently stored per line for audit purposes, even after the sheet is resolved.
- See [09_fraud_controls.md](./09_fraud_controls.md) for the audit trail layer that will sit on top of this.
- Discrepancies are immediately visible and block activation.
- `QuantityResolved` is permanently stored per line for audit purposes.
---
## Open Questions
- ✅ Vehicle capacity check — deferred to Step 13 (Fleet Management).
- ✅ Discrepancy resolution — supervisor enters `QuantityResolved` per line item.
- ✅ Ride activation trigger — `confirm-receipt` and `resolve` auto-trigger `Open → Active` internally.
- ✅ Item population — Delivery rides auto-populated from orders; Sale rides manually entered.
1. Vehicle capacity check - deferred to Step 13.
2. Discrepancy resolution - supervisor enters `QuantityResolved` per line item.
3. Ride activation trigger - `confirm-receipt` and `resolve` auto-trigger `Open -> Active`.
4. Delivery item population - from assigned orders on the ride's route.
---
## Implementation Notes
**Build order:**
1. Enum — add `LoadingSheetStatus` to `Models/Enums.cs`; add `Load` to `StockMovementType`
2. Entities — `LoadingSheet`, `LoadingSheetItem` (new)
3. `AppDBContext` — new DbSets + Fluent config
4. Migration — `LoadingEvent` (schema) + `LoadingPermissions` (4 new permissions seeded)
5. DTOs — `LoadingSheetDTOs.cs`
6. Repository — `ILoadingSheetRepository`, `LoadingSheetRepository`
7. Service — `LoadingSheetService`
8. Controller — `LoadingSheetController`
9. DI registration in `Program.cs`
**Feature folder:** `Features/LoadingEvent/`
**What was built:**
1. ✅ Enums — added `LoadingSheetStatus` and `Load` to `MovementType` in `Models/Enums.cs`
2. ✅ Entities — `LoadingSheet`, `LoadingSheetItem` created in `Models/`
3. ✅ `AppDBContext` — `LoadingSheets`, `LoadingSheetItems` DbSets added; Fluent config for FK restrictions and ride/warehouse relationships
4. ✅ DTOs — `LoadingSheetDTOs.cs` in `Features/LoadingEvent/`
5. ✅ Repository — `ILoadingSheetRepository`, `LoadingSheetRepository` in `Features/LoadingEvent/`
6. ✅ Service — `LoadingSheetService` in `Features/LoadingEvent/`
7. ✅ Controller — `LoadingSheetController` in `Features/LoadingEvent/`
8. ✅ DI registration in `Program.cs`
9. ⚠️ Permissions added to seed (`loading.view`, `loading.dispatch`, `loading.receive`, `loading.resolve`) — **authorization layer is being replaced, skip this**
**Pending (run manually):**
- `dotnet ef migrations add LoadingEvent` — blocked by a pre-existing duplicate Permission seed ID in `AppDBContextModelSnapshot.cs` (ID `20000000-0000-0000-0000-000000000040` is assigned to both `categories.delete` and `driver_profiles.create`). This is a snapshot consistency issue from a previous session and needs to be resolved before migration generation will succeed.
- `dotnet ef database update` after migration is generated.
Build after the ride model and before full delivery/sale execution.
+107 -38
View File
@@ -1,79 +1,148 @@
# 05 — Delivery Ride Execution
**Status**: Not Started
**Status**: Planning
**Depends On**: 04 — Loading Event
---
## Goal
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half
of [mainPlan.md §1](./mainPlan.md#1-order-management):
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
Fulfill pending orders against an active Delivery ride. The driver visits each store on the route,
delivers the ordered items, gets customer confirmation, and the system creates the stock movement,
updates the order, and completes the sale.
---
## Background / Context
Today `Order` has no status and no link to a ride. This step is where the order lifecycle
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)).
- A Delivery ride goes `Active` after the loading event is confirmed.
- Orders are linked to a ride implicitly through route: `Ride → Route → Stores → Pending Orders`.
- `QuantityFulfilled` on `OrderItem` tracks what was actually delivered (added in pre-planning).
- No separate backorder entity — partially fulfilled orders stay `PartiallyFulfilled` and get picked
up by the next delivery ride on the same route.
- Customer confirmation in v1 is a boolean flag (`CustomerConfirmed`). Full signature/OTP deferred
to Step 09 (Fraud Controls).
---
## Data Model Changes
### Delivery Task (per order on ride)
### New Entity — DeliveryTask
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`,
`Failed`).
Extends `Template` (adds `IsActive`, `CreatedAt` — consistent with all other entities).
### Delivery Outcome
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK (from Template) |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_OrderId | Guid | FK → Order |
| Sequence | int | Visit order on the route |
| Status | DeliveryTaskStatus | State machine below |
| CustomerConfirmed | bool | Set to true at confirm time |
| ConfirmedAt | DateTime? | Timestamp of customer confirmation |
| Note | string? | Driver note on failure or partial delivery |
| IsActive | bool | From Template (soft delete) |
| CreatedAt | DateTime | From Template |
Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder`
for the shortfall.
### New Enum — DeliveryTaskStatus
### Customer Confirmation
```csharp
public enum DeliveryTaskStatus
{
Pending,
Delivered,
PartiallyDelivered,
Failed
}
```
Signature or OTP captured against the delivery task before it can be marked complete.
### No new columns on OrderItem
`QuantityFulfilled` already added in pre-planning. No further model changes needed.
---
## State Machine
```
Pending → Delivered (all lines fully delivered + customer confirmed)
Pending → PartiallyDelivered (some lines short + customer confirmed)
Pending → Failed (driver could not deliver, no stock movement)
```
---
## Stock Movements
Created on `Delivered` or `PartiallyDelivered` after customer confirmation. One movement per item:
- **Debit** vehicle: `-QuantityFulfilled` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
- `VehicleId` is resolved from `DeliveryTask.FK_RideId → DeliveryRun.FK_VehicleId`
- `MovementType.Sale`
- `ReferenceId = OrderId`
No movement on `Failed`.
---
## Order and Sale Status Transitions (triggered internally)
| Condition | Order | Sale |
|---|---|---|
| All OrderItem lines: QuantityFulfilled == Quantity | → Fulfilled | → Completed |
| Any OrderItem line: QuantityFulfilled < Quantity | → PartiallyFulfilled | stays Pending |
| Task Failed | stays Pending | stays Pending |
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` |
| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) |
| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` |
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP |
Base routes: `/api/rides/{rideId}/tasks` and `/api/delivery-tasks`
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/rides/{rideId}/tasks/generate` | Driver/System | Auto-create tasks from all Pending/PartiallyFulfilled orders for stores on ride's route |
| GET | `/api/rides/{rideId}/tasks` | Driver | List all tasks for this ride with current status |
| GET | `/api/delivery-tasks/{id}` | Driver | Task detail with order items and quantities |
| PUT | `/api/delivery-tasks/{id}/quantities` | Driver | Set QuantityFulfilled per order item before confirming |
| POST | `/api/delivery-tasks/{id}/confirm` | Driver | Customer confirmed — creates stock movements, transitions order/sale |
| POST | `/api/delivery-tasks/{id}/fail` | Driver | Mark task as failed with a note |
---
## Business Rules
- A delivery task cannot be marked `Delivered` without a customer confirmation.
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape).
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle
location per [02_stock_ledger.md](./02_stock_ledger.md).
1. Delivery tasks can only be created for a ride in `Active` status and of type `Delivery`.
2. One task per order per ride — cannot create a second task for the same order on the same ride.
3. `QuantityFulfilled` must be set on all order items before calling confirm.
4. `QuantityFulfilled` cannot exceed `Quantity` on any line.
5. `CustomerConfirmed` must be true before the task can move to `Delivered` or `PartiallyDelivered`.
6. Stock movement is created only once — at confirm. Never on fail.
7. A failed task does not change the order or sale status — the order stays available for the next ride.
8. Partial delivery: the unfulfilled quantity (`Quantity - QuantityFulfilled`) remains on the same
order. The next delivery ride on the same route will pick it up automatically via `generate`.
---
## Fraud / Control Considerations
## Implementation Build Order
- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not
editable after submission).
1. Enum — add `DeliveryTaskStatus` to `Models/Enums.cs`
2. Entity — `DeliveryTask` in `Models/`
3. `AppDBContext` — new DbSet + Fluent config (Restrict on Ride, Order FKs)
4. Migration
5. DTOs — `DeliveryTaskDTOs.cs` in `Features/DeliveryRide/`
6. Repository — `IDeliveryTaskRepository`, `DeliveryTaskRepository`
7. Service — `DeliveryTaskService`
8. Controller — `DeliveryTaskController`
9. DI registration in `Program.cs`
**Feature folder:** `Features/DeliveryRide/`
---
## Open Questions
## Open Questions — Resolved
- Signature capture (image) or OTP (SMS code) — one, or both, in v1?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- ✅ Customer confirmation — boolean flag in v1, full signature/OTP in Step 09
- ✅ Backorder — no separate entity; same order stays PartiallyFulfilled for next ride
- ✅ Task generation — on-demand via `generate` endpoint, not automatic on ride activation
- ✅ Failed task — order stays Pending, available for next ride, no stock movement
+95 -43
View File
@@ -1,80 +1,132 @@
# 06 — Sale Ride Execution
**Status**: Not Started
**Status**: Planning
**Depends On**: 04 — Loading Event
---
## Goal
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is
generated immediately, and payment is captured at point of sale. Covers `createInvoice`,
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting),
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
(§11) if promotions are live by the time this ships.
Allow a sales agent on an active Sale ride to create sales in the field. Every sale creates an
order. Immediate handover deducts vehicle stock and closes the order immediately. Non-immediate
handover leaves the order pending for a future delivery ride.
---
## Background / Context
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row —
the field invoice *is* the order, created and settled in one step.
- `SaleService.CreateAsync` already exists and handles both `FulfillImmediately` paths.
- What Step 06 adds: vehicle stock validation and deduction on immediate handover, and the
Payment recording endpoint.
- `Sale.PaymentStatus` and the `Payment` model were added in pre-planning.
- Pricing is already resolved via `PriceListService` inside `SaleService`.
---
## Data Model Changes
### Field Invoice
No new entities. Everything needed was added in pre-planning:
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`,
`OnCredit`).
| What | Where | Status |
|---|---|---|
| `Payment` entity | `Models/Payment.cs` | ✅ Done |
| `PaymentStatus` enum | `Models/Enums.cs` | ✅ Done |
| `PaymentMethod` enum | `Models/Enums.cs` | ✅ Done |
| `Sale.PaymentStatus` | `Models/Sale.cs` | ✅ Done |
| `OrderItem.QuantityFulfilled` | `Models/OrderItem.cs` | ✅ Done |
### Field Invoice Line Item
`FieldInvoiceId`, `ItemId`, `Quantity`, `UnitPrice` (subject to the discount-limit rules in
[08_sales_agent_controls.md](./08_sales_agent_controls.md)).
### Payment Record
`FieldInvoiceId`, `Amount`, `Method` (`Cash`, `Credit`), `CollectedAt`.
Migration needed for: `Payment` table, `Sale.PaymentStatus` column, `OrderItem.QuantityFulfilled` column.
---
## API Endpoints
## Changes to Existing SaleService
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here |
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` |
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits |
`SaleService.CreateAsync` needs two additions when `FulfillImmediately = true` and `RideId` is set:
1. **Stock check** — for each item, verify vehicle has enough stock (`StockBalance` at
`LocationType.Vehicle` with `LocationId = VehicleId`). Reject the whole sale if any item is short.
2. **Stock deduction** — debit vehicle balance per item, create one `StockMovement` per item:
- `MovementType.Sale`
- `FromLocationId = VehicleId`, `FromLocationType = Vehicle`
- `ReferenceId = OrderId`
`VehicleId` is resolved from `Sale.FK_RideId → DeliveryRun.FK_VehicleId`.
**SaleService constructor additions required:**
- `IStockRepository` — for stock balance check and debit
- `IRideRepository` — to look up the ride and resolve `FK_VehicleId`
When `FulfillImmediately = false`, no stock movement — order stays Pending for a delivery ride.
---
## New Feature — Payment
**Feature folder:** `Features/Payment/`
### API Endpoints
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
### Payment Creation Logic
On `POST /api/sales/{id}/payments`:
1. Validate sale exists and is not `Cancelled`.
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
- `sum == 0` → `Pending`
- `sum < Sale.TotalAmount` → `PartiallyPaid`
- `sum >= Sale.TotalAmount` → `Paid`
4. Update `Sale.PaymentStatus` in the same transaction.
### DTOs
```csharp
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(Guid Id, Guid SaleId, Guid StoreId, decimal Amount,
PaymentMethod Method, DateTime CollectedAt, Guid CollectedBy);
```
---
## Business Rules
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the
invoice is finalized.
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location,
immediately (not batched at ride close).
- Discount applied per line must not exceed the agent's role-level limit (§8).
1. Driver cannot sell more than vehicle stock for immediate handover — entire sale rejected if any
item is short (no partial sale creation).
2. Every sale creates an order, even when all items are handed over immediately.
3. `FulfillImmediately = true`:
- Vehicle stock debited immediately per item.
- Order → `Fulfilled`, Sale → `Completed` at creation time (already in SaleService).
- `QuantityFulfilled = Quantity` on all order items (already in SaleService).
4. `FulfillImmediately = false`:
- No stock movement at sale time.
- Order → `Pending`, Sale → `Pending` at creation time.
5. Payment can be recorded at any time — sale does not need to be `Completed` first.
6. Overpayment is allowed (`sum > TotalAmount`) — `PaymentStatus` stays `Paid`.
7. A `Cancelled` sale cannot receive new payments.
---
## Fraud / Control Considerations
## Implementation Build Order
- Cash collected must reconcile against invoiced totals at ride close — see
[07_ride_reconciliation.md](./07_ride_reconciliation.md).
1. Migration — `Payment` table, `Sale.PaymentStatus`, `OrderItem.QuantityFulfilled`
2. Extend `SaleService.CreateAsync` — stock check + stock movement on `FulfillImmediately`
3. DTOs — `PaymentDTOs.cs` in `Features/Payment/`
4. Repository — `IPaymentRepository`, `PaymentRepository`
5. Service — `PaymentService`
6. Controller — `PaymentController`
7. DI registration in `Program.cs`
**Feature folder:** `Features/Payment/` (new), `Features/Sale/` (extended)
---
## Open Questions
## Open Questions — Resolved
- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one
sequence?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- ✅ Payment timing — can be recorded at any time, not locked to sale completion
- ✅ Overpayment — allowed, status stays Paid
- ✅ Stock check scope — entire sale rejected if any item is short, no partial creation
- ✅ Non-immediate handover — no stock movement at sale time, order picked up by delivery ride
+16 -8
View File
@@ -43,8 +43,8 @@ They must be resolved before new feature steps begin because later steps depend
| 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
| 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
| 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) |
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) |
| 05 | Delivery Ride Execution | Planning | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Planning | 04 | [06_sale_ride.md](./06_sale_ride.md) |
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
@@ -303,13 +303,17 @@ Introduce the concept of stock per location. Every warehouse (fixed or vehicle)
of each item. Stock is never edited directly — all changes happen through movement transactions
(load, sale, return, damage, adjustment). The current balance is always the running sum.
This is the single most important architectural decision for the whole system.
For now, the shop is treated as a business destination, not a tracked stock location in this
system. That means warehouse-to-vehicle and vehicle-to-warehouse movements update tracked stock
balances, while vehicle-to-shop handover reduces vehicle stock and updates sale/order fulfillment
state without creating a shop stock balance.
**Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction.
### 03 — Ride Lifecycle
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine:
Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time.
Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving,
which route, which vehicle, and when it started and ended.
which route, which vehicle, and when it started and ended. For now, one ride uses one route.
**Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens.
### 04 — Loading Event
@@ -324,14 +328,18 @@ On a delivery ride, the driver fulfills pre-confirmed orders. Each order becomes
The driver marks each as delivered, partially delivered, or failed. Partially delivered orders
generate a credit note for the undelivered quantity. Customer confirmation (signature or OTP)
is required to mark a delivery complete. Stock movements are created per delivery event.
Vehicle-to-shop handover reduces vehicle stock only; the shop does not yet exist as a tracked stock
location in the ledger.
**Gate**: A delivery ride can execute all orders on its route and produce a delivery report.
### 06 — Sale Ride Execution
On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist.
The driver selects items, quantities, and prices (within approved discount limits), and the invoice
is generated immediately. Payment is collected (cash or credit) at the point of sale.
The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded.
**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale.
On a sale ride, the sales agent visits customers and creates sales in the field. Every sale creates
an order. If items are handed over immediately, the order is fulfilled immediately and the sale is
completed immediately. If items are not handed over immediately, the order stays pending for later
fulfillment. Payment is collected based on the final sale flow. The vehicle's stock ledger decreases
only for quantities actually handed over from the vehicle. The shop is not yet a tracked stock
location in the ledger.
**Gate**: A sale ride can create sales in the field, create linked orders, and reduce vehicle stock for immediate handover quantities.
### 07 — Ride Close & Reconciliation
When a ride ends, the system performs a full reconciliation. The equation must balance: