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 HasStoreScopeAsync(Guid storeId) { if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope; return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId); } private async Task 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 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> GetAllAsync(Guid? rideId, int page, int pageSize) { IEnumerable? 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(); 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(page, pageSize, total, result); } public async Task 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?> GetByStoreIdAsync(Guid storeId) { if (!await HasStoreScopeAsync(storeId)) return null; var sales = await repo.GetByStoreIdAsync(storeId); var result = new List(); 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 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 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("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(); 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, }; 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); } }