275 lines
11 KiB
C#
275 lines
11 KiB
C#
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<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
|
|
{
|
|
IEnumerable<Guid>? storeIds = null;
|
|
if (!HasGlobalScope && currentUser.UserId is not null)
|
|
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
|
|
|
|
var total = await repo.GetCountAsync(rideId, storeIds);
|
|
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
|
|
|
|
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 new PagedResponse<SaleResponse>(page, pageSize, total, 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?> CancelAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
// TEMPORARY: see CreateAsync — store-scope enforcement depends on the broken auth
|
|
// middleware (testbug.md #28) and would reject every cancel. Skipped for now.
|
|
var sale = await repo.GetByIdAsync(id);
|
|
if (sale is null || !sale.IsActive) return null;
|
|
if (sale.Status == SaleStatus.Cancelled)
|
|
throw new InvalidOperationException("Sale is already cancelled");
|
|
|
|
sale.Status = SaleStatus.Cancelled;
|
|
|
|
await uow.BeginAsync(ct);
|
|
try { repo.Update(sale); await uow.CommitAsync(ct); }
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
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<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
|
{
|
|
// TEMPORARY: store-scope enforcement and currentUser resolution both depend on the broken
|
|
// auth middleware (see testbug.md #28) — currentUser.UserId/RoleLevel are always unset, so
|
|
// both checks would reject every sale. Skipped for now; restore once auth is fixed.
|
|
var userId = currentUser.UserId ?? request.CreatedByUserId
|
|
?? throw new InvalidOperationException("CreatedByUserId is required while auth is unavailable");
|
|
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId)
|
|
?? throw new InvalidOperationException("Store not found");
|
|
if (!store.IsActive)
|
|
throw new InvalidOperationException("Store is inactive");
|
|
|
|
if (store.KycStatus != KycStatus.Verified)
|
|
{
|
|
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
|
if (request.TotalAmount > threshold)
|
|
throw new InvalidOperationException(
|
|
$"Store KYC is not verified — sale amount ({request.TotalAmount}) exceeds the unverified-store threshold ({threshold})");
|
|
}
|
|
|
|
var sale = new Models.Sale
|
|
{
|
|
FK_StoreId = request.StoreId,
|
|
SaleDate = request.SaleDate,
|
|
CreatedByUserId = userId,
|
|
FK_RideId = request.RideId,
|
|
FulfillImmediately = request.FulfillImmediately
|
|
};
|
|
|
|
var orderItems = new List<Models.OrderItem>();
|
|
foreach (var item in request.Items)
|
|
{
|
|
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate)
|
|
?? throw new InvalidOperationException(
|
|
$"Could not resolve a price for item {item.ItemId} on {request.SaleDate:yyyy-MM-dd} — check the item exists, an active price list covers this date/region, and any supplied unit price is within its min/max range");
|
|
|
|
orderItems.Add(new Models.OrderItem
|
|
{
|
|
FK_ItemId = item.ItemId,
|
|
Quantity = item.Quantity,
|
|
UnitPrice = unitPrice
|
|
});
|
|
}
|
|
|
|
var order = new Models.Order
|
|
{
|
|
FK_SaleId = sale.Id,
|
|
FK_StoreId = request.StoreId,
|
|
OrderDate = request.SaleDate,
|
|
CreatedByUserId = userId,
|
|
};
|
|
|
|
foreach (var orderItem in orderItems)
|
|
orderItem.FK_OrderId = order.Id;
|
|
|
|
// For a van sale against a specific ride, sell whatever quantity is actually on that
|
|
// vehicle instead of rejecting the whole sale over one short item — the sale is always a
|
|
// clean, completed sale of what's handed over, never a "partially fulfilled" order.
|
|
Guid? vehicleId = null;
|
|
if (request.FulfillImmediately && request.RideId.HasValue)
|
|
{
|
|
var ride = await rideRepo.GetByIdAsync(request.RideId.Value)
|
|
?? throw new InvalidOperationException("Ride not found");
|
|
vehicleId = ride.FK_VehicleId;
|
|
|
|
foreach (var item in orderItems)
|
|
{
|
|
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
|
|
item.Quantity = Math.Min(item.Quantity, (int)(balance?.Quantity ?? 0));
|
|
}
|
|
}
|
|
|
|
foreach (var item in orderItems)
|
|
item.QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0;
|
|
|
|
if (request.FulfillImmediately)
|
|
{
|
|
sale.Status = SaleStatus.Completed;
|
|
sale.CompletedAt = DateTime.Now;
|
|
order.Status = OrderStatus.Fulfilled;
|
|
order.FulfilledAt = DateTime.Now;
|
|
}
|
|
else
|
|
{
|
|
sale.Status = SaleStatus.Pending;
|
|
order.Status = OrderStatus.Pending;
|
|
}
|
|
|
|
sale.TotalAmount = orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
|
order.TotalAmount = sale.TotalAmount;
|
|
|
|
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.Where(i => i.QuantityFulfilled > 0))
|
|
{
|
|
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
|
|
balance!.Quantity -= item.QuantityFulfilled;
|
|
balance.UpdatedAt = DateTime.Now;
|
|
stockRepo.UpdateBalance(balance);
|
|
|
|
await stockRepo.AddMovementAsync(new StockMovement
|
|
{
|
|
FK_ItemId = item.FK_ItemId,
|
|
FromLocationId = vehicleId.Value,
|
|
FromLocationType = LocationType.Vehicle,
|
|
ToLocationId = StockService.ExternalLocationId,
|
|
ToLocationType = LocationType.External,
|
|
Quantity = item.QuantityFulfilled,
|
|
Type = MovementType.Sale,
|
|
ReferenceId = order.Id
|
|
});
|
|
}
|
|
}
|
|
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await uow.RollbackAsync(ct);
|
|
throw;
|
|
}
|
|
|
|
return ToResponse(sale, order, orderItems);
|
|
}
|
|
}
|