Merge pull request 'Feat/pricing & supplier' (#5) from feat/pricing_&_supplier into dev
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -30,6 +30,9 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<StockBalance> StockBalances { get; set; }
|
public DbSet<StockBalance> StockBalances { get; set; }
|
||||||
public DbSet<StockMovement> StockMovements { get; set; }
|
public DbSet<StockMovement> StockMovements { get; set; }
|
||||||
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
|
public DbSet<StoreKycDocument> StoreKycDocuments { get; set; }
|
||||||
|
public DbSet<PriceList> PriceLists { get; set; }
|
||||||
|
public DbSet<PriceListItem> PriceListItems { get; set; }
|
||||||
|
public DbSet<Supplier> Suppliers { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -58,6 +61,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
|
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<PriceListItem>()
|
||||||
|
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace Distribution.Features.Item;
|
|||||||
public interface IItemRepository
|
public interface IItemRepository
|
||||||
{
|
{
|
||||||
Task<Models.Item?> GetByIdAsync(Guid id);
|
Task<Models.Item?> GetByIdAsync(Guid id);
|
||||||
|
Task<Models.Item?> GetBySkuAsync(string sku);
|
||||||
Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null);
|
Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null);
|
||||||
Task<bool> SkuExistsAsync(string sku, Guid? excludeId = null);
|
Task<bool> SkuExistsAsync(string sku, Guid? excludeId = null);
|
||||||
Task<Models.Item?> GetByExternalErpItemIdAsync(int erpId);
|
Task<Models.Item?> GetByExternalErpItemIdAsync(int erpId);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public class ItemRepository(AppDBContext context) : IItemRepository
|
|||||||
.Include(i => i.Conversions).ThenInclude(c => c.ToUom)
|
.Include(i => i.Conversions).ThenInclude(c => c.ToUom)
|
||||||
.FirstOrDefaultAsync(i => i.Id == id);
|
.FirstOrDefaultAsync(i => i.Id == id);
|
||||||
|
|
||||||
|
public async Task<Models.Item?> GetBySkuAsync(string sku) =>
|
||||||
|
await context.Items.FirstOrDefaultAsync(i => i.Sku == sku && i.IsActive);
|
||||||
|
|
||||||
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
|
public async Task<List<Models.Item>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null)
|
||||||
{
|
{
|
||||||
var query = context.Items
|
var query = context.Items
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
public record OrderItemRequest(Guid ItemId, int Quantity);
|
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
|
||||||
|
|
||||||
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
|
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
|
||||||
|
|
||||||
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
|
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
|
||||||
|
|
||||||
public record OrderItemResponse(Guid ItemId, int Quantity);
|
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
|
||||||
|
|
||||||
public record OrderResponse(
|
public record OrderResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
@@ -9,6 +11,8 @@ namespace Distribution.Features.Order;
|
|||||||
public class OrderService(
|
public class OrderService(
|
||||||
IOrderRepository repo,
|
IOrderRepository repo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
|
IItemRepository itemRepo,
|
||||||
|
PriceListService priceListService,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IScopeAccessService scopeAccess,
|
IScopeAccessService scopeAccess,
|
||||||
@@ -18,7 +22,7 @@ public class OrderService(
|
|||||||
|
|
||||||
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
|
||||||
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
|
||||||
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity)).ToList(),
|
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
|
||||||
o.IsActive, o.CreatedAt);
|
o.IsActive, o.CreatedAt);
|
||||||
|
|
||||||
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
|
||||||
@@ -29,6 +33,29 @@ public class OrderService(
|
|||||||
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
|
||||||
|
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
|
||||||
|
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
|
||||||
|
/// can be resolved, or if the requested price is outside the allowed band.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
|
||||||
|
{
|
||||||
|
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, orderDate);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<OrderResponse>> GetAllAsync()
|
public async Task<List<OrderResponse>> GetAllAsync()
|
||||||
{
|
{
|
||||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
||||||
@@ -72,7 +99,7 @@ public class OrderService(
|
|||||||
{
|
{
|
||||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||||
|
|
||||||
var store = await storeRepo.GetByIdAsync(request.StoreId);
|
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
|
||||||
if (store is null || !store.IsActive) return null;
|
if (store is null || !store.IsActive) return null;
|
||||||
if (store.KycStatus != KycStatus.Verified)
|
if (store.KycStatus != KycStatus.Verified)
|
||||||
{
|
{
|
||||||
@@ -86,12 +113,20 @@ public class OrderService(
|
|||||||
OrderDate = request.OrderDate,
|
OrderDate = request.OrderDate,
|
||||||
TotalAmount = request.TotalAmount
|
TotalAmount = request.TotalAmount
|
||||||
};
|
};
|
||||||
var orderItems = request.Items.Select(i => new Models.OrderItem
|
var orderItems = new List<Models.OrderItem>();
|
||||||
|
foreach (var i in request.Items)
|
||||||
{
|
{
|
||||||
FK_OrderId = order.Id,
|
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
||||||
FK_ItemId = i.ItemId,
|
if (unitPrice is null) return null;
|
||||||
Quantity = i.Quantity
|
|
||||||
}).ToList();
|
orderItems.Add(new Models.OrderItem
|
||||||
|
{
|
||||||
|
FK_OrderId = order.Id,
|
||||||
|
FK_ItemId = i.ItemId,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitPrice = unitPrice.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ namespace Distribution.Features.Store;
|
|||||||
public interface IStoreRepository
|
public interface IStoreRepository
|
||||||
{
|
{
|
||||||
Task<Models.Store?> GetByIdAsync(Guid id);
|
Task<Models.Store?> GetByIdAsync(Guid id);
|
||||||
|
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
|
||||||
Task<List<Models.Store>> GetAllAsync();
|
Task<List<Models.Store>> GetAllAsync();
|
||||||
Task AddAsync(Models.Store entity);
|
Task AddAsync(Models.Store entity);
|
||||||
void Update(Models.Store entity);
|
void Update(Models.Store entity);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
|
|||||||
public async Task<Models.Store?> GetByIdAsync(Guid id) =>
|
public async Task<Models.Store?> GetByIdAsync(Guid id) =>
|
||||||
await context.Stores.FindAsync(id);
|
await context.Stores.FindAsync(id);
|
||||||
|
|
||||||
|
public async Task<Models.Store?> GetByIdWithRegionAsync(Guid id) =>
|
||||||
|
await context.Stores
|
||||||
|
.Include(s => s.Route).ThenInclude(r => r.Region)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id);
|
||||||
|
|
||||||
public async Task<List<Models.Store>> GetAllAsync() =>
|
public async Task<List<Models.Store>> GetAllAsync() =>
|
||||||
await context.Stores.ToListAsync();
|
await context.Stores.ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
|
public interface ISupplierRepository
|
||||||
|
{
|
||||||
|
Task<Models.Supplier?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Supplier>> GetAllAsync();
|
||||||
|
Task AddAsync(Models.Supplier entity);
|
||||||
|
void Update(Models.Supplier entity);
|
||||||
|
void Remove(Models.Supplier entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/suppliers")]
|
||||||
|
[Authorize]
|
||||||
|
public class SupplierController(SupplierService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll() =>
|
||||||
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
|
[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(CreateSupplierRequest 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, UpdateSupplierRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
return deleted ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
|
public record CreateSupplierRequest(string Name, string? ContactInfo);
|
||||||
|
|
||||||
|
public record UpdateSupplierRequest(string Name, string? ContactInfo);
|
||||||
|
|
||||||
|
public record SupplierResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? ContactInfo,
|
||||||
|
decimal? PerformanceScore,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
|
public class SupplierRepository(AppDBContext context) : ISupplierRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.Supplier?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Suppliers.FindAsync(id);
|
||||||
|
|
||||||
|
public async Task<List<Models.Supplier>> GetAllAsync() =>
|
||||||
|
await context.Suppliers.Where(s => s.IsActive).ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.Supplier entity) =>
|
||||||
|
await context.Suppliers.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.Supplier entity) =>
|
||||||
|
context.Suppliers.Update(entity);
|
||||||
|
|
||||||
|
public void Remove(Models.Supplier entity)
|
||||||
|
{
|
||||||
|
entity.IsActive = false;
|
||||||
|
context.Suppliers.Update(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
|
public class SupplierService(ISupplierRepository repo, IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static SupplierResponse Map(Models.Supplier s) =>
|
||||||
|
new(s.Id, s.Name, s.ContactInfo, s.PerformanceScore, s.IsActive, s.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<List<SupplierResponse>> GetAllAsync() =>
|
||||||
|
(await repo.GetAllAsync()).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<SupplierResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var supplier = await repo.GetByIdAsync(id);
|
||||||
|
if (supplier is null || !supplier.IsActive) return null;
|
||||||
|
return Map(supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SupplierResponse> CreateAsync(CreateSupplierRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var supplier = new Models.Supplier
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
ContactInfo = request.ContactInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(supplier); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SupplierResponse?> UpdateAsync(Guid id, UpdateSupplierRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var supplier = await repo.GetByIdAsync(id);
|
||||||
|
if (supplier is null || !supplier.IsActive) return null;
|
||||||
|
|
||||||
|
supplier.Name = request.Name;
|
||||||
|
supplier.ContactInfo = request.ContactInfo;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(supplier); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var supplier = await repo.GetByIdAsync(id);
|
||||||
|
if (supplier is null || !supplier.IsActive) return false;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Remove(supplier); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPricingAndSuppliers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PriceLists",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Name = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
|
||||||
|
EffectiveFrom = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
EffectiveTo = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PriceLists", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PriceLists_Regions_FK_RegionId",
|
||||||
|
column: x => x.FK_RegionId,
|
||||||
|
principalTable: "Regions",
|
||||||
|
principalColumn: "Id");
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Suppliers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Name = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
ContactInfo = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
PerformanceScore = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Suppliers", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PriceListItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PriceListId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Price = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PriceListItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PriceListItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PriceListItems_PriceLists_FK_PriceListId",
|
||||||
|
column: x => x.FK_PriceListId,
|
||||||
|
principalTable: "PriceLists",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PriceListItems_FK_ItemId",
|
||||||
|
table: "PriceListItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PriceListItems_FK_PriceListId_FK_ItemId",
|
||||||
|
table: "PriceListItems",
|
||||||
|
columns: new[] { "FK_PriceListId", "FK_ItemId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PriceLists_FK_RegionId",
|
||||||
|
table: "PriceLists",
|
||||||
|
column: "FK_RegionId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PriceListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Suppliers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PriceLists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPriceListItemMinPrice : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "MinPrice",
|
||||||
|
table: "PriceListItems",
|
||||||
|
type: "decimal(65,30)",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MinPrice",
|
||||||
|
table: "PriceListItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -571,6 +571,72 @@ namespace Distribution.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EffectiveFrom")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EffectiveTo")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FK_RegionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_RegionId");
|
||||||
|
|
||||||
|
b.ToTable("PriceLists");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PriceListItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PriceListId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("MinPrice")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PriceListId", "FK_ItemId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("PriceListItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Region", b =>
|
modelBuilder.Entity("Distribution.Models.Region", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1973,6 +2039,33 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("SubCategories");
|
b.ToTable("SubCategories");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.Supplier", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("ContactInfo")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<decimal?>("PerformanceScore")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Suppliers");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Uom", b =>
|
modelBuilder.Entity("Distribution.Models.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2281,6 +2374,34 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Order");
|
b.Navigation("Order");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Region", "Region")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_RegionId");
|
||||||
|
|
||||||
|
b.Navigation("Region");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PriceListItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.PriceList", "PriceList")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_PriceListId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("PriceList");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.RolePermission", b =>
|
modelBuilder.Entity("Distribution.Models.RolePermission", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Permission", "Permission")
|
b.HasOne("Distribution.Models.Permission", "Permission")
|
||||||
@@ -2481,6 +2602,11 @@ namespace Distribution.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Conversions");
|
b.Navigation("Conversions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PriceList", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class PriceList : Template
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
public Guid? FK_RegionId { get; set; }
|
||||||
|
[ForeignKey("FK_RegionId")]
|
||||||
|
public Region? Region { get; set; }
|
||||||
|
|
||||||
|
public DateTime EffectiveFrom { get; set; }
|
||||||
|
public DateTime EffectiveTo { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PriceListItem> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class PriceListItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_PriceListId { get; set; }
|
||||||
|
[ForeignKey("FK_PriceListId")]
|
||||||
|
public PriceList PriceList { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Floor price for negotiation. Null = no negotiation allowed, Price is fixed.
|
||||||
|
/// Must be less than or equal to Price when set.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MinPrice { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class Supplier : Template
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string? ContactInfo { get; set; }
|
||||||
|
public decimal? PerformanceScore { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,11 +5,13 @@ using Distribution.Features.Brand;
|
|||||||
using Distribution.Features.Category;
|
using Distribution.Features.Category;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.Order;
|
using Distribution.Features.Order;
|
||||||
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Region;
|
using Distribution.Features.Region;
|
||||||
using Distribution.Features.Role;
|
using Distribution.Features.Role;
|
||||||
using Distribution.Features.Route;
|
using Distribution.Features.Route;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
using Distribution.Features.Store.Kyc;
|
using Distribution.Features.Store.Kyc;
|
||||||
|
using Distribution.Features.Supplier;
|
||||||
using Distribution.Features.Sync;
|
using Distribution.Features.Sync;
|
||||||
using Distribution.Features.Uom;
|
using Distribution.Features.Uom;
|
||||||
using Distribution.Features.User;
|
using Distribution.Features.User;
|
||||||
@@ -48,6 +50,8 @@ builder.Services.AddScoped<IUomRepository, UomRepository>();
|
|||||||
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
||||||
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
||||||
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
|
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
|
||||||
|
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
|
||||||
|
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
|
||||||
|
|
||||||
builder.Services.AddScoped<RegionService>();
|
builder.Services.AddScoped<RegionService>();
|
||||||
builder.Services.AddScoped<RouteService>();
|
builder.Services.AddScoped<RouteService>();
|
||||||
@@ -65,6 +69,8 @@ builder.Services.AddScoped<ItemService>();
|
|||||||
builder.Services.AddScoped<SyncService>();
|
builder.Services.AddScoped<SyncService>();
|
||||||
builder.Services.AddScoped<StockService>();
|
builder.Services.AddScoped<StockService>();
|
||||||
builder.Services.AddScoped<StoreKycDocumentService>();
|
builder.Services.AddScoped<StoreKycDocumentService>();
|
||||||
|
builder.Services.AddScoped<PriceListService>();
|
||||||
|
builder.Services.AddScoped<SupplierService>();
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 14 — Pricing & Supplier Master Data
|
# 14 — Pricing & Supplier Master Data
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done — migration applied to the database
|
||||||
**Depends On**: 01 — Item Catalog
|
**Depends On**: 01 — Item Catalog (Done)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,56 +15,339 @@ records. `createProduct`/`deactivateSku` live in 01; this step is everything els
|
|||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
`Item.BasePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat price. There is
|
`Item.SalePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat, optional price
|
||||||
no concept of a region- or date-scoped price list, and no `Supplier` entity anywhere in the schema.
|
— `OrderItem.UnitPrice` is captured from it at order-creation time, or supplied manually if
|
||||||
|
`SalePrice` is null. There is no concept of a region- or date-scoped price list, and no `Supplier`
|
||||||
|
entity anywhere in the schema.
|
||||||
|
|
||||||
|
`applyPricingAndDiscounts` ([mainPlan.md §1](./mainPlan.md#1-order-management-lifecycle)) is the
|
||||||
|
function that resolves the actual price for an order line; this step implements the price-list half
|
||||||
|
of it (region + date scoping). The discount/scheme half stays out of scope until
|
||||||
|
[17_promotions_schemes.md](./17_promotions_schemes.md) lands — until then `applyPricingAndDiscounts`
|
||||||
|
only walks price lists, never scheme discounts.
|
||||||
|
|
||||||
|
Resolved in planning discussion (2026-08-20): price lists are **region-only in v1**, not
|
||||||
|
segment-scoped. `Store.Segment` ([12_store_management.md](./12_store_management.md)) is not
|
||||||
|
consulted by pricing. Segment-based pricing can be added as a later step without breaking this
|
||||||
|
model, since it would only add an optional new scoping column.
|
||||||
|
|
||||||
|
### Negotiable price range (Min/Max)
|
||||||
|
|
||||||
|
An item's price within a price list can float in a band instead of being fixed: the same actor who
|
||||||
|
creates the price list (`pricing.create`/`pricing.update`, AreaManager and above) sets both a
|
||||||
|
**ceiling** (`Price` — the list/default price) and an optional **floor** (`MinPrice`) below which no
|
||||||
|
order for that item may be priced. The sales rep/driver placing the order can then supply any
|
||||||
|
`UnitPrice` within `[MinPrice, Price]` on `OrderItemRequest` instead of always taking the resolved
|
||||||
|
default — the same negotiation-boundary pattern already used for discount limits in
|
||||||
|
[08_sales_agent_controls.md](./08_sales_agent_controls.md).
|
||||||
|
|
||||||
|
- `PriceListItem.MinPrice (decimal?)` — `null` = no negotiation, the item is fixed at `Price`.
|
||||||
|
- `PUT /api/price-lists/{id}/items/{sku}` accepts `{ "price": ..., "minPrice": ... }`; rejected with
|
||||||
|
409 if `MinPrice > Price`.
|
||||||
|
- `OrderService.CreateAsync` validates any caller-supplied `OrderItemRequest.UnitPrice` against
|
||||||
|
`[MinPrice ?? Price, Price]` for that item's resolved price (region-scoped list → global list;
|
||||||
|
the `Item.SalePrice` fallback has no `MinPrice` and is always fixed). The order is rejected
|
||||||
|
(400, via a null resolution) if the requested price falls outside the band, or if no price can be
|
||||||
|
resolved at all.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### Price List
|
### PriceList
|
||||||
|
```
|
||||||
|
PriceList
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── Name (string)
|
||||||
|
├── FK_RegionId (Guid?) — nullable, null = global (applies to all regions)
|
||||||
|
├── EffectiveFrom (DateTime)
|
||||||
|
├── EffectiveTo (DateTime)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
`Name`, `RegionId` (nullable — null means global), `EffectiveFrom`, `EffectiveTo`.
|
### PriceListItem
|
||||||
|
```
|
||||||
### Price List Item
|
PriceListItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
`PriceListId`, `ItemId`, `Price` — overrides `Item.BasePrice` when an applicable, currently-
|
├── FK_PriceListId (Guid) — required
|
||||||
effective price list exists for the order's region.
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Price (decimal) — ceiling/list price, overrides Item.SalePrice for this item while
|
||||||
|
│ the parent PriceList is active and applicable to the order's region
|
||||||
|
├── MinPrice (decimal?) — nullable floor for negotiated pricing; null = Price is fixed.
|
||||||
|
│ Must be <= Price when set.
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
Unique constraint on `(PriceListId, ItemId)` — one price per item per price list.
|
||||||
|
|
||||||
### Supplier
|
### Supplier
|
||||||
|
```
|
||||||
|
Supplier
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── Name (string)
|
||||||
|
├── ContactInfo (string?) — free text (phone/email/address), matches ContactInfo pattern
|
||||||
|
│ used elsewhere rather than splitting into separate columns
|
||||||
|
├── PerformanceScore (decimal?) — nullable, populated by 15_procurement.md's evaluateSupplier;
|
||||||
|
│ this step only defines the column and leaves it null-only
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
`Name`, `ContactInfo`, `PerformanceScore` (nullable, populated by
|
---
|
||||||
[15_procurement.md](./15_procurement.md)'s `evaluateSupplier`).
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
One migration needed:
|
||||||
|
1. **AddPricingAndSuppliers** — creates `PriceLists`, `PriceListItems`, `Suppliers` tables.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
> Permission column names the target policy for each route; enforcement is **not implemented yet**
|
||||||
|---|---|
|
> — see [Permissions to Add to Seed](#permissions-to-add-to-seed). Endpoints require
|
||||||
| `createPriceList(name, region, effectiveDates)` | `POST /api/price-lists` |
|
> authentication only until seeding is turned on.
|
||||||
| `updateProductPricing(sku, priceListId, price)` | `PUT /api/price-lists/{id}/items/{sku}` |
|
|
||||||
| `createSupplierMaster(supplierData)` | `POST /api/suppliers` |
|
### Price Lists
|
||||||
|
| Method | Route | Permission | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | /api/price-lists | pricing.view | Filterable by region, active-on-date |
|
||||||
|
| GET | /api/price-lists/{id} | pricing.view | Includes items |
|
||||||
|
| POST | /api/price-lists | pricing.create | `createPriceList(name, region, effectiveDates)` |
|
||||||
|
| PUT | /api/price-lists/{id} | pricing.update | Rejects if new dates overlap another price list for any shared `(ItemId, RegionId)` pair |
|
||||||
|
| DELETE | /api/price-lists/{id} | pricing.delete | Soft delete |
|
||||||
|
| PUT | /api/price-lists/{id}/items/{sku} | pricing.update | `updateProductPricing(sku, priceListId, price)` — upserts the `PriceListItem` row |
|
||||||
|
| DELETE | /api/price-lists/{id}/items/{sku} | pricing.update | Removes an item's override from the price list |
|
||||||
|
|
||||||
|
### Suppliers
|
||||||
|
| Method | Route | Permission | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | /api/suppliers | suppliers.view | |
|
||||||
|
| GET | /api/suppliers/{id} | suppliers.view | |
|
||||||
|
| POST | /api/suppliers | suppliers.create | `createSupplierMaster(supplierData)` |
|
||||||
|
| PUT | /api/suppliers/{id} | suppliers.update | Cannot set `PerformanceScore` directly — that field is read-only via this endpoint, only 15_procurement.md's `evaluateSupplier` writes it |
|
||||||
|
| DELETE | /api/suppliers/{id} | suppliers.delete | Soft delete, blocked if any PurchaseOrder references it (enforced once 15_procurement.md exists; no-op check until then) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / Response Shapes
|
||||||
|
|
||||||
|
### POST `/api/price-lists`
|
||||||
|
```json
|
||||||
|
{ "name": "North Region Q3", "regionId": "...", "effectiveFrom": "2026-09-01", "effectiveTo": "2026-09-30" }
|
||||||
|
```
|
||||||
|
`regionId` omitted or null = global price list.
|
||||||
|
|
||||||
|
### PUT `/api/price-lists/{id}/items/{sku}`
|
||||||
|
```json
|
||||||
|
{ "price": 125.50, "minPrice": 115.00 }
|
||||||
|
```
|
||||||
|
Resolves `sku` to `ItemId`, upserts the `PriceListItem` row. `minPrice` is optional — omit or send
|
||||||
|
`null` for a fixed, non-negotiable price. Rejected with 409 if `minPrice > price`.
|
||||||
|
|
||||||
|
### POST `/api/suppliers`
|
||||||
|
```json
|
||||||
|
{ "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" }
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- `applyPricingAndDiscounts` (§1, used in [01_item_catalog.md](./01_item_catalog.md) and
|
1. `applyPricingAndDiscounts` resolves the unit price for an order line in this order:
|
||||||
[06_sale_ride.md](./06_sale_ride.md)) resolves price in this order: region-scoped active price
|
region-scoped active price list → global active price list → `Item.SalePrice` → reject if all
|
||||||
list → global active price list → `Item.BasePrice`.
|
three are absent (order creation requires an explicit price, per rule 10 of
|
||||||
- Two price lists cannot have overlapping `EffectiveFrom`/`EffectiveTo` ranges for the same
|
[01_item_catalog.md](./01_item_catalog.md)).
|
||||||
`(ItemId, RegionId)` pair.
|
- "Active" means `EffectiveFrom <= orderDate <= EffectiveTo` and `IsActive = true`.
|
||||||
|
- "Region-scoped" means `PriceList.RegionId` equals the order's store's region.
|
||||||
|
- If more than one region-scoped price list is active for the same region (shouldn't happen
|
||||||
|
given rule 2, but defensively), the most recently created one wins.
|
||||||
|
2. Two active price lists cannot have overlapping `EffectiveFrom`/`EffectiveTo` ranges for the same
|
||||||
|
`(ItemId, RegionId)` pair — checked at the `PriceListItem` level (a global list and a region list
|
||||||
|
with the same item may coexist since region-scoped always wins in resolution, so the overlap
|
||||||
|
check only compares price lists with the *same* `RegionId` value, including both being null).
|
||||||
|
3. A `PriceList` cannot be deleted (soft delete) while any `PriceListItem` under it is still active
|
||||||
|
— delete/deactivate the items first, or cascade-deactivate them in the same transaction (cascade
|
||||||
|
chosen: deleting a price list deactivates all its `PriceListItem` rows in the same
|
||||||
|
unit-of-work).
|
||||||
|
4. `Supplier.PerformanceScore` is null until [15_procurement.md](./15_procurement.md)'s
|
||||||
|
`evaluateSupplier` sets it; no endpoint in this step writes a non-null value.
|
||||||
|
5. A `Supplier` cannot be hard-deleted, ever — soft delete only, matching the `Template.IsActive`
|
||||||
|
convention used across the codebase.
|
||||||
|
6. `PriceListItem.MinPrice`, when set, must be `<= Price` — enforced on every write via
|
||||||
|
`PUT /api/price-lists/{id}/items/{sku}` (409 if violated).
|
||||||
|
7. At order time, a caller-supplied `OrderItemRequest.UnitPrice` must fall within
|
||||||
|
`[MinPrice ?? Price, Price]` for the item's resolved `PriceListItem` (region-scoped, then
|
||||||
|
global). The `Item.SalePrice` fallback has no `MinPrice` — always fixed, no negotiation. An
|
||||||
|
out-of-band price, or no resolvable price at all, rejects the whole order (400).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions to Add to Seed
|
||||||
|
|
||||||
|
**Status: Not implemented for now.** Endpoints in this step are built without permission
|
||||||
|
enforcement (no `[Authorize(Policy = ...)]` gate) until explicitly requested. The codes and role
|
||||||
|
assignments below are the target design to wire in later — keep them here as the reference, not as
|
||||||
|
something to seed yet.
|
||||||
|
|
||||||
|
Target permission codes (not yet in `RoleAndPermissionSeed.cs`):
|
||||||
|
```
|
||||||
|
pricing.view, pricing.create, pricing.update, pricing.delete
|
||||||
|
suppliers.view, suppliers.create, suppliers.update, suppliers.delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Target role assignments (mirrors the [01_item_catalog.md](./01_item_catalog.md) items/categories
|
||||||
|
pattern), for when seeding is turned on:
|
||||||
|
- **SalesRep (10)**: pricing.view
|
||||||
|
- **Supervisor (20)**: pricing.view
|
||||||
|
- **AreaManager (30)**: pricing.view, pricing.create, pricing.update, suppliers.view
|
||||||
|
- **AreaSupervisor (40)**: all pricing + supplier permissions
|
||||||
|
- **RegionalManager (50)**: all pricing + supplier permissions
|
||||||
|
- **RegionalAgency (60)**: all pricing + supplier permissions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Are price lists customer/segment-specific as well as region-specific (ties into `Store.Segment`
|
- None. Resolved in planning discussion (2026-08-20): price lists are region-only in v1
|
||||||
from [12_store_management.md](./12_store_management.md)), or region-only in v1?
|
(no `Store.Segment` scoping); deleting a price list cascades to deactivate its items rather than
|
||||||
|
being blocked by them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
### File structure created / touched
|
||||||
|
```
|
||||||
|
Features/
|
||||||
|
PriceList/
|
||||||
|
PriceListController.cs
|
||||||
|
PriceListDTOs.cs
|
||||||
|
PriceListService.cs (ResolveUnitPriceAsync — the price-list half of applyPricingAndDiscounts)
|
||||||
|
IPriceListRepository.cs
|
||||||
|
PriceListRepository.cs
|
||||||
|
Supplier/
|
||||||
|
SupplierController.cs
|
||||||
|
SupplierDTOs.cs
|
||||||
|
SupplierService.cs
|
||||||
|
ISupplierRepository.cs
|
||||||
|
SupplierRepository.cs
|
||||||
|
Order/
|
||||||
|
OrderDTOs.cs (extended — OrderItemRequest gained an optional UnitPrice override
|
||||||
|
for the SalePrice-is-null / no-price-list-match case; OrderItemResponse
|
||||||
|
now echoes the captured UnitPrice)
|
||||||
|
OrderService.cs (extended — CreateAsync resolves each line's price via a private
|
||||||
|
ResolveUnitPriceAsync that calls PriceListService, using the request's
|
||||||
|
UnitPrice override if supplied; rejects the order (returns null) if no
|
||||||
|
price can be resolved at all)
|
||||||
|
Item/
|
||||||
|
IItemRepository.cs / ItemRepository.cs (extended — GetBySkuAsync, needed to resolve the
|
||||||
|
{sku} route segment on the price-list item endpoints)
|
||||||
|
Store/
|
||||||
|
IStoreRepository.cs / StoreRepository.cs (extended — GetByIdWithRegionAsync, includes
|
||||||
|
Store.Route.Region so OrderService can read the
|
||||||
|
store's RegionId for price resolution)
|
||||||
|
|
||||||
|
Models/
|
||||||
|
PriceList.cs (new)
|
||||||
|
PriceListItem.cs (new)
|
||||||
|
Supplier.cs (new)
|
||||||
|
|
||||||
|
Data/AppDBContext.cs
|
||||||
|
add PriceLists, PriceListItems, Suppliers DbSets; unique index on
|
||||||
|
PriceListItem(FK_PriceListId, FK_ItemId)
|
||||||
|
|
||||||
|
Migrations/
|
||||||
|
AddPricingAndSuppliers (applied)
|
||||||
|
|
||||||
|
Program.cs
|
||||||
|
register IPriceListRepository/PriceListRepository, ISupplierRepository/SupplierRepository,
|
||||||
|
PriceListService, SupplierService
|
||||||
|
|
||||||
|
Data/Seed/
|
||||||
|
RoleAndPermissionSeed.cs — untouched. Permission enforcement is intentionally deferred (see
|
||||||
|
Permissions to Add to Seed above); controllers use plain [Authorize]
|
||||||
|
with no policy for now.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Price resolution logic (pseudo)
|
||||||
|
```
|
||||||
|
ResolveUnitPriceAsync(itemId, storeId, orderDate):
|
||||||
|
regionId = store.RegionId
|
||||||
|
regionList = active PriceListItem where ItemId = itemId
|
||||||
|
and PriceList.RegionId = regionId
|
||||||
|
and PriceList.EffectiveFrom <= orderDate <= PriceList.EffectiveTo
|
||||||
|
and PriceList.IsActive and PriceListItem.IsActive
|
||||||
|
order by PriceList.CreatedAt desc, take 1
|
||||||
|
if regionList found: return regionList.Price
|
||||||
|
|
||||||
|
globalList = same query with PriceList.RegionId = null
|
||||||
|
if globalList found: return globalList.Price
|
||||||
|
|
||||||
|
if item.SalePrice != null: return item.SalePrice
|
||||||
|
return null // caller (OrderService) rejects with 400 if no explicit price was supplied
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlap validation on price list write (pseudo)
|
||||||
|
```
|
||||||
|
On PUT /api/price-lists/{id}/items/{sku} or POST /api/price-lists:
|
||||||
|
for each ItemId affected, check existing PriceListItem rows joined to PriceList
|
||||||
|
where PriceList.RegionId = this.RegionId (both null counts as a match)
|
||||||
|
and PriceList.Id != this.Id
|
||||||
|
and date ranges overlap (existing.EffectiveFrom <= new.EffectiveTo
|
||||||
|
and existing.EffectiveTo >= new.EffectiveFrom)
|
||||||
|
→ reject with 400 if any match found
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worked Example
|
||||||
|
|
||||||
|
Item `COKE-330` has `SalePrice = 100`. Store `S1` is in the North region.
|
||||||
|
|
||||||
|
**Set up a negotiable region price list:**
|
||||||
|
```
|
||||||
|
POST /api/price-lists
|
||||||
|
{ "name": "North Region September", "regionId": "<northRegionId>",
|
||||||
|
"effectiveFrom": "2026-09-01", "effectiveTo": "2026-09-30" }
|
||||||
|
→ PriceList A (RegionId = North)
|
||||||
|
|
||||||
|
PUT /api/price-lists/{A.Id}/items/COKE-330
|
||||||
|
{ "price": 90, "minPrice": 80 }
|
||||||
|
```
|
||||||
|
This publishes a band for `COKE-330` in the North region: ceiling 90, floor 80.
|
||||||
|
|
||||||
|
**Order at the default (no negotiation):**
|
||||||
|
```
|
||||||
|
POST /api/orders
|
||||||
|
{ "storeId": "S1", "orderDate": "2026-09-15", "items": [{ "itemId": "COKE-330", "quantity": 10 }] }
|
||||||
|
```
|
||||||
|
No `unitPrice` supplied → resolves to the ceiling, **unit price = 90**.
|
||||||
|
|
||||||
|
**Order with a negotiated price inside the band:**
|
||||||
|
```
|
||||||
|
POST /api/orders
|
||||||
|
{ "storeId": "S1", "orderDate": "2026-09-15",
|
||||||
|
"items": [{ "itemId": "COKE-330", "quantity": 10, "unitPrice": 83 }] }
|
||||||
|
```
|
||||||
|
83 is within `[80, 90]` → **accepted, unit price = 83**.
|
||||||
|
|
||||||
|
**Order with a price outside the band:**
|
||||||
|
```
|
||||||
|
POST /api/orders
|
||||||
|
{ "storeId": "S1", "orderDate": "2026-09-15",
|
||||||
|
"items": [{ "itemId": "COKE-330", "quantity": 10, "unitPrice": 75 }] }
|
||||||
|
```
|
||||||
|
75 is below `MinPrice = 80` → **the whole order is rejected (400)**, same as if no price could be
|
||||||
|
resolved at all.
|
||||||
|
|
||||||
|
A store in the South region (no region price list, so it falls through to the global list or
|
||||||
|
`Item.SalePrice = 100`) has no `MinPrice` unless a global `PriceListItem` also sets one — an
|
||||||
|
override there is rejected unless it equals the fixed price exactly.
|
||||||
|
|
||||||
|
**Supplier example** (unrelated to pricing resolution, same step):
|
||||||
|
```
|
||||||
|
POST /api/suppliers
|
||||||
|
{ "name": "Acme Distributors", "contactInfo": "acme@example.com, +91-98765-43210" }
|
||||||
|
→ Supplier { Id, Name: "Acme Distributors", PerformanceScore: null }
|
||||||
|
```
|
||||||
|
`PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier`
|
||||||
|
runs against this supplier.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
|
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
|
||||||
| Unit of Work + Repositories | Done | Transaction support throughout |
|
| Unit of Work + Repositories | Done | Transaction support throughout |
|
||||||
| Soft Deletes | Done | IsActive pattern on all entities |
|
| Soft Deletes | Done | IsActive pattern on all entities |
|
||||||
|
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||||
|
|
||||||
## Known Gaps in Existing System (Pre-Work)
|
## Known Gaps in Existing System (Pre-Work)
|
||||||
|
|
||||||
@@ -46,9 +47,9 @@ They must be resolved before new feature steps begin because later steps depend
|
|||||||
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
||||||
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
|
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
|
||||||
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
||||||
| 12 | Store Management (Beyond CRUD) | Not Started | 00 | [12_store_management.md](./12_store_management.md) |
|
| 12 | Store Management (Beyond CRUD) | In Progress | 00 | [12_store_management.md](./12_store_management.md) |
|
||||||
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
||||||
| 14 | Pricing & Supplier Master Data | Not Started | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||||
| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
||||||
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
||||||
| 17 | Promotions & Trade Scheme Management | Not Started | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
| 17 | Promotions & Trade Scheme Management | Not Started | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||||
@@ -79,10 +80,10 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented — `OrderService.CreateAsync` exists but has no credit/stock validation or state machine yet. |
|
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, enforces store scope, blocks inactive stores, and applies the unverified-KYC order threshold from config. It still has no stock validation, credit-limit validation, approval flow, or order state machine. |
|
||||||
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented — no credit limit field on `Store` yet. |
|
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. |
|
||||||
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
|
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
|
||||||
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Not implemented — depends on §7 Master Data and §11 Promotions below. |
|
| `applyPricingAndDiscounts(orderId)` | Runs the active price list and eligible scheme rules against order lines. | Partially implemented — `OrderService.CreateAsync` resolves the price-list half via `PriceListService.ResolveUnitPriceAsync` (region-scoped → global → `Item.SalePrice`, or a caller-supplied `UnitPrice`); the scheme-discount half still depends on §11 Promotions below. |
|
||||||
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
||||||
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
||||||
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
|
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
|
||||||
@@ -124,15 +125,15 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented — `StoreController` create exists, no KYC document handling. |
|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
|
||||||
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Not implemented. |
|
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
|
||||||
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Not implemented — no credit limit field on `Store`. |
|
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
|
||||||
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Not implemented — depends on §13 Finance & Accounting. |
|
| `getStoreOutstandingBalance(storeId)` | Returns current receivables against the store. | Partially implemented - `GET /api/stores/{id}/outstanding-balance` returns the cached `OutstandingBalance`, `CreditLimit`, and available credit. It is not yet driven by Finance events or invoice/payment records. |
|
||||||
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Not implemented — no segment field on `Store`. |
|
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
||||||
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
||||||
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Not implemented — depends on route check-in/check-out (§3). |
|
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. |
|
||||||
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Not implemented — depends on §11 Promotions. |
|
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Placeholder only - endpoint exists (`GET /api/stores/{id}/schemes/{schemeId}/eligibility`) but currently returns a default eligible response until Promotions is implemented. |
|
||||||
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Not implemented. |
|
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. |
|
||||||
|
|
||||||
### 5. Vehicle / Fleet Management
|
### 5. Vehicle / Fleet Management
|
||||||
|
|
||||||
@@ -173,9 +174,9 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
|
| `createProduct(skuData)` | Creates a SKU with category, UOM, barcode, pack hierarchy. | Not implemented — `Item` model exists but has no properties; see [01_item_catalog.md](./01_item_catalog.md). |
|
||||||
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
|
| `deactivateSku(sku, reason)` | Retires a SKU from active ordering. | Not implemented. |
|
||||||
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Not implemented — no price list entity exists. |
|
| `createPriceList(name, region, effectiveDates)` | Defines a new price list scoped to a region/date range. | Implemented — `POST /api/price-lists`, see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md). |
|
||||||
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Not implemented. |
|
| `updateProductPricing(sku, priceListId, price)` | Sets/updates the price of a SKU within a price list. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
|
||||||
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Not implemented — no `Supplier` entity exists. |
|
| `createSupplierMaster(supplierData)` | Registers a new supplier/vendor. | Implemented — `POST /api/suppliers`. `PerformanceScore` stays null until [15_procurement.md](./15_procurement.md)'s `evaluateSupplier` exists. |
|
||||||
|
|
||||||
### 8. Enterprise-wide Inventory & Stock
|
### 8. Enterprise-wide Inventory & Stock
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user