Add DeliveryTask, Payment, and Sale features

Implemented DeliveryTask, Payment, and Sale entities with full CRUD, business logic, and API endpoints. Updated Order/OrderItem for sale linkage and fulfillment tracking. Registered new DbSets and DI services. Revised documentation for delivery, sale, and payment workflows.
This commit is contained in:
Kalana B. Thilakarathna
2026-08-24 21:34:57 +05:30
parent a833586e83
commit 6d8b6b3e59
38 changed files with 3938 additions and 563 deletions
@@ -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);
}