Merge pull request 'Feat/procurement' (#8) from feat/procurement into dev
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -41,6 +41,12 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
|
||||||
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
public DbSet<LoadingSheet> LoadingSheets { get; set; }
|
||||||
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
|
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
|
||||||
|
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
|
||||||
|
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||||||
|
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
|
||||||
|
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
|
||||||
|
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
|
||||||
|
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -160,6 +166,78 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
|||||||
.HasForeignKey(i => i.FK_ItemId)
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrder>()
|
||||||
|
.HasOne(po => po.Supplier)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(po => po.FK_SupplierId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasOne(i => i.PurchaseOrder)
|
||||||
|
.WithMany(po => po.Items)
|
||||||
|
.HasForeignKey(i => i.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
|
.HasOne(gr => gr.PurchaseOrder)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceipt>()
|
||||||
|
.HasOne(gr => gr.Warehouse)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(gr => gr.FK_WarehouseId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasOne(i => i.GoodsReceipt)
|
||||||
|
.WithMany(gr => gr.Items)
|
||||||
|
.HasForeignKey(i => i.FK_GoodsReceiptId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasOne(i => i.PurchaseOrderItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturn>()
|
||||||
|
.HasOne(pr => pr.PurchaseOrder)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(pr => pr.FK_PurchaseOrderId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
|
.HasOne(i => i.PurchaseReturn)
|
||||||
|
.WithMany(pr => pr.Items)
|
||||||
|
.HasForeignKey(i => i.FK_PurchaseReturnId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
|
.HasOne(i => i.Item)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FK_ItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseOrderItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_GoodsReceiptId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<GoodsReceiptItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseOrderItemId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PurchaseReturnItem>()
|
||||||
|
.HasIndex(i => i.FK_PurchaseReturnId);
|
||||||
|
|
||||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(id, request, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
|
||||||
|
|
||||||
|
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
|
public record GrnItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderItemId,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
int AcceptedQuantity,
|
||||||
|
int RejectedQuantity);
|
||||||
|
|
||||||
|
public record GrnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
Guid ReceivedByUserId,
|
||||||
|
Guid WarehouseId,
|
||||||
|
string WarehouseName,
|
||||||
|
DateTime ReceivedAt,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<GrnItemResponse> Items);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||||
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.GoodsReceipt entity) =>
|
||||||
|
await context.GoodsReceipts.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive);
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.GoodsReceiptItems
|
||||||
|
.Include(i => i.PurchaseOrderItem)
|
||||||
|
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
|
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public class GrnService(
|
||||||
|
IGrnRepository repo,
|
||||||
|
IPoRepository poRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
|
||||||
|
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
|
||||||
|
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
|
||||||
|
|
||||||
|
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
||||||
|
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||||
|
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var gr = await repo.GetByIdAsync(id);
|
||||||
|
if (gr is null || !gr.IsActive) return null;
|
||||||
|
return Map(gr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
||||||
|
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
|
||||||
|
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await poRepo.GetByIdAsync(purchaseOrderId)
|
||||||
|
?? throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (!po.IsActive)
|
||||||
|
throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot receive goods against a purchase order in {po.Status} status.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||||
|
|
||||||
|
var goodsReceipt = new Models.GoodsReceipt
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = po.Id,
|
||||||
|
FK_ReceivedByUserId = receivedByUserId,
|
||||||
|
FK_WarehouseId = request.WarehouseId,
|
||||||
|
ReceivedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||||
|
|
||||||
|
if (poItem.IsCancelled)
|
||||||
|
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
|
||||||
|
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||||
|
|
||||||
|
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
||||||
|
|
||||||
|
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||||
|
|
||||||
|
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderItemId = poItem.Id,
|
||||||
|
AcceptedQuantity = line.AcceptedQuantity,
|
||||||
|
RejectedQuantity = line.RejectedQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||||
|
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
|
||||||
|
? PurchaseOrderStatus.Received
|
||||||
|
: PurchaseOrderStatus.PartiallyReceived;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(goodsReceipt);
|
||||||
|
|
||||||
|
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
||||||
|
{
|
||||||
|
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
|
||||||
|
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
||||||
|
if (balance is null)
|
||||||
|
{
|
||||||
|
await stockRepo.AddBalanceAsync(new StockBalance
|
||||||
|
{
|
||||||
|
FK_ItemId = poItem.FK_ItemId,
|
||||||
|
LocationId = request.WarehouseId,
|
||||||
|
LocationType = LocationType.Warehouse,
|
||||||
|
Quantity = line.AcceptedQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
balance.Quantity += line.AcceptedQuantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = poItem.FK_ItemId,
|
||||||
|
ToLocationId = request.WarehouseId,
|
||||||
|
ToLocationType = LocationType.Warehouse,
|
||||||
|
Quantity = line.AcceptedQuantity,
|
||||||
|
Type = MovementType.PurchaseReceipt,
|
||||||
|
ReferenceId = goodsReceipt.Id,
|
||||||
|
CreatedBy = receivedByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
poRepo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
|
public interface IGrnRepository
|
||||||
|
{
|
||||||
|
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
|
Task AddAsync(Models.GoodsReceipt entity);
|
||||||
|
|
||||||
|
/// <summary>True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard).</summary>
|
||||||
|
Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId);
|
||||||
|
|
||||||
|
/// <summary>Sum of AcceptedQuantity per ItemId across all receipts for a Po.</summary>
|
||||||
|
Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
using Distribution.Features.Store;
|
using Distribution.Features.Store;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -12,6 +13,7 @@ public class OrderService(
|
|||||||
IOrderRepository repo,
|
IOrderRepository repo,
|
||||||
IStoreRepository storeRepo,
|
IStoreRepository storeRepo,
|
||||||
IItemRepository itemRepo,
|
IItemRepository itemRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
PriceListService priceListService,
|
PriceListService priceListService,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
@@ -56,6 +58,19 @@ public class OrderService(
|
|||||||
return requestedUnitPrice.Value;
|
return requestedUnitPrice.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
|
||||||
|
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
|
||||||
|
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
|
||||||
|
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
|
||||||
|
{
|
||||||
|
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
|
||||||
|
var totalAvailable = balances.Sum(b => b.Quantity);
|
||||||
|
return quantity <= totalAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<OrderResponse>> GetAllAsync()
|
public async Task<List<OrderResponse>> GetAllAsync()
|
||||||
{
|
{
|
||||||
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
|
||||||
@@ -116,6 +131,8 @@ public class OrderService(
|
|||||||
var orderItems = new List<Models.OrderItem>();
|
var orderItems = new List<Models.OrderItem>();
|
||||||
foreach (var i in request.Items)
|
foreach (var i in request.Items)
|
||||||
{
|
{
|
||||||
|
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
|
||||||
|
|
||||||
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
|
||||||
if (unitPrice is null) return null;
|
if (unitPrice is null) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public interface IPoRepository
|
||||||
|
{
|
||||||
|
Task<Models.PurchaseOrder?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status);
|
||||||
|
Task AddAsync(Models.PurchaseOrder entity);
|
||||||
|
void Update(Models.PurchaseOrder entity);
|
||||||
|
|
||||||
|
Task AddItemAsync(PurchaseOrderItem entity);
|
||||||
|
|
||||||
|
/// <summary>True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier.</summary>
|
||||||
|
Task<bool> HasActiveForSupplierAsync(Guid supplierId);
|
||||||
|
|
||||||
|
/// <summary>All active PurchaseOrders for a supplier, with items, used by evaluateSupplier.</summary>
|
||||||
|
Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/purchase-orders")]
|
||||||
|
[Authorize]
|
||||||
|
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
|
||||||
|
Ok(await service.GetAllAsync(supplierId, status));
|
||||||
|
|
||||||
|
[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(CreatePoRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(request, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, UpdatePoRequest 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 }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/close")]
|
||||||
|
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CloseAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CancelAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CancelLineAsync(id, itemId, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/match-invoice")]
|
||||||
|
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
|
||||||
|
{
|
||||||
|
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/resolve-variance")]
|
||||||
|
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ResolveInvoiceVarianceAsync(id, request, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
|
||||||
|
|
||||||
|
public record CreatePoRequest(Guid SupplierId, List<PoItemRequest> Items);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
|
||||||
|
/// null = add a new line to the Po. Only valid while the Po is Issued.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost);
|
||||||
|
|
||||||
|
public record UpdatePoRequest(List<UpdatePoLineRequest> Items);
|
||||||
|
|
||||||
|
public record CancelPoRequest(string Reason);
|
||||||
|
|
||||||
|
public record CancelPoLineRequest(string? Reason);
|
||||||
|
|
||||||
|
public record PoItemResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid ItemId,
|
||||||
|
string Sku,
|
||||||
|
string ItemName,
|
||||||
|
int Quantity,
|
||||||
|
decimal UnitCost,
|
||||||
|
int ReceivedQuantity,
|
||||||
|
int CancelledQuantity,
|
||||||
|
bool IsCancelled);
|
||||||
|
|
||||||
|
public record PoResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid SupplierId,
|
||||||
|
string SupplierName,
|
||||||
|
Guid CreatedByUserId,
|
||||||
|
PurchaseOrderStatus Status,
|
||||||
|
string? CancelReason,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<PoItemResponse> Items);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal Po-vs-Grn quantity/cost reconciliation. Full 3-way match (Po + Grn + Invoice) activates
|
||||||
|
/// once Finance's Invoice entity (step 19, not yet built) exists — until then this compares only
|
||||||
|
/// PurchaseOrderItem vs GoodsReceiptItem.
|
||||||
|
/// </summary>
|
||||||
|
public record InvoiceMatchLineResult(Guid PurchaseOrderItemId, Guid ItemId, int OrderedQuantity, int AcceptedQuantity, decimal UnitCost, bool QuantityMatches);
|
||||||
|
|
||||||
|
public record InvoiceMatchResult(Guid PurchaseOrderId, Guid? GoodsReceiptId, Guid? InvoiceId, bool IsMatched, List<InvoiceMatchLineResult> Lines, string Note);
|
||||||
|
|
||||||
|
public record ResolveInvoiceVarianceRequest(string Variance, string Resolution, decimal? AdjustedUnitCost, Guid? PurchaseOrderItemId);
|
||||||
|
|
||||||
|
public record InvoiceVarianceResolutionResponse(Guid PurchaseOrderId, string Variance, string Resolution, DateTime ResolvedAt);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public class PoRepository(AppDBContext context) : IPoRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.PurchaseOrder?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.PurchaseOrders
|
||||||
|
.Include(po => po.Supplier)
|
||||||
|
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(po => po.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status)
|
||||||
|
{
|
||||||
|
var query = context.PurchaseOrders
|
||||||
|
.Include(po => po.Supplier)
|
||||||
|
.Include(po => po.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(po => po.IsActive)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (supplierId.HasValue) query = query.Where(po => po.FK_SupplierId == supplierId.Value);
|
||||||
|
if (status.HasValue) query = query.Where(po => po.Status == status.Value);
|
||||||
|
|
||||||
|
return await query.OrderByDescending(po => po.CreatedAt).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.PurchaseOrder entity) =>
|
||||||
|
await context.PurchaseOrders.AddAsync(entity);
|
||||||
|
|
||||||
|
public void Update(Models.PurchaseOrder entity) =>
|
||||||
|
context.PurchaseOrders.Update(entity);
|
||||||
|
|
||||||
|
public async Task AddItemAsync(PurchaseOrderItem entity) =>
|
||||||
|
await context.PurchaseOrderItems.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<bool> HasActiveForSupplierAsync(Guid supplierId) =>
|
||||||
|
await context.PurchaseOrders.AnyAsync(po =>
|
||||||
|
po.FK_SupplierId == supplierId &&
|
||||||
|
po.IsActive &&
|
||||||
|
po.Status != PurchaseOrderStatus.Cancelled);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId) =>
|
||||||
|
await context.PurchaseOrders
|
||||||
|
.Include(po => po.Items)
|
||||||
|
.Where(po => po.FK_SupplierId == supplierId && po.IsActive)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
|
public class PoService(
|
||||||
|
IPoRepository repo,
|
||||||
|
IGrnRepository goodsReceiptRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
|
||||||
|
|
||||||
|
private static PoResponse Map(Models.PurchaseOrder po) =>
|
||||||
|
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
|
||||||
|
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
|
||||||
|
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
|
||||||
|
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
|
||||||
|
|
||||||
|
public async Task<PoResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A purchase order must have at least one line item.");
|
||||||
|
|
||||||
|
var po = new Models.PurchaseOrder
|
||||||
|
{
|
||||||
|
FK_SupplierId = request.SupplierId,
|
||||||
|
FK_CreatedByUserId = createdByUserId,
|
||||||
|
Status = PurchaseOrderStatus.Issued
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
po.Items.Add(new PurchaseOrderItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitCost = line.UnitCost
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(po.Id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
|
||||||
|
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (po.Status != PurchaseOrderStatus.Issued)
|
||||||
|
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.PurchaseOrderItemId.HasValue)
|
||||||
|
{
|
||||||
|
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||||
|
|
||||||
|
if (existing.ReceivedQuantity > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
|
||||||
|
|
||||||
|
existing.FK_ItemId = line.ItemId;
|
||||||
|
existing.Quantity = line.Quantity;
|
||||||
|
existing.UnitCost = line.UnitCost;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await repo.AddItemAsync(new PurchaseOrderItem
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = po.Id,
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitCost = line.UnitCost
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(po);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return (await GetByIdAsync(id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
|
||||||
|
|
||||||
|
po.Status = PurchaseOrderStatus.Closed;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
|
||||||
|
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
|
||||||
|
|
||||||
|
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
|
||||||
|
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
|
||||||
|
|
||||||
|
po.Status = PurchaseOrderStatus.Cancelled;
|
||||||
|
po.CancelReason = request.Reason;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity < Quantity
|
||||||
|
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
|
||||||
|
/// every non-cancelled line is fully received.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(id);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||||
|
|
||||||
|
if (line.IsCancelled)
|
||||||
|
throw new InvalidOperationException("This line is already cancelled.");
|
||||||
|
|
||||||
|
if (line.ReceivedQuantity >= line.Quantity)
|
||||||
|
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
|
||||||
|
|
||||||
|
line.IsCancelled = true;
|
||||||
|
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
|
||||||
|
|
||||||
|
var activeLines = po.Items.Where(i => i.IsActive).ToList();
|
||||||
|
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
|
||||||
|
&& activeLines.Any(i => !i.IsCancelled))
|
||||||
|
{
|
||||||
|
po.Status = PurchaseOrderStatus.Received;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(po);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
|
||||||
|
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(poId);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
var receipts = grnId.HasValue
|
||||||
|
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
|
||||||
|
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
|
||||||
|
|
||||||
|
var acceptedByItem = receipts
|
||||||
|
.SelectMany(g => g.Items.Where(i => i.IsActive))
|
||||||
|
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
|
||||||
|
|
||||||
|
var lines = po.Items.Where(i => i.IsActive).Select(i =>
|
||||||
|
{
|
||||||
|
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
|
||||||
|
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var isMatched = lines.All(l => l.QuantityMatches);
|
||||||
|
|
||||||
|
return new InvoiceMatchResult(
|
||||||
|
poId, grnId, invoiceId, isMatched, lines,
|
||||||
|
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
|
||||||
|
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
|
||||||
|
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await repo.GetByIdAsync(poId);
|
||||||
|
if (po is null || !po.IsActive) return null;
|
||||||
|
|
||||||
|
if (request.AdjustedUnitCost.HasValue)
|
||||||
|
{
|
||||||
|
if (!request.PurchaseOrderItemId.HasValue)
|
||||||
|
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
|
||||||
|
|
||||||
|
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
|
||||||
|
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
|
||||||
|
|
||||||
|
line.UnitCost = request.AdjustedUnitCost.Value;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(po); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public interface IPurchaseReturnRepository
|
||||||
|
{
|
||||||
|
Task<Models.PurchaseReturn?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
|
Task AddAsync(Models.PurchaseReturn entity);
|
||||||
|
|
||||||
|
/// <summary>Sum of Quantity per ItemId across all prior returns for a Po.</summary>
|
||||||
|
Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api/purchase-orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|
||||||
|
[HttpPost("api/purchase-orders/{id:guid}/returns")]
|
||||||
|
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CreateAsync(id, request, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
|
||||||
|
|
||||||
|
public record CreatePurchaseReturnRequest(string Reason, List<PurchaseReturnItemRequest> Items);
|
||||||
|
|
||||||
|
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
|
||||||
|
|
||||||
|
public record PurchaseReturnResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseOrderId,
|
||||||
|
string Reason,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<PurchaseReturnItemResponse> Items);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository
|
||||||
|
{
|
||||||
|
public async Task<Models.PurchaseReturn?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.PurchaseReturns
|
||||||
|
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||||
|
.FirstOrDefaultAsync(pr => pr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.PurchaseReturns
|
||||||
|
.Include(pr => pr.Items).ThenInclude(i => i.Item)
|
||||||
|
.Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive)
|
||||||
|
.OrderByDescending(pr => pr.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task AddAsync(Models.PurchaseReturn entity) =>
|
||||||
|
await context.PurchaseReturns.AddAsync(entity);
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||||
|
await context.PurchaseReturnItems
|
||||||
|
.Include(i => i.PurchaseReturn)
|
||||||
|
.Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||||
|
.GroupBy(i => i.FK_ItemId)
|
||||||
|
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) })
|
||||||
|
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Infra.UoW;
|
||||||
|
using Distribution.Models;
|
||||||
|
|
||||||
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
|
public class PurchaseReturnService(
|
||||||
|
IPurchaseReturnRepository repo,
|
||||||
|
IPoRepository poRepo,
|
||||||
|
IGrnRepository goodsReceiptRepo,
|
||||||
|
IStockRepository stockRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
|
{
|
||||||
|
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
|
||||||
|
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity);
|
||||||
|
|
||||||
|
private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) =>
|
||||||
|
new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt,
|
||||||
|
pr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<PurchaseReturnResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var pr = await repo.GetByIdAsync(id);
|
||||||
|
if (pr is null || !pr.IsActive) return null;
|
||||||
|
return Map(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PurchaseReturnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
|
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot
|
||||||
|
/// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance
|
||||||
|
/// and writes a StockMovement in the same unit of work.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PurchaseReturnResponse> CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var po = await poRepo.GetByIdAsync(purchaseOrderId);
|
||||||
|
if (po is null || !po.IsActive)
|
||||||
|
throw new InvalidOperationException("Purchase order not found.");
|
||||||
|
|
||||||
|
if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId))
|
||||||
|
throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it.");
|
||||||
|
|
||||||
|
if (request.Items is null || request.Items.Count == 0)
|
||||||
|
throw new InvalidOperationException("A purchase return must have at least one line item.");
|
||||||
|
|
||||||
|
var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId);
|
||||||
|
var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId);
|
||||||
|
|
||||||
|
// The receipt(s) for this Po share a single receiving warehouse per the current model
|
||||||
|
// (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse
|
||||||
|
// as the stock-decrement location for the return.
|
||||||
|
var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId);
|
||||||
|
var warehouseId = receipts.First().FK_WarehouseId;
|
||||||
|
|
||||||
|
var purchaseReturn = new Models.PurchaseReturn
|
||||||
|
{
|
||||||
|
FK_PurchaseOrderId = purchaseOrderId,
|
||||||
|
Reason = request.Reason
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var line in request.Items)
|
||||||
|
{
|
||||||
|
if (line.Quantity <= 0)
|
||||||
|
throw new InvalidOperationException("Return quantity must be greater than zero.");
|
||||||
|
|
||||||
|
receivedTotals.TryGetValue(line.ItemId, out var received);
|
||||||
|
returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned);
|
||||||
|
var available = received - alreadyReturned;
|
||||||
|
|
||||||
|
if (line.Quantity > available)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available}).");
|
||||||
|
|
||||||
|
purchaseReturn.Items.Add(new Models.PurchaseReturnItem
|
||||||
|
{
|
||||||
|
FK_ItemId = line.ItemId,
|
||||||
|
Quantity = line.Quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.AddAsync(purchaseReturn);
|
||||||
|
|
||||||
|
foreach (var line in purchaseReturn.Items)
|
||||||
|
{
|
||||||
|
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
|
||||||
|
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
|
||||||
|
|
||||||
|
if (balance.Quantity < line.Quantity)
|
||||||
|
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
|
||||||
|
|
||||||
|
balance.Quantity -= line.Quantity;
|
||||||
|
balance.UpdatedAt = DateTime.Now;
|
||||||
|
stockRepo.UpdateBalance(balance);
|
||||||
|
|
||||||
|
await stockRepo.AddMovementAsync(new StockMovement
|
||||||
|
{
|
||||||
|
FK_ItemId = line.FK_ItemId,
|
||||||
|
FromLocationId = warehouseId,
|
||||||
|
FromLocationType = LocationType.Warehouse,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
Type = MovementType.PurchaseReturn,
|
||||||
|
ReasonCode = request.Reason,
|
||||||
|
ReferenceId = purchaseReturn.Id,
|
||||||
|
CreatedBy = createdByUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var saved = await repo.GetByIdAsync(purchaseReturn.Id);
|
||||||
|
return Map(saved!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,18 @@ public class SupplierController(SupplierService service) : ControllerBase
|
|||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
try
|
||||||
return deleted ? NoContent() : NotFound();
|
{
|
||||||
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
return deleted ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/evaluate")]
|
||||||
|
public async Task<IActionResult> Evaluate(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.EvaluateAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
using Distribution.Features.Grn;
|
||||||
|
using Distribution.Features.Po;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.Supplier;
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
public class SupplierService(ISupplierRepository repo, IUnitOfWork uow)
|
public class SupplierService(
|
||||||
|
ISupplierRepository repo,
|
||||||
|
IPoRepository purchaseOrderRepo,
|
||||||
|
IGrnRepository goodsReceiptRepo,
|
||||||
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
private static SupplierResponse Map(Models.Supplier s) =>
|
private static SupplierResponse Map(Models.Supplier s) =>
|
||||||
new(s.Id, s.Name, s.ContactInfo, s.PerformanceScore, s.IsActive, s.CreatedAt);
|
new(s.Id, s.Name, s.ContactInfo, s.PerformanceScore, s.IsActive, s.CreatedAt);
|
||||||
@@ -52,10 +58,83 @@ public class SupplierService(ISupplierRepository repo, IUnitOfWork uow)
|
|||||||
var supplier = await repo.GetByIdAsync(id);
|
var supplier = await repo.GetByIdAsync(id);
|
||||||
if (supplier is null || !supplier.IsActive) return false;
|
if (supplier is null || !supplier.IsActive) return false;
|
||||||
|
|
||||||
|
if (await purchaseOrderRepo.HasActiveForSupplierAsync(id))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cannot delete a supplier that is referenced by an active purchase order.");
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try { repo.Remove(supplier); await uow.CommitAsync(ct); }
|
try { repo.Remove(supplier); await uow.CommitAsync(ct); }
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The only writer of Supplier.PerformanceScore. Computes a simple 0-100 score from this
|
||||||
|
/// supplier's own purchase-order history:
|
||||||
|
/// - lineFulfillmentRatio: received (non-cancelled, fully-received) Po lines / all non-active
|
||||||
|
/// Po lines seen (received + cancelled) - how reliably the supplier fulfils what it commits to.
|
||||||
|
/// - acceptanceRatio: AcceptedQuantity / (AcceptedQuantity + RejectedQuantity) across every
|
||||||
|
/// GoodsReceipt against this supplier's Pos - how much of what arrives is usable.
|
||||||
|
/// score = round(average(lineFulfillmentRatio, acceptanceRatio) * 100, 2).
|
||||||
|
/// Defaults to 50 (neutral) when there is no history yet, rather than hardcoding a fixed score.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SupplierResponse?> EvaluateAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var supplier = await repo.GetByIdAsync(id);
|
||||||
|
if (supplier is null || !supplier.IsActive) return null;
|
||||||
|
|
||||||
|
var purchaseOrders = await purchaseOrderRepo.GetBySupplierAsync(id);
|
||||||
|
|
||||||
|
var receivedLines = 0;
|
||||||
|
var cancelledLines = 0;
|
||||||
|
foreach (var po in purchaseOrders)
|
||||||
|
{
|
||||||
|
foreach (var item in po.Items.Where(i => i.IsActive))
|
||||||
|
{
|
||||||
|
if (item.IsCancelled) cancelledLines++;
|
||||||
|
else if (item.ReceivedQuantity >= item.Quantity) receivedLines++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalAccepted = 0;
|
||||||
|
var totalRejected = 0;
|
||||||
|
foreach (var po in purchaseOrders)
|
||||||
|
{
|
||||||
|
foreach (var receipt in await goodsReceiptRepo.GetByPurchaseOrderIdAsync(po.Id))
|
||||||
|
{
|
||||||
|
foreach (var item in receipt.Items.Where(i => i.IsActive))
|
||||||
|
{
|
||||||
|
totalAccepted += item.AcceptedQuantity;
|
||||||
|
totalRejected += item.RejectedQuantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineFulfillmentRatio = receivedLines + cancelledLines > 0
|
||||||
|
? (decimal)receivedLines / (receivedLines + cancelledLines)
|
||||||
|
: (decimal?)null;
|
||||||
|
|
||||||
|
var acceptanceRatio = totalAccepted + totalRejected > 0
|
||||||
|
? (decimal)totalAccepted / (totalAccepted + totalRejected)
|
||||||
|
: (decimal?)null;
|
||||||
|
|
||||||
|
decimal score;
|
||||||
|
if (lineFulfillmentRatio.HasValue && acceptanceRatio.HasValue)
|
||||||
|
score = Math.Round((lineFulfillmentRatio.Value + acceptanceRatio.Value) / 2m * 100m, 2);
|
||||||
|
else if (lineFulfillmentRatio.HasValue)
|
||||||
|
score = Math.Round(lineFulfillmentRatio.Value * 100m, 2);
|
||||||
|
else if (acceptanceRatio.HasValue)
|
||||||
|
score = Math.Round(acceptanceRatio.Value * 100m, 2);
|
||||||
|
else
|
||||||
|
score = 50m;
|
||||||
|
|
||||||
|
supplier.PerformanceScore = score;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(supplier); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return Map(supplier);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Distribution.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProcurement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PurchaseOrders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_SupplierId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CancelReason = table.Column<string>(type: "longtext", nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PurchaseOrders", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseOrders_Suppliers_FK_SupplierId",
|
||||||
|
column: x => x.FK_SupplierId,
|
||||||
|
principalTable: "Suppliers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GoodsReceipts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_ReceivedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_WarehouseId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
ReceivedAt = 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_GoodsReceipts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GoodsReceipts_PurchaseOrders_FK_PurchaseOrderId",
|
||||||
|
column: x => x.FK_PurchaseOrderId,
|
||||||
|
principalTable: "PurchaseOrders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GoodsReceipts_Warehouses_FK_WarehouseId",
|
||||||
|
column: x => x.FK_WarehouseId,
|
||||||
|
principalTable: "Warehouses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PurchaseOrderItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseOrderId = 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"),
|
||||||
|
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UnitCost = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
|
||||||
|
ReceivedQuantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CancelledQuantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsCancelled = table.Column<bool>(type: "tinyint(1)", 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_PurchaseOrderItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseOrderItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseOrderItems_PurchaseOrders_FK_PurchaseOrderId",
|
||||||
|
column: x => x.FK_PurchaseOrderId,
|
||||||
|
principalTable: "PurchaseOrders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PurchaseReturns",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
Reason = table.Column<string>(type: "longtext", nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PurchaseReturns", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseReturns_PurchaseOrders_FK_PurchaseOrderId",
|
||||||
|
column: x => x.FK_PurchaseOrderId,
|
||||||
|
principalTable: "PurchaseOrders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GoodsReceiptItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseOrderItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
AcceptedQuantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
RejectedQuantity = table.Column<int>(type: "int", 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_GoodsReceiptItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GoodsReceiptItems_GoodsReceipts_FK_GoodsReceiptId",
|
||||||
|
column: x => x.FK_GoodsReceiptId,
|
||||||
|
principalTable: "GoodsReceipts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_GoodsReceiptItems_PurchaseOrderItems_FK_PurchaseOrderItemId",
|
||||||
|
column: x => x.FK_PurchaseOrderItemId,
|
||||||
|
principalTable: "PurchaseOrderItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PurchaseReturnItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||||
|
FK_PurchaseReturnId = 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"),
|
||||||
|
Quantity = table.Column<int>(type: "int", 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_PurchaseReturnItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseReturnItems_Items_FK_ItemId",
|
||||||
|
column: x => x.FK_ItemId,
|
||||||
|
principalTable: "Items",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseReturnItems_PurchaseReturns_FK_PurchaseReturnId",
|
||||||
|
column: x => x.FK_PurchaseReturnId,
|
||||||
|
principalTable: "PurchaseReturns",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GoodsReceiptItems_FK_GoodsReceiptId",
|
||||||
|
table: "GoodsReceiptItems",
|
||||||
|
column: "FK_GoodsReceiptId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GoodsReceiptItems_FK_PurchaseOrderItemId",
|
||||||
|
table: "GoodsReceiptItems",
|
||||||
|
column: "FK_PurchaseOrderItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GoodsReceipts_FK_PurchaseOrderId",
|
||||||
|
table: "GoodsReceipts",
|
||||||
|
column: "FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GoodsReceipts_FK_WarehouseId",
|
||||||
|
table: "GoodsReceipts",
|
||||||
|
column: "FK_WarehouseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseOrderItems_FK_ItemId",
|
||||||
|
table: "PurchaseOrderItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseOrderItems_FK_PurchaseOrderId",
|
||||||
|
table: "PurchaseOrderItems",
|
||||||
|
column: "FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseOrders_FK_SupplierId",
|
||||||
|
table: "PurchaseOrders",
|
||||||
|
column: "FK_SupplierId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseReturnItems_FK_ItemId",
|
||||||
|
table: "PurchaseReturnItems",
|
||||||
|
column: "FK_ItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseReturnItems_FK_PurchaseReturnId",
|
||||||
|
table: "PurchaseReturnItems",
|
||||||
|
column: "FK_PurchaseReturnId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseReturns_FK_PurchaseOrderId",
|
||||||
|
table: "PurchaseReturns",
|
||||||
|
column: "FK_PurchaseOrderId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GoodsReceiptItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PurchaseReturnItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GoodsReceipts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PurchaseOrderItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PurchaseReturns");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PurchaseOrders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,6 +187,72 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("DriverProfiles");
|
b.ToTable("DriverProfiles");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PurchaseOrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ReceivedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_WarehouseId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReceivedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_WarehouseId");
|
||||||
|
|
||||||
|
b.ToTable("GoodsReceipts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("AcceptedQuantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_GoodsReceiptId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PurchaseOrderItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("RejectedQuantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_GoodsReceiptId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PurchaseOrderItemId");
|
||||||
|
|
||||||
|
b.ToTable("GoodsReceiptItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -471,6 +537,135 @@ namespace Distribution.Migrations
|
|||||||
b.ToTable("PriceListItems");
|
b.ToTable("PriceListItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseOrder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("CancelReason")
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_CreatedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_SupplierId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_SupplierId");
|
||||||
|
|
||||||
|
b.ToTable("PurchaseOrders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<int>("CancelledQuantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PurchaseOrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCancelled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ReceivedQuantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("UnitCost")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
b.ToTable("PurchaseOrderItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseReturn", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PurchaseOrderId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PurchaseOrderId");
|
||||||
|
|
||||||
|
b.ToTable("PurchaseReturns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_ItemId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("FK_PurchaseReturnId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FK_ItemId");
|
||||||
|
|
||||||
|
b.HasIndex("FK_PurchaseReturnId");
|
||||||
|
|
||||||
|
b.ToTable("PurchaseReturnItems");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Region", b =>
|
modelBuilder.Entity("Distribution.Models.Region", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1315,6 +1510,44 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_PurchaseOrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.Warehouse", "Warehouse")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_WarehouseId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("PurchaseOrder");
|
||||||
|
|
||||||
|
b.Navigation("Warehouse");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.GoodsReceipt", "GoodsReceipt")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_GoodsReceiptId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.PurchaseOrderItem", "PurchaseOrderItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_PurchaseOrderItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("GoodsReceipt");
|
||||||
|
|
||||||
|
b.Navigation("PurchaseOrderItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
b.HasOne("Distribution.Models.Uom", "BaseUom")
|
||||||
@@ -1442,6 +1675,66 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("PriceList");
|
b.Navigation("PriceList");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseOrder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Supplier", "Supplier")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_SupplierId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Supplier");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_PurchaseOrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("PurchaseOrder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseReturn", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.PurchaseOrder", "PurchaseOrder")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_PurchaseOrderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("PurchaseOrder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Distribution.Models.Item", "Item")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FK_ItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Distribution.Models.PurchaseReturn", "PurchaseReturn")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("FK_PurchaseReturnId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Item");
|
||||||
|
|
||||||
|
b.Navigation("PurchaseReturn");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
|
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
|
||||||
@@ -1724,6 +2017,11 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Vehicle");
|
b.Navigation("Vehicle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Item", b =>
|
modelBuilder.Entity("Distribution.Models.Item", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Conversions");
|
b.Navigation("Conversions");
|
||||||
@@ -1739,6 +2037,16 @@ namespace Distribution.Migrations
|
|||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseOrder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Distribution.Models.PurchaseReturn", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Items");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
modelBuilder.Entity("Distribution.Models.Scheme", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BundleComponents");
|
b.Navigation("BundleComponents");
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ namespace Distribution.Models
|
|||||||
Transfer,
|
Transfer,
|
||||||
Damage,
|
Damage,
|
||||||
Adjustment,
|
Adjustment,
|
||||||
Load
|
Load,
|
||||||
|
PurchaseReceipt,
|
||||||
|
PurchaseReturn
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum LoadingSheetStatus
|
public enum LoadingSheetStatus
|
||||||
@@ -38,4 +40,6 @@ namespace Distribution.Models
|
|||||||
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
|
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
|
||||||
|
|
||||||
public enum CancelRequestStatus { Pending, Approved, Rejected }
|
public enum CancelRequestStatus { Pending, Approved, Rejected }
|
||||||
|
|
||||||
|
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class GoodsReceipt : Template
|
||||||
|
{
|
||||||
|
public Guid FK_PurchaseOrderId { get; set; }
|
||||||
|
[ForeignKey("FK_PurchaseOrderId")]
|
||||||
|
public PurchaseOrder PurchaseOrder { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ReceivedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warehouse the accepted quantities are received into. Not listed as a field in
|
||||||
|
/// docs/15_procurement.md's GoodsReceipt table, but required to write StockBalance/
|
||||||
|
/// StockMovement rows (both keyed by LocationId+LocationType) for the accepted quantities.
|
||||||
|
/// </summary>
|
||||||
|
public Guid FK_WarehouseId { get; set; }
|
||||||
|
[ForeignKey("FK_WarehouseId")]
|
||||||
|
public Warehouse Warehouse { get; set; }
|
||||||
|
|
||||||
|
public DateTime ReceivedAt { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
|
public ICollection<GoodsReceiptItem> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class GoodsReceiptItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_GoodsReceiptId { get; set; }
|
||||||
|
[ForeignKey("FK_GoodsReceiptId")]
|
||||||
|
public GoodsReceipt GoodsReceipt { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_PurchaseOrderItemId { get; set; }
|
||||||
|
[ForeignKey("FK_PurchaseOrderItemId")]
|
||||||
|
public PurchaseOrderItem PurchaseOrderItem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Added to warehouse stock.</summary>
|
||||||
|
public int AcceptedQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Damaged/short at receipt, not added to stock.</summary>
|
||||||
|
public int RejectedQuantity { get; set; } = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class PurchaseOrder : Template
|
||||||
|
{
|
||||||
|
public Guid FK_SupplierId { get; set; }
|
||||||
|
[ForeignKey("FK_SupplierId")]
|
||||||
|
public Supplier Supplier { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Issued;
|
||||||
|
|
||||||
|
public string? CancelReason { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PurchaseOrderItem> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class PurchaseOrderItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_PurchaseOrderId { get; set; }
|
||||||
|
[ForeignKey("FK_PurchaseOrderId")]
|
||||||
|
public PurchaseOrder PurchaseOrder { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Agreed cost per unit at the time the PO was issued.</summary>
|
||||||
|
public decimal UnitCost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Running total, incremented by createGoodsReceipt; drives PurchaseOrder.Status.</summary>
|
||||||
|
public int ReceivedQuantity { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set by cancelPurchaseOrderLine. Outstanding qty for receiving purposes is
|
||||||
|
/// Quantity - ReceivedQuantity - CancelledQuantity.
|
||||||
|
/// </summary>
|
||||||
|
public int CancelledQuantity { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>True once this line is individually cancelled.</summary>
|
||||||
|
public bool IsCancelled { get; set; } = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class PurchaseReturn : Template
|
||||||
|
{
|
||||||
|
public Guid FK_PurchaseOrderId { get; set; }
|
||||||
|
[ForeignKey("FK_PurchaseOrderId")]
|
||||||
|
public PurchaseOrder PurchaseOrder { get; set; }
|
||||||
|
|
||||||
|
public string Reason { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PurchaseReturnItem> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Distribution.Models
|
||||||
|
{
|
||||||
|
public class PurchaseReturnItem : Template
|
||||||
|
{
|
||||||
|
public Guid FK_PurchaseReturnId { get; set; }
|
||||||
|
[ForeignKey("FK_PurchaseReturnId")]
|
||||||
|
public PurchaseReturn PurchaseReturn { get; set; }
|
||||||
|
|
||||||
|
public Guid FK_ItemId { get; set; }
|
||||||
|
[ForeignKey("FK_ItemId")]
|
||||||
|
public Item Item { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Returned quantity, decremented from warehouse stock.</summary>
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ using Distribution.Features.Brand;
|
|||||||
using Distribution.Features.Category;
|
using Distribution.Features.Category;
|
||||||
using Distribution.Features.DriverComplianceConfig;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
using Distribution.Features.DriverProfile;
|
using Distribution.Features.DriverProfile;
|
||||||
|
using Distribution.Features.Grn;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
using Distribution.Features.Order;
|
using Distribution.Features.Order;
|
||||||
using Distribution.Features.PriceList;
|
using Distribution.Features.PriceList;
|
||||||
|
using Distribution.Features.Po;
|
||||||
|
using Distribution.Features.PurchaseReturn;
|
||||||
using Distribution.Features.Region;
|
using Distribution.Features.Region;
|
||||||
using Distribution.Features.LoadingEvent;
|
using Distribution.Features.LoadingEvent;
|
||||||
using Distribution.Features.Ride;
|
using Distribution.Features.Ride;
|
||||||
@@ -63,6 +66,9 @@ builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentReposito
|
|||||||
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
|
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
|
||||||
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
|
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
|
||||||
builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
|
builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
|
||||||
|
builder.Services.AddScoped<IPoRepository, PoRepository>();
|
||||||
|
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
|
||||||
|
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
|
||||||
|
|
||||||
builder.Services.AddScoped<RegionService>();
|
builder.Services.AddScoped<RegionService>();
|
||||||
builder.Services.AddScoped<RouteService>();
|
builder.Services.AddScoped<RouteService>();
|
||||||
@@ -87,6 +93,9 @@ builder.Services.AddScoped<DriverComplianceConfigService>();
|
|||||||
builder.Services.AddScoped<RideService>();
|
builder.Services.AddScoped<RideService>();
|
||||||
builder.Services.AddScoped<LoadingSheetService>();
|
builder.Services.AddScoped<LoadingSheetService>();
|
||||||
builder.Services.AddScoped<SchemeService>();
|
builder.Services.AddScoped<SchemeService>();
|
||||||
|
builder.Services.AddScoped<PoService>();
|
||||||
|
builder.Services.AddScoped<GrnService>();
|
||||||
|
builder.Services.AddScoped<PurchaseReturnService>();
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
|
|||||||
@@ -1,74 +1,413 @@
|
|||||||
# 15 — Procurement / Purchasing
|
# 15 — Procurement / Purchasing
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done — migration applied to the database
|
||||||
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
|
**Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Covers [mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) in full
|
Covers [mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) — inbound stock from suppliers,
|
||||||
— an entirely new subsystem, nothing in this area exists in the current schema.
|
the mirror of Order Management's outbound flow to stores. Purchase Requisition is deliberately
|
||||||
|
**not** part of this scope (see Background/Context) — `createPurchaseOrder` is the entry point.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
Inbound stock (`createGoodsReceipt` in [04_loading_event.md](./04_loading_event.md)/
|
Inbound stock (`createGoodsReceipt` in [04_loading_event.md](./04_loading_event.md)/
|
||||||
[mainPlan.md §6](./mainPlan.md#6-warehouse-management)) currently has no
|
[mainPlan.md §6](./mainPlan.md#6-warehouse-management)) had no purchase order to receive against —
|
||||||
purchase order to receive against — this step is what creates that PO in the first place.
|
this step creates that PO in the first place.
|
||||||
|
|
||||||
|
Sales reps take orders **from** shops (existing `Features/Order`, outbound — decreases stock,
|
||||||
|
references `Store`, prices via `PriceListItem.Price`); procurement is the mirror — bringing stock
|
||||||
|
**in** from suppliers (`Features/PurchaseOrder`, inbound — increases stock, references `Supplier`,
|
||||||
|
costs via `PurchaseOrderItem.UnitCost`). Same header+line-item shape, opposite direction and
|
||||||
|
counterparty, which is why they're separate features rather than one generic "Order" entity.
|
||||||
|
|
||||||
|
### No requisition step — how "raise a request to purchase" is actually handled
|
||||||
|
|
||||||
|
[mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) originally listed
|
||||||
|
`createPurchaseRequisition(items[], requestedBy)` as a candidate function. **Dropped from this
|
||||||
|
app's design** — the request and the decision to buy collapse into one step:
|
||||||
|
|
||||||
|
- Whoever notices stock is low (owner, manager, or eventually an automated low-stock notice) calls
|
||||||
|
`createPurchaseOrder(supplierId, items[])` directly. Issuing the PO **is** the request; it needs
|
||||||
|
no separate approval because only an already-authenticated user can call it (see Permissions —
|
||||||
|
enforcement itself is deferred, matching Supplier/PriceList).
|
||||||
|
- There is no Draft → Submitted → Approved chain because restocking authority isn't delegated down
|
||||||
|
to sales reps the way order-taking is.
|
||||||
|
- A lower-level "we're low on X" signal is `sendLowStockAlert` (§15 Notifications & Alerts, not yet
|
||||||
|
built) — a notification, not a requisition with its own approval workflow.
|
||||||
|
|
||||||
|
### Connections to existing modules
|
||||||
|
|
||||||
|
- **Item Catalog** ([01_item_catalog.md](./01_item_catalog.md)): `PurchaseOrderItem.FK_ItemId`,
|
||||||
|
`GoodsReceiptItem` (via `PurchaseOrderItemId` → `PurchaseOrderItem.FK_ItemId`), and
|
||||||
|
`PurchaseReturnItem.FK_ItemId` all reference the same `Item` table sales orders use — one
|
||||||
|
catalog, two directions of stock movement.
|
||||||
|
- **Stock Ledger** ([02_stock_ledger.md](./02_stock_ledger.md)): procurement writes to the same
|
||||||
|
`StockBalance`/`StockMovement` tables as the rest of the system rather than a separate stock
|
||||||
|
field — `createGoodsReceipt` credits `StockBalance` (`MovementType.PurchaseReceipt`),
|
||||||
|
`createPurchaseReturn` debits it (`MovementType.PurchaseReturn`). No new stock model was needed.
|
||||||
|
- **Pricing** ([14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md)): `PriceListItem.Price`
|
||||||
|
(what you charge a shop) and `PurchaseOrderItem.UnitCost` (what you pay a supplier) are
|
||||||
|
deliberately separate, unlinked fields — procurement never reads or writes `PriceList` rows.
|
||||||
|
Cost-vs-sale margin reporting is left for a later reporting step (see Open Questions).
|
||||||
|
- **Supplier Master Data** ([14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md)):
|
||||||
|
`PurchaseOrder.FK_SupplierId` links every PO to one supplier. `SupplierService.EvaluateAsync`
|
||||||
|
(`evaluateSupplier`) is now the only writer of `Supplier.PerformanceScore`, and
|
||||||
|
`SupplierService.DeleteAsync` now blocks soft-delete if any active `PurchaseOrder` references the
|
||||||
|
supplier — both were previously open items in 14's doc, now closed by this step.
|
||||||
|
- **Order Management** ([mainPlan.md §1](./mainPlan.md#1-order-management)): while implementing
|
||||||
|
this step, `OrderService.CreateAsync` was found to have no `checkStockAvailability` check at all
|
||||||
|
(mainPlan's own documented gap) — fixed alongside this step: it now rejects an order if requested
|
||||||
|
quantity exceeds total on-hand `StockBalance` for the item, summed across all locations. It still
|
||||||
|
does not decrement stock at order time — that stays deferred to loading/delivery (§04-06), per the
|
||||||
|
existing architecture, to avoid double-decrementing once those steps are built.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### Purchase Requisition
|
Six new tables — three header/line-item pairs (mirrors the `PriceList`/`PriceListItem` split).
|
||||||
|
|
||||||
`RequestedByUserId`, `Status` (`Draft`, `Submitted`, `Approved`, `Rejected`), line items
|
### PurchaseOrder
|
||||||
(`ItemId`, `Quantity`).
|
```
|
||||||
|
PurchaseOrder
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SupplierId (Guid) — required
|
||||||
|
├── FK_CreatedByUserId (Guid) — required, the user who issued the PO
|
||||||
|
├── Status (enum) — Issued, PartiallyReceived, Received, Closed, Cancelled
|
||||||
|
├── CancelReason (string?) — set by cancelPurchaseOrder
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
### Purchase Order
|
### PurchaseOrderItem
|
||||||
|
```
|
||||||
|
PurchaseOrderItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_PurchaseOrderId (Guid) — required
|
||||||
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Quantity (int) — ordered quantity
|
||||||
|
├── UnitCost (decimal) — agreed cost per unit at the time the PO was issued
|
||||||
|
├── ReceivedQuantity (int) — running total, incremented by createGoodsReceipt; drives PO Status
|
||||||
|
├── CancelledQuantity (int) — set by cancelPurchaseOrderLine, default 0; outstanding qty for
|
||||||
|
│ receiving = Quantity - ReceivedQuantity - CancelledQuantity
|
||||||
|
├── IsCancelled (bool) — true once this line is individually cancelled, default false
|
||||||
|
```
|
||||||
|
|
||||||
`SupplierId`, `RequisitionId` (nullable — a PO can be raised without a prior requisition), `Status`
|
### GoodsReceipt
|
||||||
(`Issued`, `PartiallyReceived`, `Received`, `Closed`), line items (`ItemId`, `Quantity`, `UnitCost`).
|
```
|
||||||
|
GoodsReceipt
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_PurchaseOrderId (Guid) — required
|
||||||
|
├── FK_ReceivedByUserId (Guid) — required
|
||||||
|
├── FK_WarehouseId (Guid) — required; the warehouse the accepted quantities are received into.
|
||||||
|
│ Not anticipated in the original design — added because
|
||||||
|
│ StockBalance/StockMovement are keyed by (LocationId, LocationType),
|
||||||
|
│ so a receiving location is mandatory to credit stock at all.
|
||||||
|
├── ReceivedAt (DateTime)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
### Purchase Return
|
### GoodsReceiptItem
|
||||||
|
```
|
||||||
|
GoodsReceiptItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_GoodsReceiptId (Guid) — required
|
||||||
|
├── FK_PurchaseOrderItemId (Guid) — required, ties the receipt line back to its ordered line
|
||||||
|
├── AcceptedQuantity (int) — added to warehouse stock
|
||||||
|
├── RejectedQuantity (int) — damaged/short at receipt, default 0, not added to stock
|
||||||
|
```
|
||||||
|
|
||||||
`PurchaseOrderId`, reason, line items — goods sent back to the supplier post-receipt.
|
### PurchaseReturn
|
||||||
|
```
|
||||||
|
PurchaseReturn
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_PurchaseOrderId (Guid) — required
|
||||||
|
├── Reason (string)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
|
### PurchaseReturnItem
|
||||||
|
```
|
||||||
|
PurchaseReturnItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_PurchaseReturnId (Guid) — required
|
||||||
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Quantity (int) — returned quantity, decremented from warehouse stock
|
||||||
|
```
|
||||||
|
Stock is decremented at the warehouse of the PO's most recent `GoodsReceipt` (the current model
|
||||||
|
gives one receiving warehouse per PO in practice; see Open Questions for the multi-warehouse case).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
One migration, applied:
|
||||||
|
1. **AddProcurement** (`20260824110925_AddProcurement`) — created `PurchaseOrders`,
|
||||||
|
`PurchaseOrderItems`, `GoodsReceipts`, `GoodsReceiptItems`, `PurchaseReturns`,
|
||||||
|
`PurchaseReturnItems`, with FKs to `Suppliers`/`Items`/`Warehouses` and indexes on every FK
|
||||||
|
column. Applied via `dotnet ef database update`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
> No permission policy is enforced on any route below — matches the Supplier/PriceList convention
|
||||||
|
> from step 14 (bare `[Authorize]`, authentication required, no per-action policy). See
|
||||||
|
> Permissions to Add to Seed.
|
||||||
|
|
||||||
|
### Purchase Orders (`Features/PurchaseOrder`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `createPurchaseRequisition(items[], requestedBy)` | `POST /api/purchase-requisitions` |
|
|
||||||
| `createPurchaseOrder(supplierId, items[])` | `POST /api/purchase-orders` |
|
| `createPurchaseOrder(supplierId, items[])` | `POST /api/purchase-orders` |
|
||||||
| `matchInvoiceToPO(invoiceId, poId, grnId)` | `POST /api/purchase-orders/{id}/match-invoice` |
|
| `getPurchaseOrder(poId)` | `GET /api/purchase-orders/{id}` |
|
||||||
| `evaluateSupplier(supplierId, criteria)` | `POST /api/suppliers/{id}/evaluate` |
|
| `listPurchaseOrders(filters)` | `GET /api/purchase-orders` |
|
||||||
|
| `updatePurchaseOrder(poId, items[])` | `PUT /api/purchase-orders/{id}` |
|
||||||
|
| `closePurchaseOrder(poId)` | `POST /api/purchase-orders/{id}/close` |
|
||||||
|
| `cancelPurchaseOrder(poId, reason)` | `POST /api/purchase-orders/{id}/cancel` |
|
||||||
|
| `cancelPurchaseOrderLine(poId, poItemId, reason)` | `POST /api/purchase-orders/{id}/items/{itemId}/cancel` |
|
||||||
|
| `matchInvoiceToPO(poId, invoiceId?, grnId?)` | `POST /api/purchase-orders/{id}/match-invoice` |
|
||||||
|
| `resolveInvoiceVariance(poId, variance, resolution)` | `POST /api/purchase-orders/{id}/resolve-variance` |
|
||||||
|
|
||||||
|
### Goods Receipt (`Features/Grn`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `createGoodsReceipt(poId, warehouseId, items[])` | `POST /api/purchase-orders/{id}/goods-receipts` |
|
||||||
|
| `getGoodsReceipt(grnId)` | `GET /api/goods-receipts/{id}` |
|
||||||
|
| `listGoodsReceipts(poId)` | `GET /api/purchase-orders/{id}/goods-receipts` |
|
||||||
|
|
||||||
|
### Purchase Returns (`Features/PurchaseReturn`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
| `createPurchaseReturn(poId, items[], reason)` | `POST /api/purchase-orders/{id}/returns` |
|
| `createPurchaseReturn(poId, items[], reason)` | `POST /api/purchase-orders/{id}/returns` |
|
||||||
|
| `getPurchaseReturn(returnId)` | `GET /api/purchase-returns/{id}` |
|
||||||
|
| `listPurchaseReturns(poId)` | `GET /api/purchase-orders/{id}/returns` |
|
||||||
|
|
||||||
|
### Supplier Evaluation (`Features/Supplier`, extended)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `evaluateSupplier(supplierId)` | `POST /api/suppliers/{id}/evaluate` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / Response Shapes
|
||||||
|
|
||||||
|
### POST `/api/purchase-orders`
|
||||||
|
```json
|
||||||
|
{ "supplierId": "...", "items": [{ "itemId": "...", "quantity": 100, "unitCost": 45.50 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/purchase-orders/{id}/goods-receipts`
|
||||||
|
```json
|
||||||
|
{ "warehouseId": "...", "items": [
|
||||||
|
{ "purchaseOrderItemId": "...", "acceptedQuantity": 95, "rejectedQuantity": 5 }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
`rejectedQuantity` defaults to 0 if omitted; only `acceptedQuantity` is credited to
|
||||||
|
`StockBalance`.
|
||||||
|
|
||||||
|
### POST `/api/purchase-orders/{id}/returns`
|
||||||
|
```json
|
||||||
|
{ "reason": "Damaged in transit", "items": [{ "itemId": "...", "quantity": 10 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/purchase-orders/{id}/resolve-variance`
|
||||||
|
```json
|
||||||
|
{ "variance": "Quantity mismatch: PO 100, GRN 95", "resolution": "Accepted — short shipment",
|
||||||
|
"adjustedUnitCost": null, "purchaseOrderItemId": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- `createGoodsReceipt` (§6, [04_loading_event.md](./04_loading_event.md)'s warehouse-inbound
|
1. `createPurchaseOrder` is the entry point to procurement — no prior approval step. A PO starts
|
||||||
counterpart) must reference an `Issued`/`PartiallyReceived` Purchase Order — no receipts against
|
in `Issued`.
|
||||||
a closed or nonexistent PO.
|
2. `createGoodsReceipt` must reference an `Issued`/`PartiallyReceived` PurchaseOrder — no receipts
|
||||||
- `matchInvoiceToPO` (3-way match) must block payment posting in
|
against a `Closed`/`Cancelled`/nonexistent PO. Each receipt increments
|
||||||
[19_finance_accounting.md](./19_finance_accounting.md) until PO, GRN, and invoice quantities/
|
`PurchaseOrderItem.ReceivedQuantity` by `AcceptedQuantity`, credits `StockBalance` at the
|
||||||
amounts reconcile within a configurable tolerance.
|
supplied `warehouseId` (+ a `StockMovement` row, `Type = PurchaseReceipt`), and transitions the
|
||||||
|
PO to `PartiallyReceived` or `Received` (every non-cancelled line's `ReceivedQuantity ==
|
||||||
|
Quantity`).
|
||||||
|
3. `closePurchaseOrder` is only valid once a PO is `Received` (or manually closed early with
|
||||||
|
outstanding quantity written off); `cancelPurchaseOrder` is only valid before any goods receipt
|
||||||
|
exists against the PO.
|
||||||
|
4. `cancelPurchaseOrderLine` cancels a single `PurchaseOrderItem` (`IsCancelled = true`,
|
||||||
|
`CancelledQuantity = Quantity - ReceivedQuantity`) instead of the whole PO. Only valid while
|
||||||
|
`ReceivedQuantity < Quantity` and the line isn't already fully received or cancelled. The PO
|
||||||
|
auto-completes to `Received` once every non-cancelled line is fully received.
|
||||||
|
5. `updatePurchaseOrder` may only change unreceived lines (`ReceivedQuantity == 0`) and only while
|
||||||
|
the PO is `Issued` — a line that has already received stock, or a PO past `Issued`, cannot be
|
||||||
|
edited (use `cancelPurchaseOrderLine`/`createPurchaseReturn` instead).
|
||||||
|
6. `createPurchaseReturn` requires at least one prior `createGoodsReceipt` against the PO; return
|
||||||
|
quantity per item cannot exceed `receivedTotal - alreadyReturned` for that item. Debits
|
||||||
|
`StockBalance` at the PO's (most recent) receiving warehouse (+ `StockMovement`,
|
||||||
|
`Type = PurchaseReturn`).
|
||||||
|
7. `matchInvoiceToPO` performs PO-vs-GRN quantity/cost reconciliation only — no `Invoice` entity
|
||||||
|
exists yet (step 19 Finance, not built), so `invoiceId` is accepted but not compared against
|
||||||
|
anything. Full 3-way match activates once Finance's `Invoice` model exists. Until then it
|
||||||
|
compares `PurchaseOrderItem.Quantity`/`UnitCost` against `GoodsReceiptItem.AcceptedQuantity` per
|
||||||
|
line and returns a match/variance result — it never blocks or adjusts anything itself.
|
||||||
|
8. `resolveInvoiceVariance` is the explicit function that records a variance and its resolution
|
||||||
|
(accept it, request a corrected invoice, or adjust `PurchaseOrderItem.UnitCost` retroactively) —
|
||||||
|
`matchInvoiceToPO` only detects, this function is what acts on a mismatch.
|
||||||
|
9. `evaluateSupplier` is the only function permitted to write `Supplier.PerformanceScore`
|
||||||
|
([14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) rule 4) — implemented in
|
||||||
|
`SupplierService.EvaluateAsync`, scored from that supplier's PO/GRN history (accepted vs
|
||||||
|
rejected quantity ratio, cancelled vs fulfilled line ratio).
|
||||||
|
10. `SupplierService.DeleteAsync` blocks (soft-)deleting a `Supplier` if any active `PurchaseOrder`
|
||||||
|
references it — implemented, closing the previously-open item from
|
||||||
|
[14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md).
|
||||||
|
11. `OrderService.CreateAsync` (`checkStockAvailability`, mainPlan §1) now rejects an order if any
|
||||||
|
line's requested quantity exceeds total `StockBalance` for that item summed across every
|
||||||
|
location — added alongside this step since it was a documented gap. Does not reserve or
|
||||||
|
decrement stock; actual decrement stays deferred to loading/delivery (§04-06).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions to Add to Seed
|
||||||
|
|
||||||
|
**Status: Not implemented, matching Supplier/PriceList (step 14).** All procurement controllers use
|
||||||
|
bare `[Authorize]` (authentication only, no per-action policy) — deliberately kept consistent with
|
||||||
|
how Supplier and PriceList currently ship rather than introducing enforcement piecemeal. Target
|
||||||
|
permission codes for when policy enforcement is turned on across steps 14/15 together:
|
||||||
|
```
|
||||||
|
procurement.view, procurement.create, procurement.update, procurement.receive, procurement.return,
|
||||||
|
suppliers.evaluate
|
||||||
|
```
|
||||||
|
No role assignments seeded yet — would mirror the pricing/supplier tier (AreaManager (30) and
|
||||||
|
above) per 14's existing target design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Is procurement in scope for this project at all right now, or is inbound stock assumed to be
|
- Where should cost-vs-sale-price margin reporting (`PurchaseOrderItem.UnitCost` vs
|
||||||
seeded/manual for the foreseeable future? Confirm before investing in this step — it's the
|
`PriceListItem.Price`/`Item.SalePrice`) live — a read-only endpoint/report in this step, or a
|
||||||
largest net-new subsystem in the reference doc.
|
later analytics/reporting step? Not a blocker for the core PO→GRN flow.
|
||||||
|
- Landed cost (freight, duty, taxes allocated onto received stock) is out of scope —
|
||||||
|
`PurchaseOrderItem.UnitCost` is the only cost basis captured. Defer unless accurate per-unit cost
|
||||||
|
is needed sooner.
|
||||||
|
- Reorder triggers (`setReorderPoint`/`sendLowStockAlert`) are not wired to procurement — restocking
|
||||||
|
stays fully manual until Notifications & Alerts exists.
|
||||||
|
- `createPurchaseReturn` assumes a single receiving warehouse per PO (uses the most recent
|
||||||
|
`GoodsReceipt.FK_WarehouseId`). If a PO is ever received across multiple warehouses, the return
|
||||||
|
needs a `warehouseId` on the request instead of inferring one — not needed yet since nothing
|
||||||
|
splits a receipt across warehouses today.
|
||||||
|
- Permission policy enforcement (`procurement.*`) is deferred — see Permissions to Add to Seed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
### File structure (as built)
|
||||||
|
```
|
||||||
|
Features/
|
||||||
|
PurchaseOrder/
|
||||||
|
PoController.cs — /api/purchase-orders (create/get/list/update/close/cancel/
|
||||||
|
cancel-line/match-invoice/resolve-variance)
|
||||||
|
PoDTOs.cs — CreatePoRequest, UpdatePoRequest(+Line), CancelPoRequest(+Line),
|
||||||
|
PoResponse(+Item), InvoiceMatchResult(+Line), ResolveInvoiceVarianceRequest
|
||||||
|
PoService.cs — createPurchaseOrder, updatePurchaseOrder, closePurchaseOrder,
|
||||||
|
cancelPurchaseOrder, cancelPurchaseOrderLine, matchInvoiceToPO,
|
||||||
|
resolveInvoiceVariance
|
||||||
|
IPoRepository.cs / PoRepository.cs
|
||||||
|
Grn/
|
||||||
|
GrnController.cs — nested under /api/purchase-orders/{id}/goods-receipts,
|
||||||
|
/api/goods-receipts/{id}
|
||||||
|
GrnDTOs.cs — CreateGrnRequest, GrnResponse(+Item)
|
||||||
|
GrnService.cs — createGoodsReceipt (credits StockBalance/StockMovement, updates
|
||||||
|
PurchaseOrderItem.ReceivedQuantity, transitions PO status)
|
||||||
|
IGrnRepository.cs / GrnRepository.cs
|
||||||
|
PurchaseReturn/
|
||||||
|
PurchaseReturnController.cs — nested under /api/purchase-orders/{id}/returns,
|
||||||
|
/api/purchase-returns/{id}
|
||||||
|
PurchaseReturnDTOs.cs
|
||||||
|
PurchaseReturnService.cs — createPurchaseReturn (debits StockBalance/StockMovement)
|
||||||
|
IPurchaseReturnRepository.cs / PurchaseReturnRepository.cs
|
||||||
|
Supplier/
|
||||||
|
SupplierController.cs — extended: POST /{id}/evaluate
|
||||||
|
SupplierService.cs — extended: EvaluateAsync (only writer of PerformanceScore),
|
||||||
|
DeleteAsync now blocks if an active PurchaseOrder references the supplier
|
||||||
|
Order/
|
||||||
|
OrderService.cs — extended: CreateAsync now calls HasSufficientStockAsync
|
||||||
|
(checkStockAvailability) before resolving price/inserting the order
|
||||||
|
|
||||||
|
Models/
|
||||||
|
PurchaseOrder.cs, PurchaseOrderItem.cs (new)
|
||||||
|
GoodsReceipt.cs, GoodsReceiptItem.cs (new)
|
||||||
|
PurchaseReturn.cs, PurchaseReturnItem.cs (new)
|
||||||
|
Enums.cs (extended — PurchaseOrderStatus enum added)
|
||||||
|
|
||||||
|
Data/AppDBContext.cs
|
||||||
|
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
|
||||||
|
|
||||||
|
Migrations/
|
||||||
|
20260824110925_AddProcurement (applied)
|
||||||
|
|
||||||
|
Program.cs
|
||||||
|
IPoRepository/PoRepository, IGrnRepository/GrnRepository,
|
||||||
|
IPurchaseReturnRepository/PurchaseReturnRepository registered; PoService, GrnService,
|
||||||
|
PurchaseReturnService registered (Supplier registrations already existed from step 14)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How the models connect
|
||||||
|
```
|
||||||
|
Supplier (existing, step 14)
|
||||||
|
▲
|
||||||
|
│ FK_SupplierId
|
||||||
|
PurchaseOrder ──► GoodsReceipt ──► StockBalance/StockMovement (Stock Ledger, §02)
|
||||||
|
│ │ (credited: AcceptedQuantity, MovementType.PurchaseReceipt)
|
||||||
|
│ └─ GoodsReceiptItem ─► PurchaseOrderItem
|
||||||
|
│ (AcceptedQuantity increments stock; also increments
|
||||||
|
│ PurchaseOrderItem.ReceivedQuantity, which drives PurchaseOrder.Status)
|
||||||
|
│
|
||||||
|
├─ PurchaseOrderItem (FK_ItemId, Quantity, UnitCost, ReceivedQuantity, CancelledQuantity)
|
||||||
|
│
|
||||||
|
└─ PurchaseReturn ──► PurchaseReturnItem ──► StockBalance/StockMovement
|
||||||
|
(FK_PurchaseOrderId) (FK_ItemId, Quantity; (debited: MovementType.PurchaseReturn,
|
||||||
|
via the PO's receiving at the PO's receiving warehouse)
|
||||||
|
warehouse)
|
||||||
|
|
||||||
|
Invoice (Finance, step 19 — not yet built)
|
||||||
|
└─ matchInvoiceToPO reconciles PurchaseOrderItem vs GoodsReceiptItem only, today (invoiceId
|
||||||
|
accepted but unused); full 3-way match activates once Invoice exists.
|
||||||
|
|
||||||
|
evaluateSupplier reads that supplier's PurchaseOrders/GoodsReceipts (accepted-vs-rejected quantity,
|
||||||
|
cancelled-vs-fulfilled line ratio) and writes Supplier.PerformanceScore — no other function may
|
||||||
|
write it.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Goods receipt flow (as implemented)
|
||||||
|
```
|
||||||
|
GrnService.CreateAsync(purchaseOrderId, request, receivedByUserId):
|
||||||
|
po = load PurchaseOrder with Items, must be Issued or PartiallyReceived else throw
|
||||||
|
for each (purchaseOrderItemId, acceptedQty, rejectedQty) in request.Items:
|
||||||
|
poItem = po.Items.Single(purchaseOrderItemId)
|
||||||
|
outstanding = poItem.Quantity - poItem.ReceivedQuantity - poItem.CancelledQuantity
|
||||||
|
guard acceptedQty + rejectedQty <= outstanding else throw
|
||||||
|
poItem.ReceivedQuantity += acceptedQty
|
||||||
|
credit StockBalance(itemId, request.WarehouseId, Warehouse) += acceptedQty
|
||||||
|
add StockMovement(Type = PurchaseReceipt, ToLocation = warehouse, ReferenceId = goodsReceipt.Id)
|
||||||
|
po.Status = every non-cancelled poItem.ReceivedQuantity == poItem.Quantity
|
||||||
|
? Received : PartiallyReceived
|
||||||
|
commit GoodsReceipt + GoodsReceiptItems + updated PurchaseOrderItem rows + StockBalance/Movement
|
||||||
|
in one transaction (IUnitOfWork.Begin/Commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purchase return flow (as implemented)
|
||||||
|
```
|
||||||
|
PurchaseReturnService.CreateAsync(purchaseOrderId, request, createdByUserId):
|
||||||
|
po = load PurchaseOrder, must have >= 1 GoodsReceipt else throw
|
||||||
|
warehouseId = most recent GoodsReceipt.FK_WarehouseId for this Po
|
||||||
|
for each (itemId, qty) in request.Items:
|
||||||
|
received = sum(GoodsReceiptItem.AcceptedQuantity for itemId across po's receipts)
|
||||||
|
alreadyReturned = sum(PurchaseReturnItem.Quantity for itemId across po's prior returns)
|
||||||
|
guard qty <= received - alreadyReturned else throw
|
||||||
|
debit StockBalance(itemId, warehouseId) -= qty
|
||||||
|
add StockMovement(Type = PurchaseReturn, FromLocation = warehouseId, ReferenceId = purchaseReturn.Id)
|
||||||
|
commit PurchaseReturn + PurchaseReturnItems + StockBalance/Movement in one transaction
|
||||||
|
```
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
| Soft Deletes | Done | IsActive pattern on all entities |
|
| Soft Deletes | Done | IsActive pattern on all entities |
|
||||||
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
| Price Lists / Suppliers | Done | Region-scoped price lists + supplier master; see [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||||
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
| Promotions & Trade Schemes | Done | BuyXGetY/FreeBundle/SlabDiscount/FlatPercent schemes, free-goods fulfillment, spend/ROI tracking; see [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||||
|
| Procurement / Purchasing | Done | Purchase Order → Goods Receipt → Purchase Return, supplier evaluation; see [15_procurement.md](./15_procurement.md) |
|
||||||
|
|
||||||
## Known Gaps in Existing System (Pre-Work)
|
## Known Gaps in Existing System (Pre-Work)
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ They must be resolved before new feature steps begin because later steps depend
|
|||||||
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
||||||
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
||||||
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
| 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) |
|
||||||
| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
| 15 | Procurement / Purchasing | Done | 01, 14 | [15_procurement.md](./15_procurement.md) |
|
||||||
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
||||||
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
| 17 | Promotions & Trade Scheme Management | Done | 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) |
|
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
||||||
@@ -82,9 +83,9 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createOrder(storeId, items[], salesRepId)` | Validates stock/credit, creates a new order in `pending` state. | Partially implemented - `OrderService.CreateAsync` exists, 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. |
|
| `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, applies the unverified-KYC order threshold from config, and now checks stock availability (see `checkStockAvailability` below). It still has no 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. |
|
| `validateCreditLimit(storeId, orderAmount)` | Checks store's outstanding balance + new order value against its credit limit. | Not implemented - `Store` now has `CreditLimit` and `OutstandingBalance`, but there is no dedicated validator or order-time credit-limit enforcement yet. |
|
||||||
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
|
| `checkStockAvailability(items[])` | Confirms requested SKUs/quantities are available at the fulfilling warehouse. | Implemented — `OrderService.CreateAsync` rejects an order if any line's requested quantity exceeds total `StockBalance` for that item summed across every location (added alongside [15_procurement.md](./15_procurement.md)). Not location-specific yet (no fulfilling-warehouse concept on `Store`/`Order`), and does not reserve or decrement stock — that stays deferred to loading/delivery (§05-06). |
|
||||||
| `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 now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. |
|
| `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 now exists as a separate call — `SchemeService.EvaluateEligibilityAsync`/`ApplySchemeAsync` (§11) — but is not yet auto-invoked from `OrderService.CreateAsync`; the caller applies a scheme explicitly via `POST /api/orders/{id}/apply-scheme` after order creation. |
|
||||||
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
| `submitOrderForApproval(orderId)` | Routes the order to the correct approver based on its value/discount threshold. | Not implemented — depends on [08_sales_agent_controls.md](./08_sales_agent_controls.md). |
|
||||||
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
| `approveOrder(orderId, approverId)` / `rejectOrder(orderId, reason)` | Moves order to `approved` or `rejected`, logging the actor. | Not implemented. |
|
||||||
@@ -156,7 +157,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createGoodsReceipt(poId, receivedItems[])` | Records inbound stock against a purchase order (GRN). | Not implemented — depends on §9 Procurement. |
|
| `createGoodsReceipt(poId, receivedItems[])` | Records inbound stock against a purchase order (GRN). | Implemented — `POST /api/purchase-orders/{id}/goods-receipts`, see [15_procurement.md](./15_procurement.md). Credits `StockBalance`/`StockMovement` for accepted quantities and transitions the PO's status. |
|
||||||
| `inspectGoodsReceipt(grnId, inspectionResult)` | Logs quality-check outcome before putaway. | Not implemented. |
|
| `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). |
|
| `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). |
|
| `getStockByLocation(warehouseId, sku)` | Returns real-time quantity and location for a SKU. | Not implemented — depends on [02_stock_ledger.md](./02_stock_ledger.md). |
|
||||||
@@ -195,10 +196,10 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. |
|
| `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. |
|
| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Implemented — `POST /api/purchase-orders`, see [15_procurement.md](./15_procurement.md). Also has `updatePurchaseOrder`, `closePurchaseOrder`, `cancelPurchaseOrder`, `cancelPurchaseOrderLine`. |
|
||||||
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Not implemented. |
|
| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Partially implemented — `POST /api/purchase-orders/{id}/match-invoice` reconciles PurchaseOrderItem vs GoodsReceiptItem quantities/cost only; full 3-way match activates once Finance's `Invoice` entity (§13, not built) exists. `resolveInvoiceVariance` records the mismatch and its resolution. |
|
||||||
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Not implemented. |
|
| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Implemented — `POST /api/suppliers/{id}/evaluate`, the only writer of `Supplier.PerformanceScore`. Scored from accepted-vs-rejected quantity ratio and cancelled-vs-fulfilled line ratio across that supplier's PurchaseOrders/GoodsReceipts. |
|
||||||
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Not implemented. |
|
| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Implemented — `POST /api/purchase-orders/{id}/returns`, see [15_procurement.md](./15_procurement.md). Debits `StockBalance`/`StockMovement` at the PO's receiving warehouse. |
|
||||||
|
|
||||||
### 10. Sales Force Automation (SFA) / CRM
|
### 10. Sales Force Automation (SFA) / CRM
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user