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:
@@ -0,0 +1,72 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api")]
|
||||
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("rides/{rideId:guid}/tasks/generate")]
|
||||
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.GenerateAsync(rideId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||
{
|
||||
var result = await service.GetByRideIdAsync(rideId);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("delivery-tasks/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("delivery-tasks/{id:guid}/quantities")]
|
||||
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetQuantitiesAsync(id, request, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("delivery-tasks/{id:guid}/confirm")]
|
||||
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("delivery-tasks/{id:guid}/fail")]
|
||||
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.FailAsync(id, request.Note, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||
|
||||
public record DeliveryTaskResponse(
|
||||
Guid Id,
|
||||
Guid FK_RideId,
|
||||
Guid FK_OrderId,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
int Sequence,
|
||||
DeliveryTaskStatus Status,
|
||||
bool CustomerConfirmed,
|
||||
DateTime? ConfirmedAt,
|
||||
string? Note,
|
||||
List<DeliveryTaskItemResponse> Items,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
|
||||
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
|
||||
|
||||
public record FailTaskRequest(string Note);
|
||||
@@ -0,0 +1,38 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
|
||||
{
|
||||
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
|
||||
await context.DeliveryTasks
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.Store)
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.OrderItems)
|
||||
.ThenInclude(i => i.Item)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
|
||||
await context.DeliveryTasks
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.Store)
|
||||
.Include(t => t.Order)
|
||||
.ThenInclude(o => o.OrderItems)
|
||||
.ThenInclude(i => i.Item)
|
||||
.Where(t => t.FK_RideId == rideId)
|
||||
.OrderBy(t => t.Sequence)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
|
||||
await context.DeliveryTasks
|
||||
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
|
||||
|
||||
public async Task AddAsync(DeliveryTask task) =>
|
||||
await context.DeliveryTasks.AddAsync(task);
|
||||
|
||||
public void Update(DeliveryTask task) =>
|
||||
context.DeliveryTasks.Update(task);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public class DeliveryTaskService(
|
||||
IDeliveryTaskRepository repo,
|
||||
IRideRepository rideRepo,
|
||||
IOrderRepository orderRepo,
|
||||
ISaleRepository saleRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
|
||||
new(t.Id,
|
||||
t.FK_RideId,
|
||||
t.FK_OrderId,
|
||||
t.Order.FK_StoreId,
|
||||
t.Order.Store?.StoreName ?? string.Empty,
|
||||
t.Sequence,
|
||||
t.Status,
|
||||
t.CustomerConfirmed,
|
||||
t.ConfirmedAt,
|
||||
t.Note,
|
||||
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
|
||||
i.FK_ItemId,
|
||||
i.Item?.Name ?? string.Empty,
|
||||
i.Quantity,
|
||||
i.QuantityFulfilled,
|
||||
i.UnitPrice)).ToList(),
|
||||
t.CreatedAt);
|
||||
|
||||
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
|
||||
{
|
||||
var ride = await rideRepo.GetByIdAsync(rideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
if (ride.Status != RideStatus.Active)
|
||||
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
|
||||
|
||||
if (ride.RideType != RideType.Delivery)
|
||||
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
|
||||
|
||||
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
|
||||
|
||||
var created = new List<DeliveryTask>();
|
||||
int sequence = 1;
|
||||
|
||||
foreach (var order in orders)
|
||||
{
|
||||
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
|
||||
continue;
|
||||
|
||||
var task = new DeliveryTask
|
||||
{
|
||||
FK_RideId = rideId,
|
||||
FK_OrderId = order.Id,
|
||||
Sequence = sequence++,
|
||||
Status = DeliveryTaskStatus.Pending
|
||||
};
|
||||
created.Add(task);
|
||||
}
|
||||
|
||||
if (created.Count == 0)
|
||||
return new List<DeliveryTaskResponse>();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var task in created)
|
||||
await repo.AddAsync(task);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByRideIdAsync(rideId);
|
||||
return saved.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
|
||||
{
|
||||
var tasks = await repo.GetByRideIdAsync(rideId);
|
||||
return tasks.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(id);
|
||||
return task is null ? null : ToResponse(task);
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException("Delivery task not found");
|
||||
|
||||
if (task.Status != DeliveryTaskStatus.Pending)
|
||||
throw new InvalidOperationException("Quantities can only be set for a Pending task");
|
||||
|
||||
foreach (var input in request.Items)
|
||||
{
|
||||
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
|
||||
|
||||
if (input.QuantityFulfilled < 0)
|
||||
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
|
||||
|
||||
if (input.QuantityFulfilled > orderItem.Quantity)
|
||||
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
|
||||
|
||||
orderItem.QuantityFulfilled = input.QuantityFulfilled;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(task);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException("Delivery task not found");
|
||||
|
||||
if (task.Status != DeliveryTaskStatus.Pending)
|
||||
throw new InvalidOperationException("Only a Pending task can be confirmed");
|
||||
|
||||
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
|
||||
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
var vehicleId = ride.FK_VehicleId;
|
||||
|
||||
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
|
||||
|
||||
task.CustomerConfirmed = true;
|
||||
task.ConfirmedAt = DateTime.Now;
|
||||
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
|
||||
|
||||
var order = task.Order;
|
||||
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
|
||||
if (fullyDelivered)
|
||||
order.FulfilledAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
|
||||
{
|
||||
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
|
||||
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
|
||||
|
||||
if (vehicleBalance.Quantity < item.QuantityFulfilled)
|
||||
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
|
||||
|
||||
vehicleBalance.Quantity -= item.QuantityFulfilled;
|
||||
vehicleBalance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(vehicleBalance);
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = item.FK_ItemId,
|
||||
FromLocationId = vehicleId,
|
||||
FromLocationType = LocationType.Vehicle,
|
||||
Quantity = item.QuantityFulfilled,
|
||||
Type = MovementType.Sale,
|
||||
ReferenceId = task.FK_OrderId
|
||||
});
|
||||
}
|
||||
|
||||
repo.Update(task);
|
||||
orderRepo.Update(order);
|
||||
|
||||
if (fullyDelivered)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||
?? throw new KeyNotFoundException("Sale not found");
|
||||
sale.Status = SaleStatus.Completed;
|
||||
sale.CompletedAt = DateTime.Now;
|
||||
saleRepo.Update(sale);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(taskId);
|
||||
return ToResponse(saved!);
|
||||
}
|
||||
|
||||
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException("Delivery task not found");
|
||||
|
||||
if (task.Status != DeliveryTaskStatus.Pending)
|
||||
throw new InvalidOperationException("Only a Pending task can be failed");
|
||||
|
||||
task.Status = DeliveryTaskStatus.Failed;
|
||||
task.Note = note;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(task);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(task);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
public interface IDeliveryTaskRepository
|
||||
{
|
||||
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
|
||||
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
|
||||
Task AddAsync(DeliveryTask task);
|
||||
void Update(DeliveryTask task);
|
||||
}
|
||||
@@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
|
||||
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
|
||||
{
|
||||
var groups = await context.OrderItems
|
||||
.Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId)
|
||||
.Where(oi => oi.IsActive
|
||||
&& oi.Order.IsActive
|
||||
&& oi.Order.Store.FK_RouteId == routeId
|
||||
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
|
||||
.GroupBy(oi => oi.FK_ItemId)
|
||||
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
|
||||
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
|
||||
.ToListAsync();
|
||||
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ public interface IOrderRepository
|
||||
{
|
||||
Task<Models.Order?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Order>> GetAllAsync();
|
||||
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
|
||||
Task AddAsync(Models.Order entity);
|
||||
void Update(Models.Order entity);
|
||||
void Remove(Models.Order entity);
|
||||
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
|
||||
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
|
||||
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
|
||||
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
|
||||
}
|
||||
|
||||
@@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase
|
||||
[Authorize(Policy = "orders.create")]
|
||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Policy = "orders.update")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
|
||||
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||
public record CreateOrderRequest(Guid SaleId);
|
||||
|
||||
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
|
||||
public record UpdateOrderRequest(OrderStatus Status);
|
||||
|
||||
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
|
||||
|
||||
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
|
||||
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
|
||||
|
||||
public record OrderResponse(
|
||||
Guid Id,
|
||||
Guid SaleId,
|
||||
DateTime OrderDate,
|
||||
Guid StoreId,
|
||||
decimal TotalAmount,
|
||||
OrderStatus Status,
|
||||
DateTime? FulfilledAt,
|
||||
List<OrderItemResponse> Items,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
||||
public async Task<List<Models.Order>> GetAllAsync() =>
|
||||
await context.Orders.ToListAsync();
|
||||
|
||||
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
|
||||
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
|
||||
|
||||
public async Task AddAsync(Models.Order entity) =>
|
||||
await context.Orders.AddAsync(entity);
|
||||
|
||||
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
||||
|
||||
public void Remove(Models.Order entity)
|
||||
{
|
||||
entity.Status = Models.OrderStatus.Cancelled;
|
||||
entity.IsActive = false;
|
||||
context.Orders.Update(entity);
|
||||
}
|
||||
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
|
||||
|
||||
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
|
||||
await context.OrderItems.AddRangeAsync(items);
|
||||
|
||||
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
|
||||
await context.Orders
|
||||
.Include(o => o.Store)
|
||||
.Include(o => o.OrderItems)
|
||||
.ThenInclude(i => i.Item)
|
||||
.Where(o => o.IsActive
|
||||
&& o.Store.FK_RouteId == routeId
|
||||
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Store;
|
||||
@@ -5,7 +6,6 @@ using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Distribution.Features.Order;
|
||||
|
||||
@@ -15,16 +15,16 @@ public class OrderService(
|
||||
IItemRepository itemRepo,
|
||||
IStockRepository stockRepo,
|
||||
PriceListService priceListService,
|
||||
ISaleRepository saleRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser,
|
||||
IScopeAccessService scopeAccess,
|
||||
IConfiguration configuration)
|
||||
IScopeAccessService scopeAccess)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
|
||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
|
||||
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
|
||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
|
||||
o.IsActive, o.CreatedAt);
|
||||
|
||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||
@@ -35,42 +35,6 @@ public class OrderService(
|
||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
|
||||
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
|
||||
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
|
||||
/// can be resolved, or if the requested price is outside the allowed band.
|
||||
/// </summary>
|
||||
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
|
||||
{
|
||||
var item = await itemRepo.GetByIdAsync(itemId);
|
||||
if (item is null) return null;
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
|
||||
if (resolution is null) return null;
|
||||
|
||||
if (!requestedUnitPrice.HasValue) return resolution.Price;
|
||||
|
||||
var min = resolution.MinPrice ?? resolution.Price;
|
||||
var max = resolution.Price;
|
||||
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
|
||||
|
||||
return requestedUnitPrice.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
|
||||
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
|
||||
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
|
||||
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
|
||||
/// </summary>
|
||||
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
|
||||
{
|
||||
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
|
||||
var totalAvailable = balances.Sum(b => b.Quantity);
|
||||
return quantity <= totalAvailable;
|
||||
}
|
||||
|
||||
public async Task<List<OrderResponse>> GetAllAsync()
|
||||
{
|
||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
||||
@@ -112,52 +76,15 @@ public class OrderService(
|
||||
|
||||
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||
var sale = await saleRepo.GetByIdAsync(request.SaleId);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (store.KycStatus != KycStatus.Verified)
|
||||
{
|
||||
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
||||
if (request.TotalAmount > threshold) return null;
|
||||
}
|
||||
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
|
||||
?? throw new InvalidOperationException("Orders are created automatically from sales");
|
||||
|
||||
var order = new Models.Order
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
OrderDate = request.OrderDate,
|
||||
TotalAmount = request.TotalAmount
|
||||
};
|
||||
var orderItems = new List<Models.OrderItem>();
|
||||
foreach (var i in request.Items)
|
||||
{
|
||||
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
|
||||
|
||||
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
||||
if (unitPrice is null) return null;
|
||||
|
||||
orderItems.Add(new Models.OrderItem
|
||||
{
|
||||
FK_OrderId = order.Id,
|
||||
FK_ItemId = i.ItemId,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = unitPrice.Value
|
||||
});
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(order);
|
||||
await repo.AddItemsAsync(orderItems);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
return ToResponse(order, orderItems);
|
||||
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
|
||||
return ToResponse(existingOrder, items);
|
||||
}
|
||||
|
||||
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
|
||||
@@ -165,12 +92,26 @@ public class OrderService(
|
||||
var order = await repo.GetByIdAsync(id);
|
||||
if (order is null || !order.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
|
||||
order.OrderDate = request.OrderDate;
|
||||
order.TotalAmount = request.TotalAmount;
|
||||
|
||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
|
||||
?? throw new InvalidOperationException("Sale not found for this order");
|
||||
|
||||
order.Status = request.Status;
|
||||
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||
|
||||
sale.Status = request.Status switch
|
||||
{
|
||||
OrderStatus.Fulfilled => SaleStatus.Completed,
|
||||
OrderStatus.Cancelled => SaleStatus.Cancelled,
|
||||
_ => SaleStatus.Pending
|
||||
};
|
||||
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Update(order);
|
||||
saleRepo.Update(sale);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
@@ -178,6 +119,7 @@ public class OrderService(
|
||||
await uow.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
var items = await repo.GetItemsByOrderIdAsync(id);
|
||||
return ToResponse(order, items);
|
||||
}
|
||||
@@ -187,10 +129,19 @@ public class OrderService(
|
||||
var order = await repo.GetByIdAsync(id);
|
||||
if (order is null || !order.IsActive) return false;
|
||||
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
|
||||
|
||||
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.Remove(order);
|
||||
if (sale is not null)
|
||||
{
|
||||
sale.Status = SaleStatus.Cancelled;
|
||||
sale.CompletedAt = null;
|
||||
saleRepo.Update(sale);
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public interface IPaymentRepository
|
||||
{
|
||||
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||
Task AddAsync(Models.Payment payment);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/sales/{saleId:guid}/payments")]
|
||||
public class PaymentController(PaymentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetBySale(Guid saleId)
|
||||
{
|
||||
var result = await service.GetBySaleIdAsync(saleId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(saleId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||
|
||||
public record PaymentResponse(
|
||||
Guid Id,
|
||||
Guid SaleId,
|
||||
Guid StoreId,
|
||||
decimal Amount,
|
||||
PaymentMethod Method,
|
||||
DateTime CollectedAt,
|
||||
Guid CollectedBy,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,17 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||
{
|
||||
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||
await context.Payments
|
||||
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||
.OrderBy(p => p.CollectedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.Payment payment) =>
|
||||
await context.Payments.AddAsync(payment);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
public class PaymentService(
|
||||
IPaymentRepository repo,
|
||||
ISaleRepository saleRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||
|
||||
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
|
||||
var payments = await repo.GetBySaleIdAsync(saleId);
|
||||
return payments.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
if (sale.Status == SaleStatus.Cancelled)
|
||||
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
|
||||
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
var payment = new Models.Payment
|
||||
{
|
||||
FK_SaleId = saleId,
|
||||
FK_StoreId = sale.FK_StoreId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
CollectedAt = DateTime.Now,
|
||||
CollectedBy = userId
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
var allPayments = await repo.GetBySaleIdAsync(saleId);
|
||||
var total = allPayments.Sum(p => p.Amount) + request.Amount;
|
||||
|
||||
sale.PaymentStatus = total == 0
|
||||
? PaymentStatus.Pending
|
||||
: total < sale.TotalAmount
|
||||
? PaymentStatus.PartiallyPaid
|
||||
: PaymentStatus.Paid;
|
||||
|
||||
saleRepo.Update(sale);
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Sale;
|
||||
|
||||
public interface ISaleRepository
|
||||
{
|
||||
Task<Models.Sale?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Sale>> GetAllAsync();
|
||||
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
||||
Task AddAsync(Models.Sale sale);
|
||||
void Update(Models.Sale sale);
|
||||
void Remove(Models.Sale sale);
|
||||
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Sale;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sales")]
|
||||
[Authorize]
|
||||
public class SaleController(SaleService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("store/{storeId:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||
{
|
||||
var result = await service.GetByStoreIdAsync(storeId);
|
||||
return result is null ? Forbid() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "orders.create")]
|
||||
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user