Compare commits

...

10 Commits

Author SHA1 Message Date
DeepnaPooja b2681d1236 Update pricing and store implementation plans 2026-08-20 17:29:11 +05:30
DeepnaPooja 63bc6e9cd8 Add pricing and supplier master data 2026-08-20 17:28:55 +05:30
DeepnaPooja 7a2494c156 Merge pull request 'Feat/store management' (#3) from feat/store_management into dev
Reviewed-on: #3
2026-08-20 08:13:40 +00:00
DeepnaPooja e3e331a2bb fix the running issue 2026-08-20 13:24:51 +05:30
DeepnaPooja 4e73dd7fb1 update store mngt.md 2026-08-20 13:10:46 +05:30
DeepnaPooja e88668beca Add store management workflows and KYC order gating 2026-08-20 13:08:00 +05:30
DeepnaPooja c1961ebea2 Add store KYC document management 2026-08-20 13:07:47 +05:30
DeepnaPooja cf9d2c2661 Add store credit and KYC data model 2026-08-20 13:07:27 +05:30
Kalana B. Thilakarathna 429a80b1ed Add planning docs for steps 10–23 and update roadmap
Added detailed implementation planning documents for steps 10–23, covering all major feature areas. Updated mainPlan.md to reference these steps, their dependencies, and planning files. No code changes—these documents clarify requirements and guide future development.
2026-08-20 10:04:09 +05:30
DeepnaPooja d2390e7b12 Add md file features in region, route, store, fleet, pricing, procurement, SFA, promotions, returns, finance, reporting, notifications, integration, and mobile capabilities 2026-08-19 15:18:31 +05:30
64 changed files with 12031 additions and 252 deletions
+8
View File
@@ -29,6 +29,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
public DbSet<StockBalance> StockBalances { get; set; }
public DbSet<StockMovement> StockMovements { 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)
{
@@ -57,6 +61,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(sb => new { sb.FK_ItemId, sb.LocationId })
.IsUnique();
modelBuilder.Entity<PriceListItem>()
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -35,6 +35,8 @@ public static class RoleAndPermissionSeed
("stores.create", "Create stores"),
("stores.update", "Update stores"),
("stores.delete", "Delete stores"),
("stores.kyc.verify", "Verify or reject store KYC documents"),
("stores.credit.update", "Update store credit limit and outstanding balance"),
("warehouses.view", "View warehouses"),
("warehouses.create", "Create warehouses"),
@@ -98,14 +100,14 @@ public static class RoleAndPermissionSeed
var itemPerms = new[] { "items.view", "items.create", "items.update", "items.delete", "categories.view", "categories.create", "categories.update", "categories.delete" };
var salesRepPerms = new[] { "orders.view", "orders.create", "orders.update", "stores.view", "routes.view" }.Concat(itemViewPerms).ToArray();
var supervisorPerms = salesRepPerms.Concat(["orders.delete", "stores.create", "stores.update"]).ToArray();
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view"]).Concat(itemManagePerms).Distinct().ToArray();
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update"]).Concat(itemPerms).Distinct().ToArray();
var supervisorPerms = salesRepPerms.Concat(["orders.delete", "stores.create", "stores.update", "stores.credit.update"]).ToArray();
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view", "stores.kyc.verify"]).Concat(itemManagePerms).Distinct().ToArray();
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update", "stores.delete"]).Concat(itemPerms).Distinct().ToArray();
var regionalManagerPerms = new[]
{
"orders.view", "orders.create", "orders.update", "orders.delete",
"stores.view", "stores.create", "stores.update", "stores.delete",
"stores.view", "stores.create", "stores.update", "stores.delete", "stores.kyc.verify", "stores.credit.update",
"routes.view", "routes.create", "routes.update", "routes.delete",
"vehicles.view", "vehicles.create", "vehicles.update", "vehicles.delete",
"warehouses.view", "warehouses.create", "warehouses.update", "warehouses.delete",
@@ -3,6 +3,7 @@ namespace Distribution.Features.Item;
public interface IItemRepository
{
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<bool> SkuExistsAsync(string sku, Guid? excludeId = null);
Task<Models.Item?> GetByExternalErpItemIdAsync(int erpId);
@@ -15,6 +15,9 @@ public class ItemRepository(AppDBContext context) : IItemRepository
.Include(i => i.Conversions).ThenInclude(c => c.ToUom)
.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)
{
var query = context.Items
+2 -2
View File
@@ -1,12 +1,12 @@
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 UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
public record OrderItemResponse(Guid ItemId, int Quantity);
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
public record OrderResponse(
Guid Id,
+59 -7
View File
@@ -1,15 +1,28 @@
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
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);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -20,6 +33,29 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
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()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -63,18 +99,34 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var order = new Models.Order
{
FK_StoreId = request.StoreId,
OrderDate = request.OrderDate,
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,
FK_ItemId = i.ItemId,
Quantity = i.Quantity
}).ToList();
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity,
UnitPrice = unitPrice.Value
});
}
await uow.BeginAsync(ct);
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,8 +3,10 @@ namespace Distribution.Features.Store;
public interface IStoreRepository
{
Task<Models.Store?> GetByIdAsync(Guid id);
Task<Models.Store?> GetByIdWithRegionAsync(Guid id);
Task<List<Models.Store>> GetAllAsync();
Task AddAsync(Models.Store entity);
void Update(Models.Store entity);
void Remove(Models.Store entity);
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
}
@@ -0,0 +1,7 @@
namespace Distribution.Features.Store.Kyc;
public interface IStoreKycDocumentRepository
{
Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.StoreKycDocument entity);
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store.Kyc;
[ApiController]
[Route("api/stores/{storeId:guid}/kyc-documents")]
[Authorize]
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetByStoreId(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
{
var result = await service.UploadAsync(storeId, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,5 @@
namespace Distribution.Features.Store.Kyc;
public record UploadKycDocumentRequest(string DocumentType, IFormFile File);
public record StoreKycDocumentResponse(Guid Id, Guid StoreId, string DocumentType, string FileReference, DateTime UploadedAt);
@@ -0,0 +1,13 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Store.Kyc;
public class StoreKycDocumentRepository(AppDBContext context) : IStoreKycDocumentRepository
{
public async Task<List<Models.StoreKycDocument>> GetByStoreIdAsync(Guid storeId) =>
await context.StoreKycDocuments.Where(d => d.FK_StoreId == storeId && d.IsActive).ToListAsync();
public async Task AddAsync(Models.StoreKycDocument entity) =>
await context.StoreKycDocuments.AddAsync(entity);
}
@@ -0,0 +1,71 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
namespace Distribution.Features.Store.Kyc;
public class StoreKycDocumentService(
IStoreKycDocumentRepository repo,
IStoreRepository storeRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IWebHostEnvironment env)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private static StoreKycDocumentResponse ToResponse(Models.StoreKycDocument d) =>
new(d.Id, d.FK_StoreId, d.DocumentType, d.FileReference, d.UploadedAt);
public async Task<List<StoreKycDocumentResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var docs = await repo.GetByStoreIdAsync(storeId);
return docs.Select(ToResponse).ToList();
}
public async Task<StoreKycDocumentResponse?> UploadAsync(Guid storeId, UploadKycDocumentRequest request, CancellationToken ct = default)
{
var store = await storeRepo.GetByIdAsync(storeId);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(storeId)) return null;
var uploadsRoot = Path.Combine(env.ContentRootPath, "wwwroot", "uploads", "kyc", storeId.ToString());
Directory.CreateDirectory(uploadsRoot);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(request.File.FileName)}";
var filePath = Path.Combine(uploadsRoot, fileName);
await using (var stream = new FileStream(filePath, FileMode.Create))
{
await request.File.CopyToAsync(stream, ct);
}
var fileReference = $"uploads/kyc/{storeId}/{fileName}";
var doc = new Models.StoreKycDocument
{
FK_StoreId = storeId,
DocumentType = request.DocumentType,
FileReference = fileReference,
UploadedAt = DateTime.Now
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(doc);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(doc);
}
}
@@ -44,4 +44,68 @@ public class StoreController(StoreService service) : ControllerBase
var deleted = await service.DeleteAsync(id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
return result is null ? Forbid() : Ok(result);
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.credit.update")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
return result is null ? Forbid() : Ok(result);
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/kyc/verify")]
[Authorize(Policy = "stores.kyc.verify")]
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
{
var result = await service.VerifyKycAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/segment")]
[Authorize(Policy = "stores.update")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/visit-history")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/dashboard")]
[Authorize(Policy = "stores.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
}
+22
View File
@@ -1,3 +1,5 @@
using Distribution.Models;
namespace Distribution.Features.Store;
public record CreateStoreRequest(
@@ -30,5 +32,25 @@ public record StoreResponse(
decimal Longitude,
decimal OneTimeAmount,
decimal CreditAmount,
decimal CreditLimit,
decimal OutstandingBalance,
string? Segment,
KycStatus KycStatus,
bool IsActive,
DateTime CreatedAt);
public record UpdateCreditLimitRequest(decimal NewLimit, Guid ApproverId);
public record UpdateOutstandingBalanceRequest(decimal NewBalance);
public record VerifyKycRequest(KycStatus Status, string? Notes);
public record ClassifySegmentRequest(string Segment);
public record OutstandingBalanceResponse(Guid StoreId, decimal CreditLimit, decimal OutstandingBalance, decimal AvailableCredit);
public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUserId, string Outcome);
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
@@ -8,6 +8,11 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
public async Task<Models.Store?> GetByIdAsync(Guid 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() =>
await context.Stores.ToListAsync();
@@ -22,4 +27,9 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
entity.IsActive = false;
context.Stores.Update(entity);
}
public async Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) =>
await context.Orders
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive)
.ToListAsync();
}
+137 -4
View File
@@ -1,11 +1,13 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -15,6 +17,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private static StoreResponse ToResponse(Models.Store s) =>
new(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount,
s.CreditLimit, s.OutstandingBalance, s.Segment, s.KycStatus, s.IsActive, s.CreatedAt);
public async Task<List<StoreResponse>> GetAllAsync()
{
var stores = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
@@ -23,7 +29,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
stores = stores.Where(s => accessibleStoreIds.Contains(s.Id)).ToList();
}
return stores.Select(s => new StoreResponse(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount, s.IsActive, s.CreatedAt)).ToList();
return stores.Select(ToResponse).ToList();
}
public async Task<StoreResponse?> GetByIdAsync(Guid id)
@@ -31,7 +37,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
return ToResponse(store);
}
public async Task<StoreResponse> CreateAsync(CreateStoreRequest request, CancellationToken ct = default)
@@ -58,7 +64,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
return ToResponse(store);
}
public async Task<StoreResponse?> UpdateAsync(Guid id, UpdateStoreRequest request, CancellationToken ct = default)
@@ -85,7 +91,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
await uow.RollbackAsync(ct);
throw;
}
return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt);
return ToResponse(store);
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
@@ -106,4 +112,131 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
}
return true;
}
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.CreditLimit = request.NewLimit;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.OutstandingBalance = request.NewBalance;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<OutstandingBalanceResponse?> GetOutstandingBalanceAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
}
public async Task<StoreResponse?> VerifyKycAsync(Guid id, VerifyKycRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.KycStatus = request.Status;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
store.Segment = request.Segment;
await uow.BeginAsync(ct);
try
{
repo.Update(store);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(store);
}
public async Task<List<StoreVisitHistoryItem>?> GetVisitHistoryAsync(Guid id)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
// Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists.
return [];
}
public async Task<SchemeEligibilityResponse?> CheckSchemeEligibilityAsync(Guid id, Guid schemeId)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
// Wired to Step 17 (Promotions & Schemes) once scheme rules exist.
return new SchemeEligibilityResponse(true, null);
}
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart))
periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
var periodEndExclusive = periodStart.AddMonths(1);
var orders = await repo.GetOrdersForDashboardAsync(id, periodStart, periodEndExclusive);
return new StoreDashboardResponse(
id,
periodStart.ToString("yyyy-MM"),
orders.Count,
orders.Sum(o => o.TotalAmount),
orders.Count == 0 ? null : orders.Max(o => o.OrderDate));
}
}
@@ -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,716 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddStoreCreditAndKyc : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.AddColumn<decimal>(
name: "CreditLimit",
table: "Stores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<int>(
name: "KycStatus",
table: "Stores",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<decimal>(
name: "OutstandingBalance",
table: "Stores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "Segment",
table: "Stores",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "StoreKycDocuments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
DocumentType = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FileReference = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UploadedAt = 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_StoreKycDocuments", x => x.Id);
table.ForeignKey(
name: "FK_StoreKycDocuments_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000017"),
columns: new[] { "Code", "Description" },
values: new object[] { "stores.kyc.verify", "Verify or reject store KYC documents" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000018"),
columns: new[] { "Code", "Description" },
values: new object[] { "stores.credit.update", "Update store credit limit and outstanding balance" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000019"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.view", "View warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000020"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.create", "Create warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000021"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.update", "Update warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000022"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.delete", "Delete warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000023"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.view", "View orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000024"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.create", "Create orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000025"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.update", "Update orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000026"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.delete", "Delete orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000027"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.view", "View users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000028"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.create", "Create users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000029"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.update", "Update users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000030"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.deactivate", "Deactivate users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000031"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_role", "Assign a role to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000032"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_scope", "Assign region/route/store scope to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000033"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.view", "View items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000034"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.create", "Create items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000035"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.update", "Update items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000036"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.delete", "Delete items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000037"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.view", "View categories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000038"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.create", "Create categories and subcategories" });
migrationBuilder.InsertData(
table: "Permissions",
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000039"), "categories.update", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Update categories and subcategories", true },
{ new Guid("20000000-0000-0000-0000-000000000040"), "categories.delete", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Delete categories and subcategories", true }
});
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("c0739b84-50a2-0971-429b-02bab8354c51"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("c268a531-44eb-0f29-82ba-18394ac2dcb5"), true },
{ new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ddb2d04d-19b0-20b1-2639-b157d12a9eec"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("320ba3fb-666c-ce57-3e61-95c9824c4048"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f2c14d55-d557-5cd2-a70a-4a6f58933436"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1b126bb0-e582-3e84-4728-a7ab5628fe7b"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f50b201c-6c8d-0df8-85ad-08cb137bc213"), true },
{ new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0f291826-e169-c06a-0735-bd06cbb2062d"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("10dd0249-ce04-c5d9-8f94-ccb348c1d979"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d92d572f-f1bf-9e7d-1d4d-7c7b0d575c8e"), true },
{ new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f367f6f6-3d26-7a6c-888b-da1a240b59af"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("929950da-d51b-ad5d-c4e5-55175908fb43"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a9a49749-f99d-3166-5d92-b7e380fcabb2"), true },
{ new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b17df3df-022c-c903-6ce0-e6423e648dc8"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a41f94ba-c24d-93dc-9f25-c462df19785a"), true },
{ new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e0b27435-f505-43f5-7ed9-4711f2248495"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("5e10d61c-2d7a-3388-722f-45232d2cfb71"), true },
{ new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d8257363-d838-fe7e-57a5-51b626dbfc83"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("04ece77e-b36f-8ed0-4ef9-2f6962b6f9c6"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("bf2a2f96-04ed-905d-5a8a-9047d814aa3e"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("4d287b19-ba40-7d4d-3d39-291be64197f8"), true },
{ new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43a4c7b4-a50f-aa37-b147-a30636d9d65f"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a66f3854-5a8f-95c2-cb1c-b2b2e67ee226"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("14bcecd1-b1a2-6da3-155a-233611484467"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("aeb29e0e-e819-1446-6cc6-1fb64b5ecc6b"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e18a6eca-1009-bcc6-35e9-ead943c1649f"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("3f32cdac-6925-23f2-064e-8226d781d4a3"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("dc3dd9ff-b123-a8d4-dcea-b4f5f8ff171e"), true }
});
migrationBuilder.CreateIndex(
name: "IX_StoreKycDocuments_FK_StoreId",
table: "StoreKycDocuments",
column: "FK_StoreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoreKycDocuments");
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000039"));
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000040"));
migrationBuilder.DropColumn(
name: "CreditLimit",
table: "Stores");
migrationBuilder.DropColumn(
name: "KycStatus",
table: "Stores");
migrationBuilder.DropColumn(
name: "OutstandingBalance",
table: "Stores");
migrationBuilder.DropColumn(
name: "Segment",
table: "Stores");
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000017"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.view", "View warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000018"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.create", "Create warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000019"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.update", "Update warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000020"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.delete", "Delete warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000021"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.view", "View orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000022"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.create", "Create orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000023"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.update", "Update orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000024"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.delete", "Delete orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000025"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.view", "View users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000026"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.create", "Create users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000027"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.update", "Update users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000028"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.deactivate", "Deactivate users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000029"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_role", "Assign a role to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000030"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_scope", "Assign region/route/store scope to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000031"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.view", "View items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000032"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.create", "Create items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000033"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.update", "Update items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000034"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.delete", "Delete items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000035"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.view", "View categories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000036"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.create", "Create categories and subcategories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000037"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.update", "Update categories and subcategories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000038"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.delete", "Delete categories and subcategories" });
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("85ef4e27-8643-9c43-71f6-da56f66a5c47"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1e353909-d682-58f6-df5c-8e57fa2512c9"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("7596f703-c6ab-316c-7c80-3f4e3daa174e"), true },
{ new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("aa120c36-488e-34ba-e10a-2ac45e46e119"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e4648655-7267-141e-51fa-f7d5fe3d2356"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d427b9ea-2c9b-d93e-0843-02dfa1ae8887"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b7dee5e3-f329-95a5-75c1-d02c3e40e229"), true },
{ new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e5506b30-527d-08f9-6caa-8a45d3d02999"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0dd5c943-3ace-0560-6fbc-54d8e37ce014"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ca97fb3d-132c-d8a5-4ab8-f7b8fc60f5e5"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("7780d277-9597-84ff-1a1a-f63fc6b4d3cb"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("8083c659-9506-0bd9-b793-3d6148ce9192"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("5c00e972-a697-d71a-6545-1fd8618826cf"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e9b6af8e-7947-7290-9c92-2d9b6239f395"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a701da28-aa75-3c1d-abdb-4317404f8df6"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("2c025310-765f-7d07-ab9a-50524c13d1d3"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("69f93a29-1df1-7b20-9d5c-94c264f8c4b8"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("070813f7-4053-2889-0d14-d0d5834208c2"), 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");
}
}
}
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");
}
}
}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -1,6 +1,16 @@
namespace Distribution.Models
{
public enum LocationType { Warehouse, Vehicle }
public enum LocationType
{
Warehouse,
Vehicle
}
public enum KycStatus
{
Pending,
Verified,
Rejected }
public enum MovementType
{
+18
View File
@@ -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; } = [];
}
}
+23
View File
@@ -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; }
}
}
+4
View File
@@ -11,6 +11,10 @@ namespace Distribution.Models
public decimal Longitude { get; set; } = 0;
public decimal OneTimeAmount { get; set; } = 0;
public decimal CreditAmount { get; set; } = 0;
public decimal CreditLimit { get; set; } = 0;
public decimal OutstandingBalance { get; set; } = 0;
public String? Segment { get; set; } //Gold ,silver,key Account,high risk
public KycStatus KycStatus { get; set; } = KycStatus.Pending;
public Guid FK_RouteId { get; set; }
[ForeignKey("FK_RouteId")]
+14
View File
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class StoreKycDocument : Template
{
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public String DocumentType { get; set; }
public String FileReference { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.Now;
}
}
+11
View File
@@ -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; }
}
}
+9
View File
@@ -5,10 +5,13 @@ using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Region;
using Distribution.Features.Role;
using Distribution.Features.Route;
using Distribution.Features.Store;
using Distribution.Features.Store.Kyc;
using Distribution.Features.Supplier;
using Distribution.Features.Sync;
using Distribution.Features.Uom;
using Distribution.Features.User;
@@ -46,6 +49,9 @@ builder.Services.AddScoped<IBrandRepository, BrandRepository>();
builder.Services.AddScoped<IUomRepository, UomRepository>();
builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -62,6 +68,9 @@ builder.Services.AddScoped<UomService>();
builder.Services.AddScoped<ItemService>();
builder.Services.AddScoped<SyncService>();
builder.Services.AddScoped<StockService>();
builder.Services.AddScoped<StoreKycDocumentService>();
builder.Services.AddScoped<PriceListService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+3
View File
@@ -14,5 +14,8 @@
"Issuer": "Distribution.Api",
"Audience": "Distribution.Api",
"ExpiryMinutes": 120
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
}
}
+62 -9
View File
@@ -7,7 +7,11 @@
## Goal
Close every hole identified in the currently-implemented modules (Region, Route, Vehicle, Store,
Warehouse, Order, User/Role/Permission) before any feature from [mainPlan.md](./mainPlan.md)
is built on top of them. This file is the running list of "Partially implemented" and foundational
"Not implemented" items pulled from that reference doc that block later steps, not just cosmetic
cleanup.
---
@@ -15,45 +19,94 @@
### 1. Route DTO — Missing RegionId
`RouteDTOs.cs` does not expose `RegionId` on create/update, so a Route cannot be linked to a
Region via the API even though `Route.FK_RegionId` exists on the model. Blocks §2/§3 of the
function reference (`assignAreaToRegion`, `getRegionHierarchy`) and any region-scoped authorization.
### 2. Store DTO — Missing RouteId
`StoreDTOs.cs` does not expose `RouteId` on create/update, so a Store cannot be linked to a Route
via the API even though `Store.FK_RouteId` exists on the model. Blocks `assignOutletsToRoute` (§3)
and route/region-scoped authorization for Stores and Orders.
### 3. Role Controller — Missing Authorization
`RoleController`'s GET endpoints carry only `[Authorize]`, with no `[Authorize(Policy = "...")]`
per action like every other controller. Any authenticated user — including a SalesRep — can read
every role and its full permission list today.
### 4. Secrets & Configuration Management
JWT signing key and DB connection string currently live in `appsettings.json`. Move both to
environment variables or user secrets before any non-local environment is stood up.
### 5. Input Validation
No DTO in any feature carries validation attributes (`[Required]`, `[MaxLength]`, range checks,
etc.). Bad data (empty names, negative levels, malformed emails) can enter the system silently
through any existing POST/PUT endpoint.
### 6. Item Model — Missing Properties
`Item : Template` has no properties (no SKU, name, unit of measure, category). `OrderItem`
references `Item`, but there's nothing there to reference. Fully scoped as its own step in
[01_item_catalog.md](./01_item_catalog.md) — tracked here only as the blocking dependency.
### 7. Order Has No Status / State Machine
`Order` has no status field, so `updateOrderStatus`, `approveOrder`/`rejectOrder`, and
`convertOrderToInvoice` (§1 of the function reference) have nowhere to write to. Every order-status
consumer downstream (delivery ride execution, sale ride execution, reconciliation, agent controls)
depends on this existing first.
### 8. No Credit Limit / Segment Fields on Store
`validateCreditLimit`, `updateStoreCreditLimit`, `getStoreOutstandingBalance`, and `classifyStore`
(§1, §4) all need fields that don't exist on `Store` today (credit limit, outstanding balance,
segment). Not scoped to a single feature file yet — flagged here so it isn't silently assumed by
a later step.
### 9. No Area Entity Between Region and Route
`assignAreaToRegion`, `createRoute(name, areaId)` (§2, §3) assume an `Area` level between Region
and Route that doesn't exist in the schema — only `Region → Route → Store` exists. Confirm with
the user whether `Area` is a real requirement or whether Region/Route is the intended two-level
hierarchy before building anything that assumes a third level.
### 10. No Driver Entity
`DeliveryRun.FK_DriverId`/`Driver` navigation is commented out in the model. `assignDriverToVehicle`,
`checkDriverCompliance` (§5) and any ride that needs a named driver depend on this existing.
Blocks [03_ride_lifecycle.md](./03_ride_lifecycle.md).
---
## Scope (What This Step Does NOT Include)
- No new feature modules (Procurement, Finance, Promotions, etc.) — those are separate steps,
see [mainPlan.md](./mainPlan.md) for the full list and their own
numbered files below.
- No UI/client changes.
- No changes to authentication (JWT) — only authorization policy coverage on `RoleController`.
---
## Business Rules
- A gap fix must not change the shape of an already-shipped response DTO in a breaking way without
flagging it — additive fields only unless explicitly agreed.
- Soft-delete (`IsActive`) pattern must be preserved on every entity touched.
---
## Open Questions
- Is `Area` a real business concept (a level between Region and Route), or should
`createRoute`/`assignAreaToRegion` in the function reference be reinterpreted as Region-only?
- Should `Store.CreditLimit` and `Store.OutstandingBalance` be added now as part of gap-fixing, or
deferred to a dedicated Finance step (§13)?
- Does `Order.Status` need a fixed enum now (`pending/approved/picked/dispatched/delivered/invoiced`
per §1), or a smaller interim set until the fuller order lifecycle is built?
---
+29 -9
View File
@@ -7,13 +7,20 @@
## Goal
Turn the currently-unused `DeliveryRun` model into a working ride: a session that turns a vehicle
into a temporary warehouse. Covers `assignVehicleToRoute`, `assignDriverToVehicle`,
`checkDriverCompliance` from [mainPlan.md §5](./mainPlan.md#5-vehicle--fleet-management)
and `planDeliveryRoute` from §3.
---
## Background / Context
`Models/DeliveryRun.cs` exists (`FK_VehicleId`, `FK_RouteId`, `Meterstart`, `Meterend`) but has no
repository, service, or controller — it is completely unwired today, per [mainPlan.md](./mainPlan.md)'s
"Known Gaps" table. `FK_DriverId`/`Driver` navigation is commented out because no `Driver` entity
exists yet (gap #10 in [00_existing_gaps.md](./00_existing_gaps.md)) — this step is blocked on that
being resolved first if driver assignment is required at ride-open time.
---
@@ -21,39 +28,52 @@
### Ride (extending DeliveryRun)
Add a `Status` field to `DeliveryRun` for the state machine below, and a `RideType` field
(`Delivery` | `Sale`).
### Ride State Machine
`Draft → Open → Active → Closed`. A vehicle can only be on one `Open`/`Active` ride at a time —
enforced in the service layer, not the database.
### Ride Type (Delivery vs Sale)
`Delivery` rides fulfill pre-confirmed orders (see [05_delivery_ride.md](./05_delivery_ride.md));
`Sale` rides create invoices on the spot with no pre-orders (see [06_sale_ride.md](./06_sale_ride.md)).
Both share the same ride lifecycle and loading event.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignVehicleToRoute(vehicleId, routeId)` (§5) | Implicit in `POST /api/rides` — a ride binds a vehicle to a route for its duration |
| `assignDriverToVehicle(vehicleId, driverId)` (§5) | `PUT /api/rides/{id}/driver` — blocked on Driver entity, see gap #10 |
| `checkDriverCompliance(driverId)` (§5) | Called internally before a ride can move `Draft → Open` |
| `planDeliveryRoute(warehouseId, orderIds[])` (§3) | `POST /api/rides` (for `RideType = Delivery`) |
| — | `POST /api/rides/{id}/open`, `POST /api/rides/{id}/close` — state transitions |
---
## Business Rules
- A ride cannot open without a compliant driver (once Driver entity exists) and a vehicle not
already on an open ride.
- Opening a ride creates the vehicle's stock ledger location (see [02_stock_ledger.md](./02_stock_ledger.md)).
---
## Fraud / Control Considerations
- `checkDriverCompliance` failures must block ride open, not just warn.
---
## Open Questions
- Is a `Driver` a new entity distinct from `User`, or is "driver" just a `User` with a specific
`Role`? Affects whether gap #10 needs a new table or just a role addition.
---
+30 -9
View File
@@ -7,13 +7,18 @@
## Goal
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
Covers the dispatch half of [mainPlan.md §6](./mainPlan.md#6-warehouse-management):
`generatePickList`, `confirmPicking`, `packOrder`, `buildDispatchLoad`, and `calculateLoadPlan`
from §5.
---
## Background / Context
This is the first place the [02_stock_ledger.md](./02_stock_ledger.md) movement model gets
exercised end-to-end: a `Load` movement debits the warehouse and credits the vehicle location
created when the ride opened ([03_ride_lifecycle.md](./03_ride_lifecycle.md)).
---
@@ -21,39 +26,55 @@
### Loading Sheet
Header record: `RideId`, `SourceWarehouseId`, `Status` (`Pending`, `DispatchConfirmed`,
`ReceiptConfirmed`, `DiscrepancyFlagged`, `Resolved`).
### Loading Line Item
`LoadingSheetId`, `ItemId`, `QuantityDispatched` (set by warehouse manager), `QuantityReceived`
(set by driver) — a mismatch between the two is a discrepancy.
### Two-Party Confirmation
Warehouse manager confirms what was dispatched (`generatePickList` → `confirmPicking` →
`packOrder`); driver separately confirms what was received onto the vehicle. Both confirmations are
required before the ride can move `Open → Active`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `generatePickList(orderIds[])` (§6) | `POST /api/loading-sheets` |
| `confirmPicking(pickListId, pickedItems[])` (§6) | `POST /api/loading-sheets/{id}/confirm-dispatch` |
| `packOrder(orderId, packageDetails)` (§6) | `POST /api/loading-sheets/{id}/pack` |
| `buildDispatchLoad(vehicleId, orderIds[])` (§6) | Groups multiple orders into one loading sheet |
| `calculateLoadPlan(vehicleId, orderIds[])` (§5) | Validates total volume/weight against vehicle capacity before dispatch confirmation |
| — | `POST /api/loading-sheets/{id}/confirm-receipt` — driver-side confirmation |
---
## Business Rules
- `QuantityDispatched != QuantityReceived` on any line flags the sheet `DiscrepancyFlagged` and
blocks ride activation until resolved.
- `calculateLoadPlan` must reject a load exceeding vehicle capacity before dispatch is confirmed,
not after.
---
## Fraud / Control Considerations
- Discrepancies must be immediately visible, not silently averaged into stock adjustments — see
[09_fraud_controls.md](./09_fraud_controls.md).
---
## Open Questions
- Does `Vehicle` need a `Capacity` field added now (volume/weight), or is `calculateLoadPlan`
deferred until that's available?
---
+26 -9
View File
@@ -7,13 +7,18 @@
## Goal
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half
of [mainPlan.md §1](./mainPlan.md#1-order-management):
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
---
## Background / Context
Today `Order` has no status and no link to a ride. This step is where the order lifecycle
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)).
---
@@ -21,39 +26,51 @@
### Delivery Task (per order on ride)
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`,
`Failed`).
### Delivery Outcome
Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder`
for the shortfall.
### Customer Confirmation
Signature or OTP captured against the delivery task before it can be marked complete.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` |
| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) |
| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` |
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP |
---
## Business Rules
- A delivery task cannot be marked `Delivered` without a customer confirmation.
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape).
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle
location per [02_stock_ledger.md](./02_stock_ledger.md).
---
## Fraud / Control Considerations
- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not
editable after submission).
---
## Open Questions
- Signature capture (image) or OTP (SMS code) — one, or both, in v1?
---
+27 -9
View File
@@ -7,13 +7,18 @@
## Goal
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is
generated immediately, and payment is captured at point of sale. Covers `createInvoice`,
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting),
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
(§11) if promotions are live by the time this ships.
---
## Background / Context
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row —
the field invoice *is* the order, created and settled in one step.
---
@@ -21,39 +26,52 @@
### Field Invoice
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`,
`OnCredit`).
### Field Invoice Line Item
`FieldInvoiceId`, `ItemId`, `Quantity`, `UnitPrice` (subject to the discount-limit rules in
[08_sales_agent_controls.md](./08_sales_agent_controls.md)).
### Payment Record
`FieldInvoiceId`, `Amount`, `Method` (`Cash`, `Credit`), `CollectedAt`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here |
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` |
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits |
---
## Business Rules
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the
invoice is finalized.
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location,
immediately (not batched at ride close).
- Discount applied per line must not exceed the agent's role-level limit (§8).
---
## Fraud / Control Considerations
- Cash collected must reconcile against invoiced totals at ride close — see
[07_ride_reconciliation.md](./07_ride_reconciliation.md).
---
## Open Questions
- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one
sequence?
---
+31 -10
View File
@@ -7,13 +7,18 @@
## Goal
Physically count what's left on the vehicle at ride close and reconcile it against the stock
ledger and cash collected. Covers `runCycleCount` and `reconcileStock` from
[mainPlan.md §6](./mainPlan.md#6-warehouse-management), applied to a
vehicle location instead of a warehouse zone.
---
## Background / Context
This is the control point that catches loading discrepancies ([04_loading_event.md](./04_loading_event.md))
that went unresolved, and any shortfall between what was sold/delivered and what's physically on
the vehicle.
---
@@ -21,45 +26,61 @@
### Ride Close Summary
`RideId`, `ClosedAt`, `ClosedByUserId`, computed totals for the reconciliation equation below.
### Variance Record
`RideId`, `ItemId` (or `Cash` for the monetary variance), `ExpectedQuantity`/`ExpectedAmount`,
`ActualQuantity`/`ActualAmount`, `Variance`, `ReasonCode`, `Status` (`Pending`, `Approved`,
`Rejected`).
### Supervisor Approval
Every non-zero variance requires a supervisor-level approval (`Role.Level >= 20`) before the ride
can transition to `Closed`.
---
## The Reconciliation Equation
```
Opening Stock − Sales − Damages + Returns = Closing Stock (physical count) [runCycleCount / reconcileStock]
Cash Collected + Credit Extended = Total Sales − Returns
```
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `runCycleCount(warehouseId, zoneId)` (§6) | `POST /api/rides/{id}/cycle-count` — `zoneId` maps to the ride's vehicle location |
| `reconcileStock(warehouseId, countResults)` (§6) | `POST /api/rides/{id}/reconcile` |
| — | `POST /api/rides/{id}/variances/{id}/approve` — supervisor sign-off |
| — | `POST /api/rides/{id}/close` — only succeeds once every variance is `Approved` |
---
## Business Rules
- A ride cannot close with an unexplained variance — every `Variance` row must be `Approved` or
`Rejected` (with the discrepancy converted to a stock `Adjustment` movement) before close.
- `reconcileStock` writes an `Adjustment` movement per variance line, never edits the running
balance directly (see [02_stock_ledger.md](./02_stock_ledger.md)).
---
## Fraud / Control Considerations
- Unresolved/high-frequency discrepancies are logged permanently against the agent's record — feeds
the anomaly detection in [09_fraud_controls.md](./09_fraud_controls.md).
---
## Open Questions
- Is there a variance threshold below which supervisor approval is auto-granted (e.g. rounding-
level cash differences), or does every non-zero variance require explicit sign-off?
---
+34 -9
View File
@@ -7,13 +7,21 @@
## Goal
Wire role levels to concrete business limits and approval chains. Covers the approval-routing half
of [mainPlan.md §1](./mainPlan.md#1-order-management)
(`submitOrderForApproval`, `approveOrder`, `rejectOrder`) and the performance-tracking functions
from [§10 SFA/CRM](./mainPlan.md#10-sales-force-automation-sfa--crm)
(`trackTargetVsAchievement`, `calculateIncentive`). Lead/interaction functions from §10
(`logCustomerInteraction`, `createLead`, `convertLeadToStore`) are out of scope here — see
[16_sfa_crm.md](./16_sfa_crm.md).
---
## Background / Context
`Role.Level` already exists and drives the permission seed
(`Data/Seed/RoleAndPermissionSeed.cs`), but nothing today turns a level into a discount limit,
credit limit, or approval threshold — it only gates which API endpoints a role can call.
---
@@ -21,39 +29,56 @@
### Agent Limit Configuration (per role level)
`RoleId`, `MaxDiscountPercent`, `MaxSingleTransactionValue` — read by both
[06_sale_ride.md](./06_sale_ride.md) (line-item discount check) and order approval routing here.
### Override Log
`OverrideById` (the supervisor), `TargetType`/`TargetId` (e.g. `Order`, `FieldInvoice`), `OldValue`,
`NewValue`, `Reason`, `CreatedAt` — a one-time exception, logged against the supervisor's account.
### Customer Credit Profile
Reuses `Store.CreditLimit`/`Store.OutstandingBalance` once added (gap #8 in
[00_existing_gaps.md](./00_existing_gaps.md)) rather than a separate entity.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `submitOrderForApproval(orderId)` (§1) | Triggered automatically when an order exceeds its agent's limit |
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` (§1) | `POST /api/orders/{id}/approve`, `POST /api/orders/{id}/reject` |
| `trackTargetVsAchievement(userId, period)` (§10) | `GET /api/agents/{userId}/performance?period=...` |
| `calculateIncentive(userId, period)` (§10) | `GET /api/agents/{userId}/incentive?period=...` |
| — | `POST /api/orders/{id}/override` — supervisor one-time exception, writes an Override Log entry |
---
## Business Rules
- An order/invoice whose value or discount exceeds the agent's `Agent Limit Configuration` cannot
proceed without either supervisor approval or a logged override — no silent pass-through.
- `calculateIncentive` reads only settled/closed rides (post-[07_ride_reconciliation.md](./07_ride_reconciliation.md)),
never in-flight ones.
---
## Fraud / Control Considerations
- Every `Override Log` entry is permanent and immutable once written — feeds
[09_fraud_controls.md](./09_fraud_controls.md)'s anomaly detection (agents with unusually
frequent overrides).
---
## Open Questions
- Are discount/transaction limits configured per `Role`, or can an individual `User` have a
tighter/looser override of their role's default?
- What sales target source feeds `trackTargetVsAchievement` — is there a target-setting endpoint
needed here, or does it come from §2's `setRegionalTarget` rolled down to the individual?
---
+39 -10
View File
@@ -7,13 +7,18 @@
## Goal
A dedicated, immutable audit log plus automated anomaly detection over everything built in steps
01–08. Covers [mainPlan.md §18](./mainPlan.md#18-compliance--audit)
(`logAuditEvent`, `checkLicenseCompliance`, `generateComplianceReport`) and the fraud-relevant piece
of §15 (`sendRouteDeviationAlert`) and §12 (return-without-prior-sale detection).
---
## Background / Context
Every prior step in this plan references "logged" or "flagged" behavior (override logs, loading
discrepancies, reconciliation variances) — this step is where that logging actually becomes a
queryable, immutable audit trail instead of scattered per-feature notes.
---
@@ -21,45 +26,69 @@
### Audit Log Entry
`UserId`, `Action`, `EntityType`, `EntityId`, `OldValue`/`NewValue` (JSON snapshot), `CreatedAt`,
`SourceIp`/`RequestId` if available. Append-only — no update or delete path exposed anywhere.
### Anomaly Flag
`Type` (`ReturnWithoutSale`, `RouteDeviation`, `HighVarianceRate`, `CashShortfall`), `EntityType`/
`EntityId`, `RaisedAt`, `Status` (`Open`, `Reviewed`, `Dismissed`).
### Return Approval Record
Reuses the `approveReturn` flow from [18_returns_claims.md](./18_returns_claims.md) — this step
adds the automated pre-check (`ReturnWithoutSale`) that runs before a human approver sees it.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `logAuditEvent(userId, action, entityType, entityId)` (§18) | Internal — called by every mutating service, not directly exposed |
| `checkLicenseCompliance(entityId, licenseType)` (§18) | `GET /api/compliance/{entityType}/{entityId}/license-check` |
| `generateComplianceReport(period)` (§18) | `GET /api/compliance/report?period=...` |
| `sendRouteDeviationAlert(routeId, salesRepId)` (§15) | Triggered from route check-in/out data (§3 of the function reference; not yet in any numbered step — see [11_route_sales_management.md](./11_route_sales_management.md)) |
| — | `GET /api/audit-log?entityType=...&entityId=...` |
| — | `GET /api/anomaly-flags?status=Open` |
---
## Automated Checks & Flags
- Returns without a matching prior sale (`ReturnWithoutSale`).
- Sales/deliveries outside the geofenced route (`RouteDeviation`) — depends on GPS check-in/out
data from [11_route_sales_management.md](./11_route_sales_management.md).
- Agents with consistently high variance rates across closed rides (`HighVarianceRate`) — reads
`Variance Record` history from [07_ride_reconciliation.md](./07_ride_reconciliation.md).
- Cash collected below invoiced amount (`CashShortfall`) — reads `Payment Record` from
[06_sale_ride.md](./06_sale_ride.md).
---
## Business Rules
- Every state-changing action across every feature must call `logAuditEvent` — this is the one
cross-cutting requirement every other numbered step inherits once this lands.
- Return approval requires supervisor sign-off before stock is credited back
([18_returns_claims.md](./18_returns_claims.md)), regardless of whether an anomaly flag fired.
---
## Fraud / Control Considerations
- Anomaly reports are surfaced to area managers on a scheduled basis — batch job or on-demand
endpoint, not real-time push, in v1.
---
## Open Questions
- Should `logAuditEvent` be wired in as a cross-cutting concern (e.g. an EF `SaveChanges`
interceptor) rather than an explicit call in every service, to guarantee nothing is missed?
- What licenses does `checkLicenseCompliance` actually need to validate for this business — is
regulated goods handling in scope at all, or is this function speculative until a real
requirement shows up?
---
+71
View File
@@ -0,0 +1,71 @@
# 10 — Region Management (Beyond CRUD)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Region CRUD already exists (`Features/Region`). This step covers everything in
[mainPlan.md §2](./mainPlan.md#2-region-management) that isn't plain
CRUD: targets, budgets, agency mapping, tax rules, hierarchy, and dashboarding.
---
## Background / Context
`Region` today has only a `Name`. Nothing points to it except `Route.FK_RegionId`. There is no
`Area` level (see Open Question in [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)).
---
## Data Model Changes
### Regional Target / Budget
`RegionId`, `Period` (e.g. `2026-08`), `TargetValue`, `BudgetAmount`.
### Agency-Region Map
`RegionId`, `AgencyId` — depends on whether "Regional Agency" becomes a distinct entity or stays a
`Role` (see Open Questions).
### Regional Tax Rule
`RegionId`, `RuleConfig` (JSON) — deferred to whenever tax/pricing rule overrides are prioritized.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignAreaToRegion(areaId, regionId)` | Blocked — no `Area` entity exists (see [00_existing_gaps.md](./00_existing_gaps.md#9-no-area-entity-between-region-and-route)) |
| `setRegionalTarget(regionId, period, targetValue)` | `PUT /api/regions/{id}/targets/{period}` |
| `setRegionalBudget(regionId, period, budgetAmount)` | `PUT /api/regions/{id}/budgets/{period}` |
| `mapAgencyToRegion(agencyId, regionId)` | `POST /api/regions/{id}/agencies` |
| `setRegionalTaxRule(regionId, ruleConfig)` | `PUT /api/regions/{id}/tax-rule` |
| `getRegionHierarchy(regionId)` | `GET /api/regions/{id}/hierarchy` |
| `getRegionDashboard(regionId, period)` | `GET /api/regions/{id}/dashboard?period=...` |
---
## Business Rules
- Only `RegionalManager` and above (per role level in `Data/Seed/RoleAndPermissionSeed.cs`) can
set targets/budgets for their own region; only `RegionalAgency` can set them across regions.
---
## Open Questions
- Is "Regional Agency" a business entity distinct from the `RegionalAgency` role (e.g. a contracted
third party with its own record), or is the role sufficient?
- Does `getRegionDashboard` need real-time aggregation, or a scheduled/cached rollup?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,71 @@
# 11 — Route & Sales Visit Management
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Route CRUD already exists (`Features/Route`). This step covers the sales-visit half of
[mainPlan.md §3](./mainPlan.md#3-route-management) — the part that is
about a SalesRep's daily visit schedule, not the ride/dispatch route from
[03_ride_lifecycle.md](./03_ride_lifecycle.md)'s `planDeliveryRoute`.
---
## Background / Context
Today a `Store` has a single `FK_RouteId` (bulk-assign not yet supported via API — see gap #2 in
[00_existing_gaps.md](./00_existing_gaps.md)), and route access is only granted via `UserRouteScope`
(a many-to-many grant), not single ownership. There is no visit check-in/out data anywhere.
---
## Data Model Changes
### Route Ownership
`RouteId`, `OwnerUserId` — single-owner assignment, distinct from the broader `UserRouteScope`
access grant which may cover multiple users (e.g. a Supervisor overseeing several reps' routes).
### Route Visit Log
`RouteId`, `StoreId`, `UserId`, `CheckInAt`, `CheckInGps`, `CheckOutAt` — one row per planned visit.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `assignOutletsToRoute(routeId, outletIds[])` | `POST /api/routes/{id}/stores` — bulk version of the existing single `Store.RouteId` field |
| `assignRouteToSalesRep(routeId, salesRepId)` | `PUT /api/routes/{id}/owner` |
| `optimizeRouteSequence(routeId)` | `POST /api/routes/{id}/optimize` |
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | `POST /api/routes/{id}/visits/check-in` |
| `recordRouteCheckOut(routeId, outletId, timestamp)` | `POST /api/routes/{id}/visits/check-out` |
| `getRouteAdherence(routeId, date)` | `GET /api/routes/{id}/adherence?date=...` |
| `reassignRoute(routeId, newSalesRepId)` | `PUT /api/routes/{id}/owner` (same as assign, different actor) |
| `getRoutePerformance(routeId, period)` | `GET /api/routes/{id}/performance?period=...` |
---
## Business Rules
- Check-in `gpsCoords` feeds `sendRouteDeviationAlert` in
[09_fraud_controls.md](./09_fraud_controls.md) — a check-in materially outside the store's
registered location should flag, not silently accept.
- `getStoreVisitHistory` (§4) reads directly from `Route Visit Log` — no separate storage.
---
## Open Questions
- Is route sequence optimization (`optimizeRouteSequence`) a manual reorder endpoint, or does it
need actual distance/priority-based computation (external mapping API)?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+257
View File
@@ -0,0 +1,257 @@
# 12 — Store Management (Beyond CRUD)
**Status**: Done — migration applied to the database
**Depends On**: 00 — Existing Gap Fixes, 10 — Region Management, 11 — Route & Sales Management
---
## Goal
Store CRUD already exists (`Features/Store`). This step adds everything in
[mainPlan.md §4](./mainPlan.md#4-store-outlet-management) that isn't plain CRUD: credit control,
KYC verification, segmentation, outstanding balance, visit history, scheme eligibility, and a
sales dashboard.
---
## Background / Context
`Store` currently has no credit limit, outstanding balance, segment, or KYC fields — flagged as
gap #8 in [00_existing_gaps.md](./00_existing_gaps.md). Credit fields exist informally today as
`OneTimeAmount` / `CreditAmount`, which stay as-is; this step adds the real credit-limit +
outstanding-balance + KYC-gated-ordering flow on top.
`getStoreVisitHistory` and `checkStoreSchemeEligibility` are read-only integration points into
modules that don't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) for
visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes). Implement them now
with the query shape defined below; wire the actual join once those modules land — return an empty
list / `eligible: true` (no active schemes to check) until then, guarded by a `NotImplementedYet`
style short-circuit, not a broken query.
---
## Data Model Changes
### Store — New Fields
```
Store (existing, add columns)
├── CreditLimit (decimal) — default 0. Max outstanding allowed before new orders are blocked.
├── OutstandingBalance (decimal) — default 0. Cached/denormalized. Updated by Finance module
│ (step 19) on invoice/payment events via UpdateOutstandingBalanceAsync.
│ Until Finance exists, only moved by manual adjustment endpoint.
├── Segment (string?) — free text, e.g. "Gold", "Silver", "Key Account", "High Risk".
├── KycStatus (enum) — Pending | Verified | Rejected. Default Pending.
```
### New Enum — KycStatus
```csharp
public enum KycStatus { Pending, Verified, Rejected }
```
### New Entity — StoreKycDocument
```
StoreKycDocument
├── Id (Guid) — PK, from Template
├── FK_StoreId (Guid) — required
├── DocumentType (string) — free text, e.g. "GST Certificate", "PAN Card", "Shop License,NIC"
├── FileReference (string) — relative path under wwwroot/uploads/kyc/{storeId}/
├── UploadedAt (DateTime)
├── IsActive, CreatedAt — from Template
```
### AppSettings — New Config Key
```
StoreManagement:UnverifiedKycOrderThreshold (decimal) — default 0.
Max single-order value a store with KycStatus != Verified may place.
0 disables ordering entirely for unverified stores; set higher to allow small orders through.
```
---
## Migrations
One migration needed:
1. **AddStoreCreditAndKyc** — adds `CreditLimit`, `OutstandingBalance`, `Segment`, `KycStatus`
columns to `Stores`; creates `StoreKycDocuments` table.
---
## API Endpoints
### Existing (unchanged)
| Method | Route | Permission | Notes |
|---|---|---|---|
| GET | /api/stores | stores.view | |
| GET | /api/stores/{id} | stores.view | |
| POST | /api/stores | stores.create | |
| PUT | /api/stores/{id} | stores.update | |
| DELETE | /api/stores/{id} | stores.delete | Soft delete |
### New
| Method | Route | Permission | Notes |
|---|---|---|---|
| POST | /api/stores/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
| GET | /api/stores/{id}/kyc-documents | stores.view | Lists documents for a store |
| PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` |
| PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above |
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
| GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit |
| PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` |
| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands |
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Returns `{ eligible: true, reason: null }` until Step 17 wiring lands |
| GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables |
---
## Request / Response Shapes
### POST `/api/stores/{id}/kyc-documents` (multipart/form-data)
Fields: `documentType` (string), `file` (binary).
Response:
```json
{ "id": "...", "storeId": "...", "documentType": "GST Certificate", "fileReference": "uploads/kyc/{storeId}/{guid}.pdf", "uploadedAt": "2026-08-20T10:00:00" }
```
### PUT `/api/stores/{id}/kyc/verify` request
```json
{ "status": "Verified", "notes": "GST + PAN checked against ERP records" }
```
### PUT `/api/stores/{id}/credit-limit` request
```json
{ "newLimit": 50000, "approverId": "..." }
```
`approverId` must resolve to a user with `RoleLevel >= 20` (Supervisor or above); enforced
server-side from the authenticated caller, not trusted from the body — the field is echoed back
for audit display only.
### GET `/api/stores/{id}/outstanding-balance` response
```json
{ "storeId": "...", "creditLimit": 50000, "outstandingBalance": 32000, "availableCredit": 18000 }
```
### GET `/api/stores/{id}/dashboard` response
```json
{
"storeId": "...",
"period": "2026-08",
"orderCount": 12,
"totalOrderValue": 145000,
"lastOrderDate": "2026-08-18T09:00:00"
}
```
---
## Business Rules
1. `updateStoreCreditLimit` requires the authenticated caller to have `RoleLevel >= 20`
(Supervisor or above) — enforced by the `stores.credit.update` policy plus a role-level check
in the service, same pattern as `StoreService.HasGlobalScope`.
2. A store with `KycStatus != Verified` may only place orders up to
`StoreManagement:UnverifiedKycOrderThreshold`. `OrderService` must check this at order creation
time; orders exceeding the threshold are rejected with a 400. This mirrors the stock-ledger
pattern of rejecting at the point of the write, not after.
3. `deactivateStore` (existing `DELETE /api/stores/{id}`) blocks new orders against the store,
matching the existing `IsActive` soft-delete filter already used in `OrderService`. No separate
reason field is added — soft delete already covers it via the existing `Template.IsActive` flag.
4. `OutstandingBalance` is never edited directly by client-supplied arbitrary values outside the
dedicated endpoint — always through `UpdateOutstandingBalanceAsync`, so Finance (step 19) has a
single integration point to call into later.
5. KYC documents are append-only from the API's perspective — no update endpoint. To replace a
document, upload a new one; old ones stay for audit trail (soft-deletable via `IsActive` if
ever needed).
6. Scope rules for all new endpoints follow the existing `StoreService` pattern: global-scope roles
(`RoleLevel >= 50`) see everything, others are checked via `IScopeAccessService.HasStoreScopeAsync`.
---
## Permissions to Add to Seed
New permission codes to add to the existing seed:
```
stores.kyc.verify, stores.credit.update
```
(`stores.view`, `stores.create`, `stores.update`, `stores.delete` already exist.)
Role assignments:
- **SalesRep (10)**: stores.view (unchanged)
- **Supervisor (20)**: stores.view, stores.update, stores.credit.update
- **AreaManager (30)**: stores.view, stores.update, stores.credit.update, stores.kyc.verify
- **AreaSupervisor (40)**: all store permissions
- **RegionalManager (50)**: all store permissions
- **RegionalAgency (60)**: all store permissions
---
## Open Questions
- None. Resolved in planning discussion (2026-08-20): OutstandingBalance is cached/denormalized;
KYC document type is free text with local disk storage; unverified stores are gated by a
configurable order-value threshold rather than a hard block.
---
## Implementation Notes
### File structure created / touched
```
Features/
Store/
StoreController.cs (extended — credit-limit, balance, kyc/verify, segment,
visit-history, scheme-eligibility, dashboard endpoints)
StoreDTOs.cs (extended — new request/response records)
StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check)
IStoreRepository.cs (extended — GetOrdersForDashboardAsync)
StoreRepository.cs (extended)
Kyc/
StoreKycDocumentController.cs
StoreKycDocumentDTOs.cs
StoreKycDocumentService.cs
IStoreKycDocumentRepository.cs
StoreKycDocumentRepository.cs
Order/
OrderService.cs (extended — KycStatus + UnverifiedKycOrderThreshold gate in CreateAsync)
Models/
Store.cs (add CreditLimit, OutstandingBalance, Segment, KycStatus columns)
StoreKycDocument.cs (new)
Enums.cs (add KycStatus enum)
Migrations/
AddStoreCreditAndKyc
appsettings.json
add "StoreManagement": { "UnverifiedKycOrderThreshold": 0 }
Program.cs
register IStoreKycDocumentRepository, StoreKycDocumentRepository, StoreKycDocumentService
```
KYC documents live as a sub-namespace `Features/Store/Kyc/` rather than a top-level feature,
matching the one-feature-per-folder convention (`Features/Store`, `Features/Stock`) — they only
ever belong to a Store and have no independent lifecycle.
### KYC upload logic (pseudo)
```
POST /api/stores/{id}/kyc-documents — multipart (documentType, file):
1. Load Store by id, reject if missing/inactive or caller lacks store scope
2. Ensure wwwroot/uploads/kyc/{storeId}/ exists
3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}"
4. Insert StoreKycDocument row, commit in the same UoW transaction
```
### Order-creation KYC gate (pseudo)
```
OrderService.CreateAsync — before inserting the order:
1. Load Store by request.StoreId
2. If Store.KycStatus != Verified:
threshold = config["StoreManagement:UnverifiedKycOrderThreshold"]
if request.TotalAmount > threshold: reject (return null → 403 at controller)
```
</content>
+78
View File
@@ -0,0 +1,78 @@
# 13 — Fleet Management (Beyond CRUD)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
---
## Goal
Vehicle CRUD already exists (`Features/Vehicle`). This step covers everything in
[mainPlan.md §5](./mainPlan.md#5-vehicle--fleet-management) that isn't
plain CRUD or already scoped into [03_ride_lifecycle.md](./03_ride_lifecycle.md)/
[04_loading_event.md](./04_loading_event.md): maintenance, fuel, telemetry, and utilization.
---
## Background / Context
`Vehicle` today only has `RegNumber`/`NumberPlate`. No capacity, ownership, maintenance, or fuel
data exists. There is no `Driver` entity — see gap #10 in
[00_existing_gaps.md](./00_existing_gaps.md).
---
## Data Model Changes
### Vehicle — New Fields
`CapacityVolume`, `CapacityWeight` (needed by `calculateLoadPlan` in
[04_loading_event.md](./04_loading_event.md)), `OwnershipType` (`Owned`, `Leased`, `Contracted`).
### Maintenance Task
`VehicleId`, `MaintenanceType`, `DueAt` (date or mileage-based), `CompletedAt`.
### Fuel Log
`VehicleId`, `Amount`, `Liters`, `Date`.
### Telemetry Record
`VehicleId`, `GpsCoords`, `Speed`, `Timestamp` — high-volume; likely a separate store/retention
policy from the rest of the schema (see Open Questions).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `scheduleMaintenance(vehicleId, maintenanceType, dueAt)` | `POST /api/vehicles/{id}/maintenance` |
| `logFuelExpense(vehicleId, amount, liters, date)` | `POST /api/vehicles/{id}/fuel-logs` |
| `recordVehicleTelemetry(vehicleId, gpsCoords, speed, timestamp)` | `POST /api/vehicles/{id}/telemetry` — likely ingested via [22_integration_api.md](./22_integration_api.md) rather than direct client calls |
| `getVehicleUtilization(vehicleId, period)` | `GET /api/vehicles/{id}/utilization?period=...` |
| `retireVehicle(vehicleId, reason)` | Extends existing `DELETE /api/vehicles/{id}` with a reason field |
---
## Business Rules
- `retireVehicle` must be blocked while the vehicle is on an open/active ride
([03_ride_lifecycle.md](./03_ride_lifecycle.md)).
- Overdue `Maintenance Task`s should block a vehicle from being assigned to a new ride — exact
enforcement level (hard block vs. warning) to confirm.
---
## Open Questions
- Is real-time telemetry (`recordVehicleTelemetry`) actually in scope, or is odometer
start/end (`DeliveryRun.Meterstart`/`Meterend`, already modeled) sufficient for this business?
- What third-party telematics provider, if any, is being integrated (affects [22_integration_api.md](./22_integration_api.md))?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,353 @@
# 14 — Pricing & Supplier Master Data
**Status**: Done — migration applied to the database
**Depends On**: 01 — Item Catalog (Done)
---
## Goal
Covers the remainder of [mainPlan.md §7](./mainPlan.md#7-master-data-management)
not already handled by [01_item_catalog.md](./01_item_catalog.md): price lists and supplier master
records. `createProduct`/`deactivateSku` live in 01; this step is everything else in §7.
---
## Background / Context
`Item.SalePrice` (from [01_item_catalog.md](./01_item_catalog.md)) is a single flat, optional price
— `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
### 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
```
### PriceListItem
```
PriceListItem
├── Id (Guid) — PK, from Template
├── FK_PriceListId (Guid) — required
├── 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
├── 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
```
---
## Migrations
One migration needed:
1. **AddPricingAndSuppliers** — creates `PriceLists`, `PriceListItems`, `Suppliers` tables.
---
## API Endpoints
> 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
> authentication only until seeding is turned on.
### 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
1. `applyPricingAndDiscounts` resolves the unit price for an order line in this order:
region-scoped active price list → global active price list → `Item.SalePrice` → reject if all
three are absent (order creation requires an explicit price, per rule 10 of
[01_item_catalog.md](./01_item_catalog.md)).
- "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
- None. Resolved in planning discussion (2026-08-20): price lists are 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
### 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.
+74
View File
@@ -0,0 +1,74 @@
# 15 — Procurement / Purchasing
**Status**: Not Started
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
---
## Goal
Covers [mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) in full
— an entirely new subsystem, nothing in this area exists in the current schema.
---
## Background / Context
Inbound stock (`createGoodsReceipt` in [04_loading_event.md](./04_loading_event.md)/
[mainPlan.md §6](./mainPlan.md#6-warehouse-management)) currently has no
purchase order to receive against — this step is what creates that PO in the first place.
---
## Data Model Changes
### Purchase Requisition
`RequestedByUserId`, `Status` (`Draft`, `Submitted`, `Approved`, `Rejected`), line items
(`ItemId`, `Quantity`).
### Purchase Order
`SupplierId`, `RequisitionId` (nullable — a PO can be raised without a prior requisition), `Status`
(`Issued`, `PartiallyReceived`, `Received`, `Closed`), line items (`ItemId`, `Quantity`, `UnitCost`).
### Purchase Return
`PurchaseOrderId`, reason, line items — goods sent back to the supplier post-receipt.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createPurchaseRequisition(items[], requestedBy)` | `POST /api/purchase-requisitions` |
| `createPurchaseOrder(supplierId, items[])` | `POST /api/purchase-orders` |
| `matchInvoiceToPO(invoiceId, poId, grnId)` | `POST /api/purchase-orders/{id}/match-invoice` |
| `evaluateSupplier(supplierId, criteria)` | `POST /api/suppliers/{id}/evaluate` |
| `createPurchaseReturn(poId, items[], reason)` | `POST /api/purchase-orders/{id}/returns` |
---
## Business Rules
- `createGoodsReceipt` (§6, [04_loading_event.md](./04_loading_event.md)'s warehouse-inbound
counterpart) must reference an `Issued`/`PartiallyReceived` Purchase Order — no receipts against
a closed or nonexistent PO.
- `matchInvoiceToPO` (3-way match) must block payment posting in
[19_finance_accounting.md](./19_finance_accounting.md) until PO, GRN, and invoice quantities/
amounts reconcile within a configurable tolerance.
---
## Open Questions
- Is procurement in scope for this project at all right now, or is inbound stock assumed to be
seeded/manual for the foreseeable future? Confirm before investing in this step — it's the
largest net-new subsystem in the reference doc.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+68
View File
@@ -0,0 +1,68 @@
# 16 — Sales Force Automation (SFA) / CRM
**Status**: Not Started
**Depends On**: 12 — Store Management
---
## Goal
Covers the lead/interaction half of
[mainPlan.md §10](./mainPlan.md#10-sales-force-automation-sfa--crm):
`logCustomerInteraction`, `createLead`, `convertLeadToStore`. Performance/incentive functions
(`trackTargetVsAchievement`, `calculateIncentive`) are already scoped in
[08_sales_agent_controls.md](./08_sales_agent_controls.md) — not repeated here.
---
## Background / Context
There is no `Lead` entity or interaction log anywhere in the schema today — new outlet onboarding
currently goes straight to `POST /api/stores` with no pre-qualification step.
---
## Data Model Changes
### Lead
`ProspectName`, `ContactInfo`, `Notes`, `Status` (`New`, `Qualified`, `Converted`, `Rejected`),
`AssignedToUserId`.
### Customer Interaction
`StoreId` (nullable — can be logged against a `Lead` instead), `LeadId` (nullable), `UserId`,
`Notes`, `CreatedAt`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `logCustomerInteraction(storeId, notes)` | `POST /api/stores/{id}/interactions` |
| `createLead(prospectData)` | `POST /api/leads` |
| `convertLeadToStore(leadId)` | `POST /api/leads/{id}/convert` — creates a `Store` via the existing `POST /api/stores` path internally |
---
## Business Rules
- `convertLeadToStore` reuses `createStore` (§4, [12_store_management.md](./12_store_management.md))
rather than duplicating store-creation logic — a `Lead` conversion is not a separate creation
path.
- Exactly one of `StoreId`/`LeadId` must be set on a `Customer Interaction` row, never both, never
neither.
---
## Open Questions
- Does a `Lead` need its own scope/authorization model (region/route), or does it inherit the
creating user's scope with no further restriction until converted?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,70 @@
# 17 — Promotions & Trade Scheme Management
**Status**: Not Started
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
---
## Goal
Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management)
in full — an entirely new subsystem. Once built, `applyPricingAndDiscounts` (§1) and
`checkStoreSchemeEligibility` (§4) both read from it.
---
## Background / Context
No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this
doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source
— it only enforces the flat per-role discount ceiling from
[08_sales_agent_controls.md](./08_sales_agent_controls.md).
---
## Data Model Changes
### Scheme
`Name`, `RuleType` (`BuyXGetY`, `SlabDiscount`, `FlatPercent`), `RuleConfig` (JSON), `Eligibility`
(store segment / region scope), `DateRangeFrom`, `DateRangeTo`.
### Scheme Redemption
`SchemeId`, `OrderId` (or `FieldInvoiceId`), `DiscountAmount`, `RedeemedAt` — feeds
`trackSchemeSpend` and `getSchemeROI` without re-scanning every order.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` |
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` |
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` |
---
## Business Rules
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless explicitly flagged as an
agent-limit-exempt scheme.
- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data
source before implementing (see Open Questions).
---
## Open Questions
- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme
is created, or a trailing-period average computed automatically?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+74
View File
@@ -0,0 +1,74 @@
# 18 — Returns & Claims Management
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
---
## Goal
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
both reference this step's credit note and approval shapes — this is where those are actually
defined.
---
## Background / Context
No return, credit note, or claim entity exists today. `createBackorder` (§1,
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
---
## Data Model Changes
### Return Request
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
`Rejected`), `RequestedByUserId`.
### Credit Note
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`.
### Claim
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` |
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
---
## Business Rules
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
the `ReturnWithoutSale` anomaly flagged automatically in
[09_fraud_controls.md](./09_fraud_controls.md).
- Approving a return creates a `Return` stock movement crediting the relevant location, per
[02_stock_ledger.md](./02_stock_ledger.md).
---
## Open Questions
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
also be raised against an internal warehouse/supplier discrepancy?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,75 @@
# 19 — Finance & Accounting
**Status**: Not Started
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 18 — Returns & Claims Management
---
## Goal
Covers [mainPlan.md §13](./mainPlan.md#13-finance--accounting) beyond
what [06_sale_ride.md](./06_sale_ride.md) already scoped for field invoices
(`createInvoice`/`recordPayment` there are the van-sale-specific case; this step is the general
warehouse-fulfilled-order invoicing path plus GL posting and reporting).
---
## Background / Context
`convertOrderToInvoice` is referenced from [05_delivery_ride.md](./05_delivery_ride.md) but has no
home until this step. There is no invoice, payment, or GL entity for warehouse-fulfilled
(non-field-sale) orders today.
---
## Data Model Changes
### Invoice
`OrderId` (nullable if it originates from a field invoice instead), `StoreId`, `TotalAmount`,
`TaxAmount`, `Status` (`Issued`, `PartiallyPaid`, `Paid`), `IssuedAt`.
### Payment
`InvoiceId`, `Amount`, `Method`, `CollectedAt` — same shape as the `Payment Record` in
[06_sale_ride.md](./06_sale_ride.md); consider whether to unify into one table keyed by a generic
`InvoiceId` covering both field invoices and warehouse invoices.
### GL Posting
`TransactionId`, `Account`, `DebitAmount`, `CreditAmount`, `PostedAt`.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` |
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |
---
## Business Rules
- `Store.OutstandingBalance` (from [12_store_management.md](./12_store_management.md)) is
maintained by this step's invoice/payment events, not edited directly.
- `postToGeneralLedger` must be idempotent per `TransactionId` — replays must not double-post.
---
## Open Questions
- Should field invoices ([06_sale_ride.md](./06_sale_ride.md)) and warehouse invoices share one
`Invoice` table, or stay as separate entities that both feed the same GL posting logic?
- Is e-invoicing (`generateTaxInvoice` for a specific jurisdiction like GST/VAT) an actual near-term
requirement, or speculative until a specific market is targeted?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,65 @@
# 20 — Reporting, Analytics & BI
**Status**: Not Started
**Depends On**: Every prior numbered step whose data it reports on (this step is a read-only
aggregation layer, not a new source of truth)
---
## Goal
Covers [mainPlan.md §14](./mainPlan.md#14-reporting-analytics--bi) in
full. This step deliberately reads data other steps already own — it does not introduce new
mutable entities.
---
## Background / Context
Individual dashboards are already referenced piecemeal elsewhere (`getRegionDashboard` in
[10_region_management.md](./10_region_management.md), `getStoreSalesDashboard` in
[12_store_management.md](./12_store_management.md), `getWarehouseKpis` in
[04_loading_event.md](./04_loading_event.md)/[07_ride_reconciliation.md](./07_ride_reconciliation.md)
context). This step is the role-aware, cross-cutting dashboard/report layer that sits above all of
them.
---
## Data Model Changes
None expected — this step reads existing tables. If a specific report proves too expensive to
compute live, a cached/materialized summary table may be added per-report, decided when that report
is actually built.
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `getDashboardData(role, scope)` | `GET /api/dashboard?scope=...` — role taken from the authenticated user, not a request param |
| `generateKpiReport(kpiType, filters)` | `GET /api/reports/{kpiType}?filters=...` |
| `getPredictiveAlert(type, scope)` | `GET /api/alerts/predictive?type=...&scope=...` — explicitly flagged below as the highest-risk item in this step |
---
## Business Rules
- `getDashboardData` must respect the same scope rules (`IScopeAccessService`) as every other
endpoint — a dashboard is not an exception to region/route/store scoping.
---
## Open Questions
- `getPredictiveAlert` implies a forecasting/ML component that doesn't exist anywhere in this
codebase today — is this in scope at all for the near term, or should it be dropped from this
step until a concrete predictive use case and data volume exist to justify it?
- Which specific KPIs matter first (fill rate, OOS%, credit risk are mentioned in the reference —
confirm the actual priority list with the user before building `generateKpiReport`)?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
@@ -0,0 +1,67 @@
# 21 — Notifications & Alerts
**Status**: Not Started
**Depends On**: 02 — Stock Ledger, 12 — Store Management, 08 — Sales Agent Controls
---
## Goal
Covers [mainPlan.md §15](./mainPlan.md#15-notifications--alerts) in full
— a cross-cutting delivery mechanism (email/SMS/push) triggered by events other steps already
produce.
---
## Background / Context
No notification channel exists in this codebase today. This step is purely the delivery mechanism
and trigger wiring; the underlying conditions it watches for are already defined elsewhere
(reorder point in [02_stock_ledger.md](./02_stock_ledger.md), credit limit in
[12_store_management.md](./12_store_management.md), route deviation logic in
[09_fraud_controls.md](./09_fraud_controls.md)).
---
## Data Model Changes
### Notification
`RecipientUserId`, `Type`, `Payload` (JSON), `Channel` (`Email`, `SMS`, `Push`, `InApp`), `SentAt`,
`ReadAt` (nullable).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `sendLowStockAlert(warehouseId, sku)` | Internal — triggered when a stock movement crosses the reorder point set in [02_stock_ledger.md](./02_stock_ledger.md) |
| `sendCreditLimitBreachAlert(storeId)` | Internal — triggered on order/invoice creation when `Store.OutstandingBalance` exceeds `Store.CreditLimit` |
| `sendApprovalPendingNotification(approverId, requestId)` | Internal — triggered by `submitOrderForApproval` ([08_sales_agent_controls.md](./08_sales_agent_controls.md)) and return approval ([18_returns_claims.md](./18_returns_claims.md)) |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Internal — triggered from [09_fraud_controls.md](./09_fraud_controls.md)'s anomaly flag |
| — | `GET /api/notifications` — recipient's own notification inbox |
| — | `PUT /api/notifications/{id}/read` |
---
## Business Rules
- Every `send*` function in §15 is a trigger, not a user-facing endpoint — the only client-facing
endpoints are inbox read/list.
- Notification delivery failures (e.g. SMS provider down) must not roll back the business
transaction that triggered them — fire-and-forget, logged separately.
---
## Open Questions
- Which channels are actually needed at launch — in-app only, or SMS/email/push from day one? This
significantly changes the infrastructure needed (third-party provider integration, see
[22_integration_api.md](./22_integration_api.md)).
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+64
View File
@@ -0,0 +1,64 @@
# 22 — Integration & API Layer
**Status**: Not Started
**Depends On**: 19 — Finance & Accounting, 13 — Fleet Management
---
## Goal
Covers [mainPlan.md §16](./mainPlan.md#16-integration--api-layer) in full
— outbound/inbound integration with third-party systems.
---
## Background / Context
No external system integration exists today. Each function here depends on a specific named
third-party provider (accounting platform, telematics provider, B2B portal) that hasn't been
chosen yet — this step cannot be scoped precisely until that choice is made per Open Questions.
---
## Data Model Changes
### Integration Sync Log
`TargetSystem`, `EntityType`, `EntityId`, `Status` (`Success`, `Failed`, `Retrying`), `AttemptedAt`,
`ErrorDetail` (nullable).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `syncToAccountingSystem(transactionId, targetSystem)` | Internal/background job — triggered on GL posting ([19_finance_accounting.md](./19_finance_accounting.md)) |
| `syncTelematicsData(vehicleId, provider)` | Internal/background job — feeds `recordVehicleTelemetry` ([13_fleet_management.md](./13_fleet_management.md)) |
| `pushOrderToDistributorPortal(orderId)` | Internal/background job — triggered on order status change ([05_delivery_ride.md](./05_delivery_ride.md)) |
| — | `GET /api/integrations/sync-log?targetSystem=...` — for support/debugging |
---
## Business Rules
- Every outbound sync must be retryable and idempotent — a failed sync logs to `Integration Sync
Log` and retries rather than silently dropping.
- No integration should be able to block the originating business transaction (order creation, GL
posting) — sync happens after commit, asynchronously.
---
## Open Questions
- Which accounting platform (QuickBooks/SAP/Xero — reference doc names all three as examples), and
which telematics provider — is either actually contracted yet, or is this step purely
speculative until a vendor is chosen?
- Does a "distributor-facing B2B portal" already exist to push orders to, or is this describing a
future/external system not yet built?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+66
View File
@@ -0,0 +1,66 @@
# 23 — Mobile & Offline Capability
**Status**: Not Started
**Depends On**: 06 — Sale Ride Execution, 05 — Delivery Ride Execution
---
## Goal
Covers [mainPlan.md §17](./mainPlan.md#17-mobile--offline-capability) in
full — offline-first support for field agents (drivers/sales reps) whose connectivity can't be
assumed during a ride.
---
## Background / Context
There is no mobile client in this repository — this step is the server-side contract a future
mobile client would sync against (queueing, conflict resolution, barcode resolution), not a client
implementation itself.
---
## Data Model Changes
### Offline Action Queue Entry
`DeviceId`, `UserId`, `ActionType` (e.g. `CreateFieldInvoice`, `RecordDelivery`), `Payload` (JSON),
`ClientCreatedAt`, `SyncedAt` (nullable), `ConflictStatus` (`None`, `Resolved`, `Unresolved`).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `queueOfflineOrder(orderData)` | Client-side only — server sees it as a normal write once synced |
| `syncOfflineQueue(deviceId)` | `POST /api/sync?deviceId=...` — batch endpoint accepting queued actions, applying them in order, returning per-action success/conflict |
| `scanBarcode(barcodeValue)` | `GET /api/items/by-barcode/{barcodeValue}` — resolves to an `Item` (needs a `Barcode` field added to `Item`, see [01_item_catalog.md](./01_item_catalog.md)) |
---
## Business Rules
- Every action type accepted by `syncOfflineQueue` must be safely replayable (idempotent by a
client-generated id) — a duplicate sync of the same queued action must not double-apply (e.g.
double-decrement stock).
- A sync conflict (e.g. stock sold twice from two devices while offline) must not silently pick a
winner — flagged `Unresolved` and surfaced to a supervisor, consistent with the reconciliation
approach in [07_ride_reconciliation.md](./07_ride_reconciliation.md).
---
## Open Questions
- Is a mobile client actually planned/being built by this team, or is offline support scoped for a
hypothetical future client? This changes whether the sync contract needs to be finalized now or
can wait.
- Does `Item` need a `Barcode` field added now (extends [01_item_catalog.md](./01_item_catalog.md)),
or is barcode scanning deferred with the rest of this step?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
+234
View File
@@ -13,6 +13,7 @@
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
| Unit of Work + Repositories | Done | Transaction support throughout |
| 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)
@@ -44,6 +45,239 @@ They must be resolved before new feature steps begin because later steps depend
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_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) |
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_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) |
| 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) |
| 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) |
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
| 21 | Notifications & Alerts | Not Started | 02, 12, 08 | [21_notifications_alerts.md](./21_notifications_alerts.md) |
| 22 | Integration & API Layer | Not Started | 19, 13 | [22_integration_api.md](./22_integration_api.md) |
| 23 | Mobile & Offline Capability | Not Started | 05, 06 | [23_mobile_offline.md](./23_mobile_offline.md) |
Steps 00–09 are the original ride/stock-ledger roadmap and keep their strict dependency order.
Steps 10–23 fill in every remaining area from the [Function Reference by Feature](#function-reference-by-feature)
section above that wasn't already covered by 00–09; they depend only on the specific earlier steps
noted above, not on the full 00–09 chain, and can be resequenced relative to each other as
priorities become clearer.
---
## Function Reference by Feature
Reference only, not a build plan — background material to consult while planning/implementing the
steps above and below. Service-layer function names and what each should do when implemented.
Names are illustrative (camelCase) — adapt to your language/framework conventions. None of the
functions below are implemented yet unless noted. User Management/RBAC functions aren't repeated
here — see `SRS_User_Management_Module.pdf`.
### 1. Order Management
| Function | What it does | Status |
|---|---|---|
| `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 - `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). |
| `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). |
| `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. |
| `convertOrderToInvoice(orderId)` | Generates the invoice once the order is dispatched/delivered. | Not implemented — depends on §13 Finance & Accounting. |
| `createBackorder(orderId, unfulfilledItems[])` | Splits unfulfillable quantity into a linked backorder. | Not implemented. |
| `cancelOrder(orderId, reason)` | Cancels an order pre-dispatch and releases any reserved stock. | Not implemented — `OrderController` supports soft delete only, no reason code or stock release. |
| `getOrdersByStore(storeId)` / `getOrdersBySalesRep(salesRepId)` | Scoped order listings. | `getOrdersByStore` implemented (`GET /api/orders/store/{storeId}`). `getOrdersBySalesRep` not implemented. |
| `getOrderAnalytics(filters)` | Returns fill rate, average order value, rejection rate over a period. | Not implemented. |
### 2. Region Management
| Function | What it does | Status |
|---|---|---|
| `createRegion(name, boundaries)` | Creates a new region record. | Partially implemented — `RegionController` supports create with `Name` only, no `boundaries` field. |
| `assignAreaToRegion(areaId, regionId)` | Links an existing area under a region. | Not implemented — no `Area` entity exists in the schema. |
| `setRegionalTarget(regionId, period, targetValue)` | Sets the sales target for a region for a given period. | Not implemented. |
| `setRegionalBudget(regionId, period, budgetAmount)` | Allocates budget at the regional level. | Not implemented. |
| `mapAgencyToRegion(agencyId, regionId)` | Assigns a Regional Agency's contract scope to a region. | Not implemented — `RegionalAgency` is currently a `Role`, not a distinct agency entity; scope is via `UserRegionScope`. |
| `setRegionalTaxRule(regionId, ruleConfig)` | Overrides tax/pricing rules for a specific region. | Not implemented. |
| `getRegionHierarchy(regionId)` | Returns all areas/territories nested under a region. | Not implemented — see [user_management_PLAN.md](../user_management_PLAN.md) territory hierarchy prerequisite. |
| `getRegionDashboard(regionId, period)` | Aggregated KPIs: sales, fill rate, agency performance for the region. | Not implemented. |
### 3. Route Management
| Function | What it does | Status |
|---|---|---|
| `createRoute(name, areaId)` | Creates a new sales visit route within an area. | Partially implemented — `RouteController` supports create, but by `RegionId` not `areaId` (no `Area` entity exists). |
| `assignOutletsToRoute(routeId, outletIds[])` | Maps a set of stores to a route. | Partially implemented — a `Store` has a single `FK_RouteId`, set via `StoreController` create/update; no bulk-assign endpoint. |
| `assignRouteToSalesRep(routeId, salesRepId)` | Assigns ownership of a route to an SR. | Not implemented — only `UserRouteScope` exists (scope grant), not a single-owner assignment. |
| `optimizeRouteSequence(routeId)` | Reorders the visit sequence for efficiency (distance/priority-based). | Not implemented. |
| `recordRouteCheckIn(routeId, outletId, gpsCoords, timestamp)` | Logs an SR's arrival at an outlet. | Not implemented. |
| `recordRouteCheckOut(routeId, outletId, timestamp)` | Logs departure and closes out the visit. | Not implemented. |
| `getRouteAdherence(routeId, date)` | Compares planned vs. actual visits for a route/day. | Not implemented — depends on check-in/check-out above. |
| `reassignRoute(routeId, newSalesRepId)` | Moves a route to a different SR. | Not implemented. |
| `getRoutePerformance(routeId, period)` | Sales per route, strike rate, missed-visit count. | Not implemented. |
| `planDeliveryRoute(warehouseId, orderIds[])` | Builds the warehouse-to-outlet dispatch route (separate from the sales visit route). | Not implemented — this is the `DeliveryRun` concept; see [03_ride_lifecycle.md](./03_ride_lifecycle.md). |
### 4. Store (Outlet) Management
| Function | What it does | Status |
|---|---|---|
| `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. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
| `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. | 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.). | 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. |
| `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. | 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. | 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
| Function | What it does | Status |
|---|---|---|
| `registerVehicle(vehicleData)` | Creates the vehicle master record (registration, capacity, ownership). | Partially implemented — `VehicleController` create exists with `RegNumber`/`NumberPlate` only, no capacity/ownership fields. |
| `assignVehicleToRoute(vehicleId, routeId)` | Assigns a vehicle to a delivery/sales route. | Not implemented — `Vehicle` has no route link; only `DeliveryRun.FK_RouteId`/`FK_VehicleId` model this, and `DeliveryRun` is unwired (see [03_ride_lifecycle.md](./03_ride_lifecycle.md)). |
| `assignDriverToVehicle(vehicleId, driverId)` | Links a driver to a vehicle, checking license validity. | Not implemented — no `Driver` entity exists yet (`DeliveryRun.FK_DriverId` is commented out in the model). |
| `scheduleMaintenance(vehicleId, maintenanceType, dueAt)` | Sets up a preventive maintenance task (mileage/calendar-based). | Not implemented. |
| `logFuelExpense(vehicleId, amount, liters, date)` | Records a fuel transaction against the vehicle. | Not implemented. |
| `recordVehicleTelemetry(vehicleId, gpsCoords, speed, timestamp)` | Ingests telematics data (location, driver behavior). | Not implemented — see §16 Integration. |
| `checkDriverCompliance(driverId)` | Validates license/permit status before dispatch. | Not implemented — depends on a `Driver` entity. |
| `calculateLoadPlan(vehicleId, orderIds[])` | Matches order volume/weight to available vehicle capacity. | Not implemented — depends on [04_loading_event.md](./04_loading_event.md). |
| `getVehicleUtilization(vehicleId, period)` | Utilization and cost-per-delivery metrics. | Not implemented. |
| `retireVehicle(vehicleId, reason)` | Removes a vehicle from active fleet, retaining history. | Partially implemented — `DELETE /api/vehicles/{id}` soft-deletes via `IsActive`, no reason code captured. |
### 6. Warehouse Management
| Function | What it does | Status |
|---|---|---|
| `createGoodsReceipt(poId, receivedItems[])` | Records inbound stock against a purchase order (GRN). | Not implemented — depends on §9 Procurement. |
| `inspectGoodsReceipt(grnId, inspectionResult)` | Logs quality-check outcome before putaway. | Not implemented. |
| `putawayStock(grnId, locationAssignments[])` | Assigns received stock to warehouse locations (zone/ABC-based). | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
| `getStockByLocation(warehouseId, sku)` | Returns real-time quantity and location for a SKU. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
| `generatePickList(orderIds[])` | Creates a pick list/task for one or more orders. | Not implemented — overlaps with [04_loading_event.md](./04_loading_event.md). |
| `confirmPicking(pickListId, pickedItems[])` | Marks items as picked, decrementing available stock. | Not implemented. |
| `packOrder(orderId, packageDetails)` | Records packing completion and package details. | Not implemented. |
| `createCrossDockTransfer(inboundId, outboundOrderId)` | Routes inbound stock directly to an outbound order, skipping storage. | Not implemented. |
| `runCycleCount(warehouseId, zoneId)` | Initiates a scheduled partial stock count. | Not implemented — overlaps with [07_ride_reconciliation.md](./07_ride_reconciliation.md) physical count concept. |
| `reconcileStock(warehouseId, countResults)` | Adjusts system stock to match physical count results. | Not implemented — see [07_ride_reconciliation.md](./07_ride_reconciliation.md). |
| `buildDispatchLoad(vehicleId, orderIds[])` | Groups orders into a dispatch load for a specific vehicle/route. | Not implemented — see [04_loading_event.md](./04_loading_event.md). |
| `receiveReturn(returnId, items[], condition)` | Processes returned goods back into the warehouse. | Not implemented — see §12 Returns & Claims. |
| `getWarehouseKpis(warehouseId, period)` | Dock-to-stock time, pick accuracy, turnover rate. | Not implemented. |
### 7. Master Data Management
| Function | What it does | Status |
|---|---|---|
| `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. |
| `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. | Implemented — `PUT /api/price-lists/{id}/items/{sku}`. |
| `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
| Function | What it does | Status |
|---|---|---|
| `getMultiLocationStock(sku)` | Returns stock levels for a SKU across all warehouses/agencies. | Not implemented — see [02_stock_ledger.md](./02_stock_ledger.md). |
| `setReorderPoint(sku, warehouseId, threshold)` | Configures the auto-replenishment trigger level. | Not implemented. |
| `createStockTransfer(fromWarehouseId, toWarehouseId, items[])` | Initiates an inter-warehouse stock movement. | Not implemented. |
| `trackBatchExpiry(sku, batchNo, expiryDate)` | Registers batch/expiry data for FEFO enforcement. | Not implemented. |
| `calculateStockValuation(warehouseId, method)` | Computes stock value using FIFO/weighted-average. | Not implemented. |
### 9. Procurement / Purchasing
| Function | What it does | Status |
|---|---|---|
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Not implemented. |
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Not implemented. |
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Not implemented. |
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Not implemented. |
### 10. Sales Force Automation (SFA) / CRM
| Function | What it does | Status |
|---|---|---|
| `logCustomerInteraction(storeId, notes)` | Records a visit note or customer interaction. | Not implemented. |
| `trackTargetVsAchievement(userId, period)` | Compares a user's sales target to actuals. | Not implemented. |
| `calculateIncentive(userId, period)` | Computes commission/incentive payout based on performance. | Not implemented. |
| `createLead(prospectData)` | Captures a prospective new outlet. | Not implemented — no `Lead` entity exists. |
| `convertLeadToStore(leadId)` | Converts a qualified lead into an active store record. | Not implemented. |
### 11. Promotions & Trade Scheme Management
| Function | What it does | Status |
|---|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Not implemented — no scheme entity exists. |
| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Not implemented. |
| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Not implemented. |
| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Not implemented. |
| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Not implemented. |
### 12. Returns & Claims Management
| Function | What it does | Status |
|---|---|---|
| `createReturnRequest(orderId, items[], reasonCode)` | Initiates a return against a delivered order. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) for the fraud-control angle on returns. |
| `approveReturn(returnId, approverId)` | Approves/rejects the return request. | Not implemented. |
| `generateCreditNote(returnId)` | Issues a credit note against the original invoice. | Not implemented. |
| `createClaim(agencyId, claimDetails)` | Raises a claim against a Regional Agency (e.g., damaged stock). | Not implemented. |
| `settleClaim(claimId, settlementAmount)` | Closes out a claim with a final settlement amount. | Not implemented. |
### 13. Finance & Accounting
| Function | What it does | Status |
|---|---|---|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no invoice entity exists. |
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Not implemented. |
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented. |
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
### 14. Reporting, Analytics & BI
| Function | What it does | Status |
|---|---|---|
| `getDashboardData(role, scope)` | Returns the role-appropriate dashboard payload. | Not implemented. |
| `generateKpiReport(kpiType, filters)` | Produces a specific KPI report (fill rate, OOS%, credit risk, etc.). | Not implemented. |
| `getPredictiveAlert(type, scope)` | Returns AI-flagged issues, e.g. a region's declining fill rate. | Not implemented. |
### 15. Notifications & Alerts
| Function | What it does | Status |
|---|---|---|
| `sendLowStockAlert(warehouseId, sku)` | Notifies relevant roles when stock drops below the reorder point. | Not implemented. |
| `sendCreditLimitBreachAlert(storeId)` | Notifies approvers when a store exceeds its credit limit. | Not implemented. |
| `sendApprovalPendingNotification(approverId, requestId)` | Notifies an approver of a pending action. | Not implemented. |
| `sendRouteDeviationAlert(routeId, salesRepId)` | Flags when an SR deviates materially from the planned route. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md) geofencing check. |
### 16. Integration & API Layer
| Function | What it does | Status |
|---|---|---|
| `syncToAccountingSystem(transactionId, targetSystem)` | Pushes a transaction to an external accounting platform (QuickBooks/SAP/Xero). | Not implemented. |
| `syncTelematicsData(vehicleId, provider)` | Pulls GPS/telematics data from a third-party provider. | Not implemented. |
| `pushOrderToDistributorPortal(orderId)` | Syncs order status to a distributor-facing B2B portal. | Not implemented. |
### 17. Mobile & Offline Capability
| Function | What it does | Status |
|---|---|---|
| `queueOfflineOrder(orderData)` | Stores an order locally on the device when offline. | Not implemented — no mobile client in this repository. |
| `syncOfflineQueue(deviceId)` | Pushes queued offline actions to the server once connectivity returns. | Not implemented. |
| `scanBarcode(barcodeValue)` | Resolves a scanned barcode to a SKU or warehouse location. | Not implemented. |
### 18. Compliance & Audit
| Function | What it does | Status |
|---|---|---|
| `logAuditEvent(userId, action, entityType, entityId)` | Writes an immutable audit trail entry for a state-changing action. | Not implemented — see [09_fraud_controls.md](./09_fraud_controls.md), which scopes an audit log as part of Fraud Controls. |
| `checkLicenseCompliance(entityId, licenseType)` | Validates a required license/permit is current (e.g., regulated goods). | Not implemented. |
| `generateComplianceReport(period)` | Produces a report of compliance status across the org for a period. | Not implemented. |
---