213 lines
8.7 KiB
C#
213 lines
8.7 KiB
C#
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<List<PriceListResponse>> 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<PriceListResponse?> GetByIdAsync(Guid id)
|
|
{
|
|
var pl = await repo.GetByIdAsync(id);
|
|
if (pl is null) return null;
|
|
return MapDetail(pl);
|
|
}
|
|
|
|
public async Task<PriceListResponse> 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<PriceListResponse?> 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<bool> 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<PriceListResponse?> 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<PriceListResponse?> 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))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public async Task<PriceResolution?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<PriceResolution?> 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);
|
|
}
|
|
}
|