using Distribution.Features.Item; using Distribution.Features.Store; using Distribution.Infra.UoW; namespace Distribution.Features.PriceList; public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, IStoreRepository storeRepo, IUnitOfWork uow) { private static PriceListItemResponse MapItem(Models.PriceListItem i) => new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Price, i.MinPrice, i.RetailPrice, i.AgencyMarginPercent); private static PriceListResponse MapDetail(Models.PriceList pl) => new(pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name, pl.EffectiveFrom, pl.EffectiveTo, pl.IsActive, pl.CreatedAt, pl.Items.Where(i => i.IsActive).Select(MapItem).ToList()); public async Task> GetAllAsync(Guid? regionId = null) { var lists = await repo.GetAllAsync(regionId); return lists.Select(pl => new PriceListResponse( pl.Id, pl.Name, pl.FK_RegionId, pl.Region?.Name, pl.EffectiveFrom, pl.EffectiveTo, pl.IsActive, pl.CreatedAt, [])).ToList(); } public async Task GetByIdAsync(Guid id) { var pl = await repo.GetByIdAsync(id); if (pl is null) return null; return MapDetail(pl); } public async Task CreateAsync(CreatePriceListRequest request, CancellationToken ct = default) { var priceList = new Models.PriceList { Name = request.Name, FK_RegionId = request.RegionId, EffectiveFrom = request.EffectiveFrom, EffectiveTo = request.EffectiveTo }; await uow.BeginAsync(ct); try { await repo.AddAsync(priceList); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return (await GetByIdAsync(priceList.Id))!; } public async Task UpdateAsync(Guid id, UpdatePriceListRequest request, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(id); if (priceList is null) return null; foreach (var item in priceList.Items.Where(i => i.IsActive)) { if (await repo.HasOverlapAsync(item.FK_ItemId, request.RegionId, request.EffectiveFrom, request.EffectiveTo, id)) throw new InvalidOperationException( $"Overlapping price list already exists for item '{item.Item.Sku}' in the same region for the requested date range."); } priceList.Name = request.Name; priceList.FK_RegionId = request.RegionId; priceList.EffectiveFrom = request.EffectiveFrom; priceList.EffectiveTo = request.EffectiveTo; priceList.IsActive = request.IsActive; await uow.BeginAsync(ct); try { repo.Update(priceList); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return (await GetByIdAsync(id))!; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(id); if (priceList is null || !priceList.IsActive) return false; await uow.BeginAsync(ct); try { foreach (var item in priceList.Items.Where(i => i.IsActive)) repo.RemoveItem(item); repo.Remove(priceList); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return true; } public async Task UpsertItemAsync(Guid priceListId, string sku, UpsertPriceListItemRequest request, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(priceListId); if (priceList is null) return null; if (!priceList.IsActive) throw new InvalidOperationException("Cannot price items on an inactive price list. Reactivate it first."); var item = await itemRepo.GetBySkuAsync(sku); if (item is null) return null; if (request.MinPrice.HasValue && request.MinPrice.Value > request.Price) throw new InvalidOperationException( $"MinPrice ({request.MinPrice}) cannot exceed Price ({request.Price}) for item '{sku}'."); if (request.RetailPrice.HasValue && request.RetailPrice.Value < request.Price) throw new InvalidOperationException( $"RetailPrice ({request.RetailPrice}) cannot be less than Price ({request.Price}) for item '{sku}' — the store would sell at a loss."); if (request.AgencyMarginPercent is < 0 or > 100) throw new InvalidOperationException("AgencyMarginPercent must be between 0 and 100."); if (await repo.HasOverlapAsync(item.Id, priceList.FK_RegionId, priceList.EffectiveFrom, priceList.EffectiveTo, priceListId)) throw new InvalidOperationException( $"Overlapping price list already exists for item '{sku}' in the same region for this date range."); var existing = await repo.GetItemAsync(priceListId, item.Id); await uow.BeginAsync(ct); try { if (existing is null) { await repo.AddItemAsync(new Models.PriceListItem { FK_PriceListId = priceListId, FK_ItemId = item.Id, Price = request.Price, MinPrice = request.MinPrice, RetailPrice = request.RetailPrice, AgencyMarginPercent = request.AgencyMarginPercent }); } else { existing.Price = request.Price; existing.MinPrice = request.MinPrice; existing.RetailPrice = request.RetailPrice; existing.AgencyMarginPercent = request.AgencyMarginPercent; repo.UpdateItem(existing); } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return (await GetByIdAsync(priceListId))!; } public async Task RemoveItemAsync(Guid priceListId, string sku, CancellationToken ct = default) { var priceList = await repo.GetByIdAsync(priceListId); if (priceList is null) return null; if (!priceList.IsActive) throw new InvalidOperationException("Cannot remove items from an inactive price list. Reactivate it first."); var item = await itemRepo.GetBySkuAsync(sku); if (item is null) return null; var existing = await repo.GetItemAsync(priceListId, item.Id); if (existing is null) return null; await uow.BeginAsync(ct); try { repo.RemoveItem(existing); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return (await GetByIdAsync(priceListId))!; } /// /// applyPricingAndDiscounts (price-list half): region-scoped active price list -> global /// active price list -> Item.SalePrice -> null if none apply. The returned PriceResolution /// carries MinPrice so the caller can validate a negotiated UnitPrice against the band; /// the Item.SalePrice fallback has no MinPrice (fixed, non-negotiable). /// public async Task ResolvePriceAsync(Guid itemId, Guid? regionId, decimal? itemSalePrice, DateTime orderDate) { if (regionId.HasValue) { var regionMatches = await repo.GetActiveItemsAsync(itemId, regionId, orderDate); if (regionMatches.Count > 0) { var m = regionMatches[0]; return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent); } } var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate); if (globalMatches.Count > 0) { var m = globalMatches[0]; return new PriceResolution(m.Price, m.MinPrice, m.RetailPrice, m.AgencyMarginPercent); } return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null, null, null) : null; } /// /// Same resolution `SaleService.ResolveUnitPriceAsync` uses internally, exposed read-only so /// the frontend can show the real price (region/date-scoped price list, else Item.SalePrice) /// before a sale is ever submitted, instead of guessing from the flat catalogue price. /// public async Task ResolveByStoreAsync(Guid itemId, Guid storeId, DateTime saleDate) { var item = await itemRepo.GetByIdAsync(itemId); if (item is null) return null; var store = await storeRepo.GetByIdWithRegionAsync(storeId); if (store is null) return null; return await ResolvePriceAsync(itemId, store.Route?.FK_RegionId, item.SalePrice, saleDate); } }