Add pricing and supplier master data

This commit is contained in:
2026-08-20 17:28:55 +05:30
parent 7a2494c156
commit 63bc6e9cd8
26 changed files with 6138 additions and 9 deletions
@@ -0,0 +1,27 @@
namespace Distribution.Features.PriceList;
public interface IPriceListRepository
{
Task<Models.PriceList?> GetByIdAsync(Guid id);
Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null);
Task AddAsync(Models.PriceList entity);
void Update(Models.PriceList entity);
void Remove(Models.PriceList entity);
Task<Models.PriceListItem?> GetItemAsync(Guid priceListId, Guid itemId);
Task AddItemAsync(Models.PriceListItem entity);
void UpdateItem(Models.PriceListItem entity);
void RemoveItem(Models.PriceListItem entity);
/// <summary>
/// Active PriceListItem rows for the given item and region (region null = global lists only),
/// whose parent PriceList is active and effective on orderDate. Most recently created first.
/// </summary>
Task<List<Models.PriceListItem>> GetActiveItemsAsync(Guid itemId, Guid? regionId, DateTime orderDate);
/// <summary>
/// Active PriceListItem rows for itemId under any PriceList with the given RegionId (null = global),
/// whose date range overlaps [effectiveFrom, effectiveTo], excluding a given PriceList.
/// </summary>
Task<bool> HasOverlapAsync(Guid itemId, Guid? regionId, DateTime effectiveFrom, DateTime effectiveTo, Guid? excludePriceListId);
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
[Authorize]
public class PriceListController(PriceListService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreatePriceListRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdatePriceListRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/items/{sku}")]
public async Task<IActionResult> UpsertItem(Guid id, string sku, UpsertPriceListItemRequest request, CancellationToken ct)
{
try
{
var result = await service.UpsertItemAsync(id, sku, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}/items/{sku}")]
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
{
var result = await service.RemoveItemAsync(id, sku, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,25 @@
namespace Distribution.Features.PriceList;
public record CreatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
public record UpdatePriceListRequest(string Name, Guid? RegionId, DateTime EffectiveFrom, DateTime EffectiveTo);
public record UpsertPriceListItemRequest(decimal Price, decimal? MinPrice = null);
public record PriceListItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, decimal Price, decimal? MinPrice);
/// <summary>
/// A resolved price with its negotiable floor. MinPrice null = no negotiation, Price is fixed.
/// </summary>
public record PriceResolution(decimal Price, decimal? MinPrice);
public record PriceListResponse(
Guid Id,
string Name,
Guid? RegionId,
string? RegionName,
DateTime EffectiveFrom,
DateTime EffectiveTo,
bool IsActive,
DateTime CreatedAt,
List<PriceListItemResponse> Items);
@@ -0,0 +1,76 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.PriceList;
public class PriceListRepository(AppDBContext context) : IPriceListRepository
{
public async Task<Models.PriceList?> GetByIdAsync(Guid id) =>
await context.PriceLists
.Include(pl => pl.Region)
.Include(pl => pl.Items.Where(i => i.IsActive)).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(pl => pl.Id == id);
public async Task<List<Models.PriceList>> GetAllAsync(Guid? regionId = null)
{
var query = context.PriceLists
.Include(pl => pl.Region)
.Where(pl => pl.IsActive);
if (regionId.HasValue) query = query.Where(pl => pl.FK_RegionId == regionId);
return await query.ToListAsync();
}
public async Task AddAsync(Models.PriceList entity) =>
await context.PriceLists.AddAsync(entity);
public void Update(Models.PriceList entity) =>
context.PriceLists.Update(entity);
public void Remove(Models.PriceList entity)
{
entity.IsActive = false;
context.PriceLists.Update(entity);
}
public async Task<Models.PriceListItem?> GetItemAsync(Guid priceListId, Guid itemId) =>
await context.PriceListItems
.FirstOrDefaultAsync(i => i.FK_PriceListId == priceListId && i.FK_ItemId == itemId && i.IsActive);
public async Task AddItemAsync(Models.PriceListItem entity) =>
await context.PriceListItems.AddAsync(entity);
public void UpdateItem(Models.PriceListItem entity) =>
context.PriceListItems.Update(entity);
public void RemoveItem(Models.PriceListItem entity)
{
entity.IsActive = false;
context.PriceListItems.Update(entity);
}
public async Task<List<Models.PriceListItem>> GetActiveItemsAsync(Guid itemId, Guid? regionId, DateTime orderDate) =>
await context.PriceListItems
.Include(i => i.PriceList)
.Where(i => i.IsActive
&& i.FK_ItemId == itemId
&& i.PriceList.IsActive
&& i.PriceList.FK_RegionId == regionId
&& i.PriceList.EffectiveFrom <= orderDate
&& i.PriceList.EffectiveTo >= orderDate)
.OrderByDescending(i => i.PriceList.CreatedAt)
.ToListAsync();
public async Task<bool> HasOverlapAsync(Guid itemId, Guid? regionId, DateTime effectiveFrom, DateTime effectiveTo, Guid? excludePriceListId) =>
await context.PriceListItems
.Include(i => i.PriceList)
.Where(i => i.IsActive
&& i.FK_ItemId == itemId
&& i.PriceList.IsActive
&& i.PriceList.FK_RegionId == regionId
&& (excludePriceListId == null || i.FK_PriceListId != excludePriceListId)
&& i.PriceList.EffectiveFrom <= effectiveTo
&& i.PriceList.EffectiveTo >= effectiveFrom)
.AnyAsync();
}
@@ -0,0 +1,171 @@
using Distribution.Features.Item;
using Distribution.Infra.UoW;
namespace Distribution.Features.PriceList;
public class PriceListService(IPriceListRepository repo, IItemRepository itemRepo, 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);
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 || !pl.IsActive) 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 || !priceList.IsActive) 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;
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 || !priceList.IsActive) return null;
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 (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
});
}
else
{
existing.Price = request.Price;
existing.MinPrice = request.MinPrice;
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 || !priceList.IsActive) return null;
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) return new PriceResolution(regionMatches[0].Price, regionMatches[0].MinPrice);
}
var globalMatches = await repo.GetActiveItemsAsync(itemId, null, orderDate);
if (globalMatches.Count > 0) return new PriceResolution(globalMatches[0].Price, globalMatches[0].MinPrice);
return itemSalePrice.HasValue ? new PriceResolution(itemSalePrice.Value, null) : null;
}
}