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